amicus 4.1.2 → 4.2.0
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 +25 -0
- package/README.md +20 -1
- package/bin/amicus.js +10 -0
- package/electron/ipc-setup-local.js +109 -0
- package/electron/ipc-setup.js +14 -2
- package/electron/preload-setup.js +3 -1
- package/electron/setup-ui-local-script.js +114 -0
- package/electron/setup-ui-local.js +75 -0
- package/electron/setup-ui-styles.js +15 -1
- package/electron/setup-ui.js +6 -1
- package/package.json +1 -1
- package/scripts/postinstall.js +12 -211
- package/src/cli-handlers-doctor.js +12 -0
- package/src/cli-handlers-init.js +83 -0
- package/src/cli-handlers-key-local.js +144 -0
- package/src/cli-handlers-provider.js +212 -0
- package/src/cli-handlers.js +31 -3
- package/src/cli.js +21 -0
- package/src/sidecar/setup-local.js +75 -0
- package/src/sidecar/setup.js +99 -3
- package/src/utils/api-key-store.js +12 -62
- package/src/utils/claude-register.js +267 -0
- package/src/utils/config.js +53 -1
- package/src/utils/doctor-local-providers-check.js +62 -0
- package/src/utils/doctor-summary.js +33 -0
- package/src/utils/env-loader.js +13 -0
- package/src/utils/env-raw-store.js +111 -0
- package/src/utils/gateway-router.js +65 -2
- package/src/utils/lifecycle.js +2 -1
- package/src/utils/local-probe.js +109 -0
- package/src/utils/local-providers.js +141 -0
- package/src/utils/model-catalog.js +6 -2
- package/src/utils/model-fetcher.js +11 -1
- package/src/utils/pricing.js +23 -9
- package/src/utils/provider-default-picker.js +17 -2
- package/src/utils/provider-default-prompt.js +7 -4
- package/src/utils/route-error.js +10 -3
- package/src/utils/route-launch.js +8 -65
- package/src/utils/route-suggestions.js +85 -0
|
@@ -6,6 +6,12 @@ const fs = require('fs');
|
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const { validateApiKey, validateOpenRouterKey, VALIDATION_ENDPOINTS } = require('./api-key-validation');
|
|
8
8
|
const { PROVIDER_ENV_MAP } = require('./provider-registry');
|
|
9
|
+
// v4.2 (B2/D3): the .env line-merge logic lives once in env-raw-store.js. saveApiKey/
|
|
10
|
+
// removeApiKey below reuse it via upsertEnvLine/deleteEnvLine; saveRawEnv/removeRawEnv
|
|
11
|
+
// (arbitrary-name local-provider bearer writes) are re-exported so every documented
|
|
12
|
+
// `require('./api-key-store').saveRawEnv` call site keeps working. No load-time cycle:
|
|
13
|
+
// env-raw-store requires THIS module only lazily, inside its functions.
|
|
14
|
+
const { saveRawEnv, removeRawEnv, upsertEnvLine, deleteEnvLine } = require('./env-raw-store');
|
|
9
15
|
|
|
10
16
|
/** Legacy key names that have been renamed (old -> new) */
|
|
11
17
|
const LEGACY_KEY_NAMES = {
|
|
@@ -143,47 +149,10 @@ function saveApiKey(provider, key) {
|
|
|
143
149
|
if (!envVar) {
|
|
144
150
|
return { success: false, error: `Unknown provider: ${provider}` };
|
|
145
151
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const envDir = path.dirname(envPath);
|
|
149
|
-
fs.mkdirSync(envDir, { recursive: true });
|
|
150
|
-
|
|
151
|
-
// Read existing .env content, preserving comments and other lines
|
|
152
|
-
let lines = [];
|
|
153
|
-
try {
|
|
154
|
-
if (fs.existsSync(envPath)) {
|
|
155
|
-
const content = fs.readFileSync(envPath, 'utf-8');
|
|
156
|
-
lines = content.split('\n');
|
|
157
|
-
}
|
|
158
|
-
} catch (_err) {
|
|
159
|
-
// Start fresh
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// Find and replace the line for this env var, or append
|
|
163
|
-
let found = false;
|
|
164
|
-
for (let i = 0; i < lines.length; i++) {
|
|
165
|
-
const trimmed = lines[i].trim();
|
|
166
|
-
if (trimmed.startsWith(envVar + '=')) {
|
|
167
|
-
lines[i] = `${envVar}=${key}`;
|
|
168
|
-
found = true;
|
|
169
|
-
break;
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
if (!found) {
|
|
173
|
-
// Remove trailing empty lines before appending
|
|
174
|
-
while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
|
|
175
|
-
lines.pop();
|
|
176
|
-
}
|
|
177
|
-
lines.push(`${envVar}=${key}`);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// Write with trailing newline
|
|
181
|
-
const output = lines.join('\n') + '\n';
|
|
182
|
-
fs.writeFileSync(envPath, output, { mode: 0o600 });
|
|
183
|
-
|
|
152
|
+
// Shared merge helper (preserves comments/other lines, dedups, 0600, trailing NL).
|
|
153
|
+
upsertEnvLine(getEnvPath(), envVar, key);
|
|
184
154
|
// Also set process.env so the key is immediately available
|
|
185
155
|
process.env[envVar] = key;
|
|
186
|
-
|
|
187
156
|
return { success: true };
|
|
188
157
|
}
|
|
189
158
|
|
|
@@ -194,29 +163,8 @@ function removeApiKey(provider) {
|
|
|
194
163
|
return { success: false, error: `Unknown provider: ${provider}` };
|
|
195
164
|
}
|
|
196
165
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
if (!fs.existsSync(envPath)) {
|
|
200
|
-
const { checkAuthJson } = require('./auth-json');
|
|
201
|
-
delete process.env[envVar];
|
|
202
|
-
return { success: true, alsoInAuthJson: checkAuthJson(provider) };
|
|
203
|
-
}
|
|
204
|
-
const content = fs.readFileSync(envPath, 'utf-8');
|
|
205
|
-
const lines = content.split('\n').filter(line => {
|
|
206
|
-
return !line.trim().startsWith(envVar + '=');
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
// Remove trailing empty lines
|
|
210
|
-
while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
|
|
211
|
-
lines.pop();
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
const output = lines.length > 0 ? lines.join('\n') + '\n' : '';
|
|
215
|
-
fs.writeFileSync(envPath, output, { mode: 0o600 });
|
|
216
|
-
} catch (_err) {
|
|
217
|
-
// Ignore
|
|
218
|
-
}
|
|
219
|
-
|
|
166
|
+
// Shared merge helper (no-op when the file is absent; preserves other lines, 0600).
|
|
167
|
+
deleteEnvLine(getEnvPath(), envVar);
|
|
220
168
|
delete process.env[envVar];
|
|
221
169
|
|
|
222
170
|
// Check if key also exists in auth.json (caller decides whether to clean)
|
|
@@ -232,6 +180,8 @@ module.exports = {
|
|
|
232
180
|
readApiKeyValues,
|
|
233
181
|
saveApiKey,
|
|
234
182
|
removeApiKey,
|
|
183
|
+
saveRawEnv,
|
|
184
|
+
removeRawEnv,
|
|
235
185
|
validateApiKey,
|
|
236
186
|
validateOpenRouterKey,
|
|
237
187
|
PROVIDER_ENV_MAP,
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module claude-register
|
|
3
|
+
* Registration core for amicus: skill install (chat skill + LLM Council),
|
|
4
|
+
* MCP server registration in Claude Code / Claude Desktop, and legacy MCP
|
|
5
|
+
* migration. Extracted from scripts/postinstall.js (Task 15, v4.2 §4.8/C2) so
|
|
6
|
+
* the SAME core can run at `npm install` time (via postinstall.js, now a
|
|
7
|
+
* thin re-exporting consumer of this module) AND on demand via `amicus init`
|
|
8
|
+
* (src/cli-handlers-init.js) — e.g. after a failed postinstall, a
|
|
9
|
+
* plugin-channel / --ignore-scripts install, or to repair deleted ~/.claude
|
|
10
|
+
* state.
|
|
11
|
+
*
|
|
12
|
+
* Behavior is byte-identical to the pre-extraction scripts/postinstall.js,
|
|
13
|
+
* with one deliberate exception (M18): registerClaudeCode/registerClaudeDesktop
|
|
14
|
+
* now RETURN their 'added'|'updated'|'unchanged' status instead of computing
|
|
15
|
+
* it and discarding it, so callers (amicus init) get real per-step status.
|
|
16
|
+
* scripts/postinstall.js's own callers ignore the return value, so this is
|
|
17
|
+
* behavior-safe for postinstall's "byte-identical" claim.
|
|
18
|
+
*/
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
const os = require('os');
|
|
24
|
+
const { execFileSync } = require('child_process');
|
|
25
|
+
|
|
26
|
+
// B13: sibling of this file under src/utils/ — was '../src/utils/mcp-self-identity'
|
|
27
|
+
// when this code lived in scripts/postinstall.js.
|
|
28
|
+
const { isAmicusMcpConfig } = require('./mcp-self-identity');
|
|
29
|
+
|
|
30
|
+
// B13: this file now lives at src/utils/, two levels below the repo root
|
|
31
|
+
// (scripts/ was only one level below) — both source paths need an extra '..'.
|
|
32
|
+
const SKILL_SOURCE = path.join(__dirname, '..', '..', 'skills', 'sidecar', 'SKILL.md');
|
|
33
|
+
const COUNCIL_SOURCE_DIR = path.join(__dirname, '..', '..', 'skills', 'second-opinion');
|
|
34
|
+
|
|
35
|
+
/** Council files + per-file install semantics: SKILL/COUNCIL-DESIGN/SEAT-BRIEFS are
|
|
36
|
+
* product code (overwrite on update, so upgrades keep them in sync with the package);
|
|
37
|
+
* MODEL-NOTES is user data — its reviewer-reliability table evolves per-run, so it is
|
|
38
|
+
* seeded once and never clobbered. */
|
|
39
|
+
const COUNCIL_FILES = [
|
|
40
|
+
{ file: 'SKILL.md', mode: 'overwrite' },
|
|
41
|
+
{ file: 'COUNCIL-DESIGN.md', mode: 'overwrite' },
|
|
42
|
+
{ file: 'SEAT-BRIEFS.md', mode: 'overwrite' },
|
|
43
|
+
{ file: 'MANUAL-ORCHESTRATION.md', mode: 'overwrite' },
|
|
44
|
+
{ file: 'MODEL-NOTES.md', mode: 'if-missing' },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
function skillsRoot() {
|
|
48
|
+
return path.join(os.homedir(), '.claude', 'skills');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const MCP_CONFIG = { command: 'npx', args: ['-y', 'amicus@latest', 'mcp'] };
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Add or update an MCP server in a JSON config file.
|
|
55
|
+
*
|
|
56
|
+
* Refreshes command/args on every install/upgrade. If the EXISTING entry at
|
|
57
|
+
* this key is already amicus-shaped (per isAmicusMcpConfig — Phase 1's single
|
|
58
|
+
* source of truth for "this is amicus"), we MERGE instead of overwrite: the
|
|
59
|
+
* user's `env` (and any other extra keys, e.g. a future `cwd`) survive the
|
|
60
|
+
* refresh. Without this, `npm i -g amicus` silently wiped
|
|
61
|
+
* "env": {"AMICUS_LEGACY_ALIASES":"1"} — the exact opt-in escape hatch Phase 4
|
|
62
|
+
* tells users to add — on every upgrade.
|
|
63
|
+
*
|
|
64
|
+
* A NON-amicus-shaped entry at this key is overwritten as before: 'amicus' is
|
|
65
|
+
* a reserved registration name, so a foreign entry there is reclaimed rather
|
|
66
|
+
* than merged with.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} configPath - Path to the JSON config file
|
|
69
|
+
* @param {string} name - MCP server name
|
|
70
|
+
* @param {object} config - MCP server config object
|
|
71
|
+
* @returns {string} 'added', 'updated', or 'unchanged'
|
|
72
|
+
*/
|
|
73
|
+
function addMcpToConfigFile(configPath, name, config) {
|
|
74
|
+
let existing = {};
|
|
75
|
+
try {
|
|
76
|
+
existing = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
77
|
+
} catch {
|
|
78
|
+
// File doesn't exist or invalid JSON — start fresh
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!existing.mcpServers) { existing.mcpServers = {}; }
|
|
82
|
+
|
|
83
|
+
const prev = existing.mcpServers[name];
|
|
84
|
+
const nextConfig = (prev && isAmicusMcpConfig(prev)) ? { ...prev, ...config } : config;
|
|
85
|
+
const status = !prev ? 'added' : JSON.stringify(prev) !== JSON.stringify(nextConfig) ? 'updated' : 'unchanged';
|
|
86
|
+
|
|
87
|
+
existing.mcpServers[name] = nextConfig;
|
|
88
|
+
if (status !== 'unchanged') {
|
|
89
|
+
const dir = path.dirname(configPath);
|
|
90
|
+
if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); }
|
|
91
|
+
fs.writeFileSync(configPath, JSON.stringify(existing, null, 2), { mode: 0o600 });
|
|
92
|
+
}
|
|
93
|
+
return status;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Install the chat skill to ~/.claude/skills/sidecar/ */
|
|
97
|
+
function installSkill() {
|
|
98
|
+
try {
|
|
99
|
+
const destDir = path.join(skillsRoot(), 'sidecar');
|
|
100
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
101
|
+
fs.copyFileSync(SKILL_SOURCE, path.join(destDir, 'SKILL.md'));
|
|
102
|
+
console.log('[amicus] Chat skill installed to ~/.claude/skills/sidecar/');
|
|
103
|
+
} catch (err) {
|
|
104
|
+
console.error(`[amicus] Warning: Could not install chat skill: ${err.message}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Install the LLM Council skill to ~/.claude/skills/second-opinion/ */
|
|
109
|
+
function installCouncilSkill(sourceDir = COUNCIL_SOURCE_DIR) {
|
|
110
|
+
const destDir = path.join(skillsRoot(), 'second-opinion');
|
|
111
|
+
try {
|
|
112
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
113
|
+
} catch (err) {
|
|
114
|
+
console.error(`[amicus] Warning: Could not create council skill dir: ${err.message}`);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
let failed = 0;
|
|
118
|
+
for (const { file, mode } of COUNCIL_FILES) {
|
|
119
|
+
try {
|
|
120
|
+
const dest = path.join(destDir, file);
|
|
121
|
+
if (mode === 'if-missing' && fs.existsSync(dest)) { continue; }
|
|
122
|
+
fs.copyFileSync(path.join(sourceDir, file), dest);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
failed++;
|
|
125
|
+
console.error(`[amicus] Warning: Could not install council file ${file}: ${err.message}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (failed === COUNCIL_FILES.length) {
|
|
129
|
+
console.error('[amicus] Warning: Council skill NOT installed (all files failed — see warnings above).');
|
|
130
|
+
} else if (failed > 0) {
|
|
131
|
+
console.log(`[amicus] Council skill partially installed (${failed}/${COUNCIL_FILES.length} files failed — see warnings above).`);
|
|
132
|
+
} else {
|
|
133
|
+
console.log('[amicus] Council skill installed to ~/.claude/skills/second-opinion/');
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Read the previous 'amicus' entry from ~/.claude.json, if any — used by the
|
|
139
|
+
* CLI add-json path to merge env the same way the file-fallback path does.
|
|
140
|
+
* Never throws: a missing/unreadable file just means "no previous entry".
|
|
141
|
+
* @returns {object|undefined}
|
|
142
|
+
*/
|
|
143
|
+
function readPrevClaudeCodeAmicusEntry() {
|
|
144
|
+
try {
|
|
145
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude.json'), 'utf-8'));
|
|
146
|
+
return parsed && parsed.mcpServers ? parsed.mcpServers.amicus : undefined;
|
|
147
|
+
} catch {
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Register MCP server in Claude Code config.
|
|
154
|
+
* @returns {'added'|'updated'|'unchanged'} the registration status (M18: both
|
|
155
|
+
* branches now report a real status instead of discarding it).
|
|
156
|
+
*/
|
|
157
|
+
function registerClaudeCode() {
|
|
158
|
+
// Try the CLI first
|
|
159
|
+
try {
|
|
160
|
+
// Merge the PREVIOUS registration's env into the add-json payload — same
|
|
161
|
+
// merge semantics as addMcpToConfigFile's file-fallback path (`{ ...prev,
|
|
162
|
+
// ...config }`: prev env keys survive, canonical MCP_CONFIG keys win on
|
|
163
|
+
// collision). Without this, a user's custom env (API key, AMICUS_* tuning
|
|
164
|
+
// knobs) on the old registration was silently dropped whenever the
|
|
165
|
+
// `claude` CLI was present, because the CLI path built its JSON payload
|
|
166
|
+
// from the bare MCP_CONFIG and delegated overwrite semantics to the
|
|
167
|
+
// claude binary — which has no idea about the user's previous entry.
|
|
168
|
+
const prev = readPrevClaudeCodeAmicusEntry();
|
|
169
|
+
const nextConfig = (prev && isAmicusMcpConfig(prev)) ? { ...prev, ...MCP_CONFIG } : MCP_CONFIG;
|
|
170
|
+
// M18: the same status formula addMcpToConfigFile uses, computed from
|
|
171
|
+
// data already in hand. The `claude` CLI itself reports no status, but we
|
|
172
|
+
// know exactly what it is about to write (nextConfig) vs what was there
|
|
173
|
+
// before (prev) — add-json writes to the SAME key the file-fallback path
|
|
174
|
+
// below would, so this is the truth, not a guess.
|
|
175
|
+
const status = !prev ? 'added' : JSON.stringify(prev) !== JSON.stringify(nextConfig) ? 'updated' : 'unchanged';
|
|
176
|
+
const mcpJson = JSON.stringify(nextConfig);
|
|
177
|
+
execFileSync('claude', ['mcp', 'add-json', 'amicus', mcpJson, '--scope', 'user'], {
|
|
178
|
+
stdio: 'pipe',
|
|
179
|
+
timeout: 10000,
|
|
180
|
+
});
|
|
181
|
+
console.log('[amicus] MCP registered in Claude Code (via CLI).');
|
|
182
|
+
|
|
183
|
+
return status;
|
|
184
|
+
} catch {
|
|
185
|
+
// CLI not available or failed — fall back to file edit
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Fallback: direct file edit
|
|
189
|
+
const claudeConfigPath = path.join(os.homedir(), '.claude.json');
|
|
190
|
+
const status = addMcpToConfigFile(claudeConfigPath, 'amicus', MCP_CONFIG);
|
|
191
|
+
if (status === 'added') {
|
|
192
|
+
console.log('[amicus] MCP registered in Claude Code (~/.claude.json).');
|
|
193
|
+
} else if (status === 'updated') {
|
|
194
|
+
console.log('[amicus] MCP config updated in Claude Code (~/.claude.json).');
|
|
195
|
+
} else {
|
|
196
|
+
console.log('[amicus] MCP already registered in Claude Code.');
|
|
197
|
+
}
|
|
198
|
+
return status;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Register MCP server in Claude Desktop / Cowork config.
|
|
203
|
+
* @returns {'added'|'updated'|'unchanged'} the registration status (M18: was
|
|
204
|
+
* computed and used for the console.log branch below, then discarded).
|
|
205
|
+
*/
|
|
206
|
+
function registerClaudeDesktop() {
|
|
207
|
+
let configDir;
|
|
208
|
+
if (process.platform === 'darwin') {
|
|
209
|
+
configDir = path.join(os.homedir(), 'Library', 'Application Support', 'Claude');
|
|
210
|
+
} else if (process.platform === 'win32') {
|
|
211
|
+
configDir = path.join(process.env.APPDATA || '', 'Claude');
|
|
212
|
+
} else {
|
|
213
|
+
configDir = path.join(os.homedir(), '.config', 'claude');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const configPath = path.join(configDir, 'claude_desktop_config.json');
|
|
217
|
+
const status = addMcpToConfigFile(configPath, 'amicus', MCP_CONFIG);
|
|
218
|
+
if (status === 'added') {
|
|
219
|
+
console.log('[amicus] MCP registered in Claude Desktop.');
|
|
220
|
+
} else if (status === 'updated') {
|
|
221
|
+
console.log('[amicus] MCP config updated in Claude Desktop.');
|
|
222
|
+
} else {
|
|
223
|
+
console.log('[amicus] MCP already registered in Claude Desktop.');
|
|
224
|
+
}
|
|
225
|
+
return status;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* One-shot migration: drop the duplicate legacy 'sidecar' MCP entry that
|
|
230
|
+
* pre-1.8 postinstalls registered alongside 'amicus' (same server twice —
|
|
231
|
+
* doubled the client-visible tool list). Only removes an entry whose command
|
|
232
|
+
* is an amicus MCP invocation; a customized 'sidecar' entry is left alone.
|
|
233
|
+
* Covers both files the three legacy registration paths wrote to:
|
|
234
|
+
* ~/.claude.json (CLI + file fallback) and claude_desktop_config.json.
|
|
235
|
+
* Never throws (postinstall must always exit 0).
|
|
236
|
+
*/
|
|
237
|
+
function migrateLegacyMcp(deps = {}) {
|
|
238
|
+
try {
|
|
239
|
+
// B13: sibling of this file under src/utils/ — was
|
|
240
|
+
// '../src/utils/legacy-mcp-migration' when this lived in scripts/postinstall.js.
|
|
241
|
+
const impl = deps.migrateLegacySidecar
|
|
242
|
+
|| require('./legacy-mcp-migration').migrateLegacySidecar;
|
|
243
|
+
for (const r of impl()) {
|
|
244
|
+
if (r.result === 'removed') {
|
|
245
|
+
console.log(`[amicus] Removed duplicate legacy 'sidecar' MCP entry from ${r.target} (same server — kept as 'amicus').`);
|
|
246
|
+
} else if (r.result === 'customized') {
|
|
247
|
+
console.log(`[amicus] Kept custom 'sidecar' MCP entry in ${r.target} (does not point at amicus).`);
|
|
248
|
+
} else if (r.result === 'write-failed') {
|
|
249
|
+
console.warn(`[amicus] Warning: could not remove the legacy 'sidecar' MCP entry from ${r.target} — run: amicus doctor --fix`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
} catch (err) {
|
|
253
|
+
console.warn(`[amicus] Warning: legacy MCP cleanup skipped: ${err && err.message}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
module.exports = {
|
|
258
|
+
MCP_CONFIG,
|
|
259
|
+
addMcpToConfigFile,
|
|
260
|
+
installSkill,
|
|
261
|
+
installCouncilSkill,
|
|
262
|
+
readPrevClaudeCodeAmicusEntry,
|
|
263
|
+
registerClaudeCode,
|
|
264
|
+
registerClaudeDesktop,
|
|
265
|
+
migrateLegacyMcp,
|
|
266
|
+
COUNCIL_FILES,
|
|
267
|
+
};
|
package/src/utils/config.js
CHANGED
|
@@ -240,6 +240,17 @@ function tryResolveModel(modelArg) {
|
|
|
240
240
|
}
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Request timeout (ms) for local (@ai-sdk/openai-compatible) provider blocks.
|
|
245
|
+
* opencode's default HTTP request timeout is too short for cold local
|
|
246
|
+
* inference: prefilling a large agent prompt (~26k tokens) on a cold local
|
|
247
|
+
* model can exceed it, killing the request during prefill before any stream
|
|
248
|
+
* data arrives -- opencode never creates the assistant message, and the
|
|
249
|
+
* caller polls out its own much longer timeout waiting for a response that
|
|
250
|
+
* will never come. 300000ms (5 minutes) covers cold-start prefill headroom.
|
|
251
|
+
*/
|
|
252
|
+
const LOCAL_REQUEST_TIMEOUT_MS = 300000;
|
|
253
|
+
|
|
243
254
|
/** Build OpenCode provider.models config from sidecar aliases, plus the
|
|
244
255
|
* actually-resolved launch route(s). The alias-derived entries let the UI
|
|
245
256
|
* model picker show every configured model (single source of truth for the
|
|
@@ -289,7 +300,16 @@ function buildProviderModels(resolvedRoutes = []) {
|
|
|
289
300
|
const providerID = parts[0];
|
|
290
301
|
const modelID = parts.slice(1).join('/');
|
|
291
302
|
|
|
292
|
-
|
|
303
|
+
// providerID is an unvalidated vendor segment split off a user-supplied
|
|
304
|
+
// model string (alias or resolved route). A bare `!providers[providerID]`
|
|
305
|
+
// check walks the prototype chain, so a vendor literally named
|
|
306
|
+
// 'constructor' (a valid, non-reserved local-provider id — see
|
|
307
|
+
// local-providers.js's ID_RE/RESERVED_IDS) reads the inherited
|
|
308
|
+
// Object.prototype.constructor (truthy), skips the init below, and the
|
|
309
|
+
// next line throws on the resulting undefined. Same bug class already
|
|
310
|
+
// fixed in gateway-router.js (19aade4) and local-providers.js +
|
|
311
|
+
// route-suggestions.js (2cba73b).
|
|
312
|
+
if (!Object.prototype.hasOwnProperty.call(providers, providerID)) {
|
|
293
313
|
providers[providerID] = { models: {} };
|
|
294
314
|
}
|
|
295
315
|
providers[providerID].models[modelID] = {};
|
|
@@ -324,6 +344,31 @@ function buildProviderModels(resolvedRoutes = []) {
|
|
|
324
344
|
addRoute(resolved);
|
|
325
345
|
}
|
|
326
346
|
|
|
347
|
+
// v4.2 §4.3: for every provider id that is a configured LOCAL provider AND was
|
|
348
|
+
// registered by the loops above, attach the OpenCode openai-compatible block.
|
|
349
|
+
// {env:VAR} interpolation keeps key material out of the config object.
|
|
350
|
+
const { getLocalProviders } = require('./local-providers');
|
|
351
|
+
const localAll = getLocalProviders();
|
|
352
|
+
for (const [id, block] of Object.entries(providers)) {
|
|
353
|
+
// Guarded the same way as the accumulator init above: `localAll` is a
|
|
354
|
+
// plain {} when no local providers are configured, so a bare
|
|
355
|
+
// `localAll[id]` for id === 'constructor' would read the inherited
|
|
356
|
+
// Object.prototype.constructor (truthy) and fabricate a fake local block
|
|
357
|
+
// for a vendor that isn't actually configured as local.
|
|
358
|
+
const entry = Object.prototype.hasOwnProperty.call(localAll, id) ? localAll[id] : undefined;
|
|
359
|
+
if (!entry) { continue; }
|
|
360
|
+
block.npm = '@ai-sdk/openai-compatible';
|
|
361
|
+
block.name = entry.name || id;
|
|
362
|
+
block.options = {
|
|
363
|
+
baseURL: entry.baseURL,
|
|
364
|
+
// @ai-sdk/openai-compatible wants a non-empty apiKey string even for
|
|
365
|
+
// servers that don't require auth (Ollama/LM Studio/llama.cpp ignore
|
|
366
|
+
// it) -- an omitted apiKey is not the same as an accepted empty one.
|
|
367
|
+
apiKey: entry.apiKeyEnv ? `{env:${entry.apiKeyEnv}}` : 'not-needed',
|
|
368
|
+
timeout: LOCAL_REQUEST_TIMEOUT_MS,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
327
372
|
return providers;
|
|
328
373
|
}
|
|
329
374
|
|
|
@@ -383,11 +428,18 @@ function resolveCouncilMembers(name, catalog = []) {
|
|
|
383
428
|
}
|
|
384
429
|
const aliases = getEffectiveAliases();
|
|
385
430
|
const known = new Set((Array.isArray(catalog) ? catalog : []).map(m => m && m.id).filter(Boolean));
|
|
431
|
+
const { isLocalProvider } = require('./local-providers');
|
|
386
432
|
const models = [];
|
|
387
433
|
const dropped = [];
|
|
388
434
|
for (const member of members) {
|
|
389
435
|
const id = member.includes('/') ? member : aliases[member];
|
|
390
436
|
if (!id) { dropped.push(member); continue; } // alias no longer resolves
|
|
437
|
+
const vendor = typeof id === 'string' ? id.split('/')[0] : '';
|
|
438
|
+
// v4.2 §4.4: a local server may simply have been off at the last catalog
|
|
439
|
+
// refresh — that is "unknown", not "delisted". Never drop a local-vendor
|
|
440
|
+
// member on catalog absence; the leg itself fails pre-flight with the
|
|
441
|
+
// actionable local_endpoint_unreachable error if the server is truly down.
|
|
442
|
+
if (isLocalProvider(vendor)) { models.push(member); continue; }
|
|
391
443
|
if (known.size > 0 && !known.has(id)) { dropped.push(member); continue; } // delisted model
|
|
392
444
|
models.push(member);
|
|
393
445
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module doctor-local-providers-check
|
|
3
|
+
* The `local-providers` doctor check (v4.2 §4.7 C8), split out of
|
|
4
|
+
* src/cli-handlers-doctor.js to keep that file under the 300-line size gate
|
|
5
|
+
* (mirrors doctor-mcp-checks.js / doctor-engine-check.js).
|
|
6
|
+
*
|
|
7
|
+
* Reachability is WARN-never-ERROR (parity with the electron and
|
|
8
|
+
* openrouter-credit checks): a configured-but-unreachable local server (e.g.
|
|
9
|
+
* `ollama serve` simply not running) is informational, not a doctor failure
|
|
10
|
+
* that flips `amicus doctor`'s exit code. No configured local providers at
|
|
11
|
+
* all is a plain 'ok' ("none configured"), never a warning.
|
|
12
|
+
*
|
|
13
|
+
* Each configured provider is probed once, bounded by probeLocalProvider's
|
|
14
|
+
* own timeout (2s here) -- doctor never introduces an unbounded wait, no
|
|
15
|
+
* matter how many local providers are configured.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {{getLocalProviders: () => Object<string, {id:string, baseURL:string, flavor?:string, apiKeyEnv?:string}>, probeLocalProvider: (entry:object, opts:{timeoutMs:number, bearer?:string}) => Promise<{status:'ok'|'unreachable', models:string[]}>}} d
|
|
22
|
+
* @returns {Promise<{id:string, name:string, status:string, message:string, hint:?string}>}
|
|
23
|
+
*/
|
|
24
|
+
async function evaluateLocalProviders(d) {
|
|
25
|
+
const id = 'local-providers';
|
|
26
|
+
const name = 'Local providers';
|
|
27
|
+
const map = d.getLocalProviders();
|
|
28
|
+
// Object.keys (never `for..in` / `map.hasOwnProperty`) -- local-providers.js
|
|
29
|
+
// stamps entries on via a plain `out[id] = ...` assignment, so a provider id
|
|
30
|
+
// like 'constructor' is a real OWN property that shadows the inherited one;
|
|
31
|
+
// Object.keys + bracket access below resolves it correctly either way.
|
|
32
|
+
const ids = Object.keys(map);
|
|
33
|
+
if (ids.length === 0) {
|
|
34
|
+
return { id, name, status: 'ok', message: 'none configured', hint: null };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const parts = [];
|
|
38
|
+
let anyDown = false;
|
|
39
|
+
for (const providerId of ids) {
|
|
40
|
+
const entry = map[providerId];
|
|
41
|
+
const bearer = entry.apiKeyEnv ? process.env[entry.apiKeyEnv] : undefined;
|
|
42
|
+
const probe = await d.probeLocalProvider(entry, { timeoutMs: 2000, bearer });
|
|
43
|
+
if (probe.status === 'ok') {
|
|
44
|
+
parts.push(`${providerId}: ${probe.models.length} models @ ${entry.baseURL}`);
|
|
45
|
+
} else {
|
|
46
|
+
anyDown = true;
|
|
47
|
+
parts.push(`${providerId}: unreachable @ ${entry.baseURL}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// WARN, never ERROR (parity with electron/openrouter-credit -- a napping
|
|
52
|
+
// local server must not fail CI).
|
|
53
|
+
const flavorHint = ids.map((i) => map[i].flavor).find((f) => f === 'lmstudio')
|
|
54
|
+
? 'Start the LM Studio server (Developer → Start Server), or `ollama serve`.'
|
|
55
|
+
: 'Start the local server, e.g. `ollama serve`.';
|
|
56
|
+
|
|
57
|
+
return anyDown
|
|
58
|
+
? { id, name, status: 'warn', message: parts.join('; '), hint: flavorHint }
|
|
59
|
+
: { id, name, status: 'ok', message: parts.join('; '), hint: null };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { evaluateLocalProviders };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module doctor-summary
|
|
3
|
+
* Compact doctor summary (v4.2 §4.7 C8). All-ok -> one line; otherwise the
|
|
4
|
+
* non-ok lines + counts + a pointer to the full command. Shared by the setup
|
|
5
|
+
* wizard finale (src/sidecar/setup.js) and, next, `amicus init` (Task 15) --
|
|
6
|
+
* kept in its own leaf module (rather than inside cli-handlers-doctor.js) so
|
|
7
|
+
* both callers can require it without pulling in the doctor check machinery,
|
|
8
|
+
* and so cli-handlers-doctor.js stays under the 300-line size gate.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {Array<{status:string,name?:string,id?:string,message?:string}>} checks
|
|
15
|
+
* @returns {string}
|
|
16
|
+
*/
|
|
17
|
+
function summarizeDoctor(checks) {
|
|
18
|
+
const list = Array.isArray(checks) ? checks : [];
|
|
19
|
+
const nonOk = list.filter((c) => c && c.status !== 'ok');
|
|
20
|
+
if (nonOk.length === 0) {
|
|
21
|
+
return `doctor: all ${list.length} checks pass`;
|
|
22
|
+
}
|
|
23
|
+
const errors = nonOk.filter((c) => c.status === 'error').length;
|
|
24
|
+
const warns = nonOk.filter((c) => c.status === 'warn').length;
|
|
25
|
+
const lines = nonOk.map((c) => {
|
|
26
|
+
const marker = c.status === 'error' ? '✗' : '⚠';
|
|
27
|
+
return ` ${marker} ${c.name || c.id}: ${c.message || ''}`.trimEnd();
|
|
28
|
+
});
|
|
29
|
+
lines.push(`${errors} error(s), ${warns} warning(s) — run \`amicus doctor\` for details`);
|
|
30
|
+
return lines.join('\n');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { summarizeDoctor };
|
package/src/utils/env-loader.js
CHANGED
|
@@ -48,6 +48,19 @@ function loadCredentials() {
|
|
|
48
48
|
logger.info(`Loaded ${envVar} from auth.json`);
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
|
+
|
|
52
|
+
// v4.2: project each configured local-provider apiKeyEnv from amicus .env into
|
|
53
|
+
// process.env (same never-overwrite rule) so the engine's {env:VAR} finds it.
|
|
54
|
+
try {
|
|
55
|
+
const { getLocalProviders } = require('./local-providers');
|
|
56
|
+
const fileEntries = loadEnvEntries();
|
|
57
|
+
for (const entry of Object.values(getLocalProviders())) {
|
|
58
|
+
if (entry.apiKeyEnv && !process.env[entry.apiKeyEnv]) {
|
|
59
|
+
const v = fileEntries.get(entry.apiKeyEnv);
|
|
60
|
+
if (v && v.length > 0) { process.env[entry.apiKeyEnv] = v; logger.info(`Loaded ${entry.apiKeyEnv} from amicus .env`); }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
} catch (_err) { /* best-effort: a bad providers block must never break credential load */ }
|
|
51
64
|
}
|
|
52
65
|
|
|
53
66
|
module.exports = { loadCredentials };
|