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
package/scripts/postinstall.js
CHANGED
|
@@ -6,229 +6,30 @@
|
|
|
6
6
|
* 1. Copies SKILL.md to ~/.claude/skills/sidecar/
|
|
7
7
|
* 2. Registers MCP server in Claude Code (~/.claude.json)
|
|
8
8
|
* 3. Registers MCP server in Claude Desktop/Cowork config
|
|
9
|
+
*
|
|
10
|
+
* The registration core (skill install, MCP registration, legacy migration)
|
|
11
|
+
* lives in src/utils/claude-register.js (Task 15, v4.2 §4.8/C2) so `amicus
|
|
12
|
+
* init` can re-run it on demand — this file is now a thin consumer that
|
|
13
|
+
* wires that core into the npm postinstall lifecycle (dev hooks, electron
|
|
14
|
+
* provisioning, and the top-level never-fail wrapper below).
|
|
9
15
|
*/
|
|
10
16
|
|
|
11
|
-
const fs = require('fs');
|
|
12
17
|
const path = require('path');
|
|
13
|
-
const os = require('os');
|
|
14
18
|
const { execFileSync } = require('child_process');
|
|
15
19
|
|
|
16
20
|
const { repairElectron } = require('../src/sidecar/electron-install');
|
|
17
21
|
const HINTS = require('../src/utils/remediation-hints');
|
|
18
|
-
const
|
|
22
|
+
const reg = require('../src/utils/claude-register');
|
|
23
|
+
const {
|
|
24
|
+
installSkill, installCouncilSkill, registerClaudeCode, registerClaudeDesktop,
|
|
25
|
+
migrateLegacyMcp, addMcpToConfigFile, COUNCIL_FILES,
|
|
26
|
+
} = reg;
|
|
19
27
|
|
|
20
28
|
const SETUP_HOOKS_SCRIPT = path.join(__dirname, 'setup-hooks.js');
|
|
21
29
|
|
|
22
30
|
/** Short cache-only provision budget: a slow disk must never hang the install. */
|
|
23
31
|
const PROVISION_TIMEOUT_MS = 15000;
|
|
24
32
|
|
|
25
|
-
const SKILL_SOURCE = path.join(__dirname, '..', 'skills', 'sidecar', 'SKILL.md');
|
|
26
|
-
const COUNCIL_SOURCE_DIR = path.join(__dirname, '..', 'skills', 'second-opinion');
|
|
27
|
-
|
|
28
|
-
/** Council files + per-file install semantics: SKILL/COUNCIL-DESIGN/SEAT-BRIEFS are
|
|
29
|
-
* product code (overwrite on update, so upgrades keep them in sync with the package);
|
|
30
|
-
* MODEL-NOTES is user data — its reviewer-reliability table evolves per-run, so it is
|
|
31
|
-
* seeded once and never clobbered. */
|
|
32
|
-
const COUNCIL_FILES = [
|
|
33
|
-
{ file: 'SKILL.md', mode: 'overwrite' },
|
|
34
|
-
{ file: 'COUNCIL-DESIGN.md', mode: 'overwrite' },
|
|
35
|
-
{ file: 'SEAT-BRIEFS.md', mode: 'overwrite' },
|
|
36
|
-
{ file: 'MANUAL-ORCHESTRATION.md', mode: 'overwrite' },
|
|
37
|
-
{ file: 'MODEL-NOTES.md', mode: 'if-missing' },
|
|
38
|
-
];
|
|
39
|
-
|
|
40
|
-
function skillsRoot() {
|
|
41
|
-
return path.join(os.homedir(), '.claude', 'skills');
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const MCP_CONFIG = { command: 'npx', args: ['-y', 'amicus@latest', 'mcp'] };
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Add or update an MCP server in a JSON config file.
|
|
48
|
-
*
|
|
49
|
-
* Refreshes command/args on every install/upgrade. If the EXISTING entry at
|
|
50
|
-
* this key is already amicus-shaped (per isAmicusMcpConfig — Phase 1's single
|
|
51
|
-
* source of truth for "this is amicus"), we MERGE instead of overwrite: the
|
|
52
|
-
* user's `env` (and any other extra keys, e.g. a future `cwd`) survive the
|
|
53
|
-
* refresh. Without this, `npm i -g amicus` silently wiped
|
|
54
|
-
* "env": {"AMICUS_LEGACY_ALIASES":"1"} — the exact opt-in escape hatch Phase 4
|
|
55
|
-
* tells users to add — on every upgrade.
|
|
56
|
-
*
|
|
57
|
-
* A NON-amicus-shaped entry at this key is overwritten as before: 'amicus' is
|
|
58
|
-
* a reserved registration name, so a foreign entry there is reclaimed rather
|
|
59
|
-
* than merged with.
|
|
60
|
-
*
|
|
61
|
-
* @param {string} configPath - Path to the JSON config file
|
|
62
|
-
* @param {string} name - MCP server name
|
|
63
|
-
* @param {object} config - MCP server config object
|
|
64
|
-
* @returns {string} 'added', 'updated', or 'unchanged'
|
|
65
|
-
*/
|
|
66
|
-
function addMcpToConfigFile(configPath, name, config) {
|
|
67
|
-
let existing = {};
|
|
68
|
-
try {
|
|
69
|
-
existing = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
70
|
-
} catch {
|
|
71
|
-
// File doesn't exist or invalid JSON — start fresh
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
if (!existing.mcpServers) { existing.mcpServers = {}; }
|
|
75
|
-
|
|
76
|
-
const prev = existing.mcpServers[name];
|
|
77
|
-
const nextConfig = (prev && isAmicusMcpConfig(prev)) ? { ...prev, ...config } : config;
|
|
78
|
-
const status = !prev ? 'added' : JSON.stringify(prev) !== JSON.stringify(nextConfig) ? 'updated' : 'unchanged';
|
|
79
|
-
|
|
80
|
-
existing.mcpServers[name] = nextConfig;
|
|
81
|
-
if (status !== 'unchanged') {
|
|
82
|
-
const dir = path.dirname(configPath);
|
|
83
|
-
if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); }
|
|
84
|
-
fs.writeFileSync(configPath, JSON.stringify(existing, null, 2), { mode: 0o600 });
|
|
85
|
-
}
|
|
86
|
-
return status;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** Install the chat skill to ~/.claude/skills/sidecar/ */
|
|
90
|
-
function installSkill() {
|
|
91
|
-
try {
|
|
92
|
-
const destDir = path.join(skillsRoot(), 'sidecar');
|
|
93
|
-
fs.mkdirSync(destDir, { recursive: true });
|
|
94
|
-
fs.copyFileSync(SKILL_SOURCE, path.join(destDir, 'SKILL.md'));
|
|
95
|
-
console.log('[amicus] Chat skill installed to ~/.claude/skills/sidecar/');
|
|
96
|
-
} catch (err) {
|
|
97
|
-
console.error(`[amicus] Warning: Could not install chat skill: ${err.message}`);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** Install the LLM Council skill to ~/.claude/skills/second-opinion/ */
|
|
102
|
-
function installCouncilSkill(sourceDir = COUNCIL_SOURCE_DIR) {
|
|
103
|
-
const destDir = path.join(skillsRoot(), 'second-opinion');
|
|
104
|
-
try {
|
|
105
|
-
fs.mkdirSync(destDir, { recursive: true });
|
|
106
|
-
} catch (err) {
|
|
107
|
-
console.error(`[amicus] Warning: Could not create council skill dir: ${err.message}`);
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
let failed = 0;
|
|
111
|
-
for (const { file, mode } of COUNCIL_FILES) {
|
|
112
|
-
try {
|
|
113
|
-
const dest = path.join(destDir, file);
|
|
114
|
-
if (mode === 'if-missing' && fs.existsSync(dest)) { continue; }
|
|
115
|
-
fs.copyFileSync(path.join(sourceDir, file), dest);
|
|
116
|
-
} catch (err) {
|
|
117
|
-
failed++;
|
|
118
|
-
console.error(`[amicus] Warning: Could not install council file ${file}: ${err.message}`);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
if (failed === COUNCIL_FILES.length) {
|
|
122
|
-
console.error('[amicus] Warning: Council skill NOT installed (all files failed — see warnings above).');
|
|
123
|
-
} else if (failed > 0) {
|
|
124
|
-
console.log(`[amicus] Council skill partially installed (${failed}/${COUNCIL_FILES.length} files failed — see warnings above).`);
|
|
125
|
-
} else {
|
|
126
|
-
console.log('[amicus] Council skill installed to ~/.claude/skills/second-opinion/');
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Read the previous 'amicus' entry from ~/.claude.json, if any — used by the
|
|
132
|
-
* CLI add-json path to merge env the same way the file-fallback path does.
|
|
133
|
-
* Never throws: a missing/unreadable file just means "no previous entry".
|
|
134
|
-
* @returns {object|undefined}
|
|
135
|
-
*/
|
|
136
|
-
function readPrevClaudeCodeAmicusEntry() {
|
|
137
|
-
try {
|
|
138
|
-
const parsed = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude.json'), 'utf-8'));
|
|
139
|
-
return parsed && parsed.mcpServers ? parsed.mcpServers.amicus : undefined;
|
|
140
|
-
} catch {
|
|
141
|
-
return undefined;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/** Register MCP server in Claude Code config */
|
|
146
|
-
function registerClaudeCode() {
|
|
147
|
-
// Try the CLI first
|
|
148
|
-
try {
|
|
149
|
-
// Merge the PREVIOUS registration's env into the add-json payload — same
|
|
150
|
-
// merge semantics as addMcpToConfigFile's file-fallback path (`{ ...prev,
|
|
151
|
-
// ...config }`: prev env keys survive, canonical MCP_CONFIG keys win on
|
|
152
|
-
// collision). Without this, a user's custom env (API key, AMICUS_* tuning
|
|
153
|
-
// knobs) on the old registration was silently dropped whenever the
|
|
154
|
-
// `claude` CLI was present, because the CLI path built its JSON payload
|
|
155
|
-
// from the bare MCP_CONFIG and delegated overwrite semantics to the
|
|
156
|
-
// claude binary — which has no idea about the user's previous entry.
|
|
157
|
-
const prev = readPrevClaudeCodeAmicusEntry();
|
|
158
|
-
const nextConfig = (prev && isAmicusMcpConfig(prev)) ? { ...prev, ...MCP_CONFIG } : MCP_CONFIG;
|
|
159
|
-
const mcpJson = JSON.stringify(nextConfig);
|
|
160
|
-
execFileSync('claude', ['mcp', 'add-json', 'amicus', mcpJson, '--scope', 'user'], {
|
|
161
|
-
stdio: 'pipe',
|
|
162
|
-
timeout: 10000,
|
|
163
|
-
});
|
|
164
|
-
console.log('[amicus] MCP registered in Claude Code (via CLI).');
|
|
165
|
-
|
|
166
|
-
return;
|
|
167
|
-
} catch {
|
|
168
|
-
// CLI not available or failed — fall back to file edit
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// Fallback: direct file edit
|
|
172
|
-
const claudeConfigPath = path.join(os.homedir(), '.claude.json');
|
|
173
|
-
const status = addMcpToConfigFile(claudeConfigPath, 'amicus', MCP_CONFIG);
|
|
174
|
-
if (status === 'added') {
|
|
175
|
-
console.log('[amicus] MCP registered in Claude Code (~/.claude.json).');
|
|
176
|
-
} else if (status === 'updated') {
|
|
177
|
-
console.log('[amicus] MCP config updated in Claude Code (~/.claude.json).');
|
|
178
|
-
} else {
|
|
179
|
-
console.log('[amicus] MCP already registered in Claude Code.');
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/** Register MCP server in Claude Desktop / Cowork config */
|
|
184
|
-
function registerClaudeDesktop() {
|
|
185
|
-
let configDir;
|
|
186
|
-
if (process.platform === 'darwin') {
|
|
187
|
-
configDir = path.join(os.homedir(), 'Library', 'Application Support', 'Claude');
|
|
188
|
-
} else if (process.platform === 'win32') {
|
|
189
|
-
configDir = path.join(process.env.APPDATA || '', 'Claude');
|
|
190
|
-
} else {
|
|
191
|
-
configDir = path.join(os.homedir(), '.config', 'claude');
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const configPath = path.join(configDir, 'claude_desktop_config.json');
|
|
195
|
-
const status = addMcpToConfigFile(configPath, 'amicus', MCP_CONFIG);
|
|
196
|
-
if (status === 'added') {
|
|
197
|
-
console.log('[amicus] MCP registered in Claude Desktop.');
|
|
198
|
-
} else if (status === 'updated') {
|
|
199
|
-
console.log('[amicus] MCP config updated in Claude Desktop.');
|
|
200
|
-
} else {
|
|
201
|
-
console.log('[amicus] MCP already registered in Claude Desktop.');
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* One-shot migration: drop the duplicate legacy 'sidecar' MCP entry that
|
|
207
|
-
* pre-1.8 postinstalls registered alongside 'amicus' (same server twice —
|
|
208
|
-
* doubled the client-visible tool list). Only removes an entry whose command
|
|
209
|
-
* is an amicus MCP invocation; a customized 'sidecar' entry is left alone.
|
|
210
|
-
* Covers both files the three legacy registration paths wrote to:
|
|
211
|
-
* ~/.claude.json (CLI + file fallback) and claude_desktop_config.json.
|
|
212
|
-
* Never throws (postinstall must always exit 0).
|
|
213
|
-
*/
|
|
214
|
-
function migrateLegacyMcp(deps = {}) {
|
|
215
|
-
try {
|
|
216
|
-
const impl = deps.migrateLegacySidecar
|
|
217
|
-
|| require('../src/utils/legacy-mcp-migration').migrateLegacySidecar;
|
|
218
|
-
for (const r of impl()) {
|
|
219
|
-
if (r.result === 'removed') {
|
|
220
|
-
console.log(`[amicus] Removed duplicate legacy 'sidecar' MCP entry from ${r.target} (same server — kept as 'amicus').`);
|
|
221
|
-
} else if (r.result === 'customized') {
|
|
222
|
-
console.log(`[amicus] Kept custom 'sidecar' MCP entry in ${r.target} (does not point at amicus).`);
|
|
223
|
-
} else if (r.result === 'write-failed') {
|
|
224
|
-
console.warn(`[amicus] Warning: could not remove the legacy 'sidecar' MCP entry from ${r.target} — run: amicus doctor --fix`);
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
} catch (err) {
|
|
228
|
-
console.warn(`[amicus] Warning: legacy MCP cleanup skipped: ${err && err.message}`);
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
33
|
/**
|
|
233
34
|
* NON-FATAL, CACHE-ONLY provisioning of the OPTIONAL electron binary (#57,
|
|
234
35
|
* supersedes #30's warn-only verifyElectron). Electron is an optionalDependency:
|
|
@@ -333,7 +134,7 @@ function setupHooks(deps = {}) {
|
|
|
333
134
|
|
|
334
135
|
async function main(deps = {}) {
|
|
335
136
|
if (process.env.AMICUS_SKIP_POSTINSTALL === '1') {
|
|
336
|
-
console.log('[amicus] AMICUS_SKIP_POSTINSTALL set — skipping global setup
|
|
137
|
+
console.log('[amicus] AMICUS_SKIP_POSTINSTALL set — skipping global setup. Run `amicus init` to register skills + MCP on demand.');
|
|
337
138
|
return;
|
|
338
139
|
}
|
|
339
140
|
const _installSkill = deps.installSkill || installSkill;
|
|
@@ -7,6 +7,9 @@ const mcpChecks = require('./utils/doctor-mcp-checks');
|
|
|
7
7
|
// engine-mcp check body — verifies the engine in the npx-cache copies the MCP
|
|
8
8
|
// actually launches (bug report #1). Split out to keep this file under the gate.
|
|
9
9
|
const engineCheck = require('./utils/doctor-engine-check');
|
|
10
|
+
// local-providers check body (v4.2 §4.7 C8) — split out to keep this file
|
|
11
|
+
// under the gate (mirrors the engineCheck/mcpChecks split above).
|
|
12
|
+
const localProvidersCheck = require('./utils/doctor-local-providers-check');
|
|
10
13
|
|
|
11
14
|
const MAX_CATALOG_AGE_MS = 24 * 60 * 60 * 1000; // 24h (mirrors model-catalog DEFAULT_MAX_AGE_MS)
|
|
12
15
|
|
|
@@ -58,6 +61,10 @@ function realDeps() {
|
|
|
58
61
|
hasAmicusRegistration: () => require('./utils/mcp-discovery').hasAmicusRegistration(),
|
|
59
62
|
inspectLegacyMcpEntries: () => require('./utils/legacy-mcp-migration').inspectAllLegacySidecarEntries(),
|
|
60
63
|
migrateLegacyMcpEntries: () => require('./utils/legacy-mcp-migration').migrateLegacySidecar(),
|
|
64
|
+
// local-providers check (v4.2 §4.7 C8): both injectable so tests never
|
|
65
|
+
// fire a real network probe when a depsOverride omits them (M14).
|
|
66
|
+
getLocalProviders: () => require('./utils/local-providers').getLocalProviders(),
|
|
67
|
+
probeLocalProvider: (e, o) => require('./utils/local-probe').probeLocalProvider(e, o),
|
|
61
68
|
skillInstalled: () => {
|
|
62
69
|
const dir = path.join(os.homedir(), '.claude', 'skills');
|
|
63
70
|
return fs.existsSync(path.join(dir, 'sidecar', 'SKILL.md'))
|
|
@@ -220,6 +227,11 @@ async function runDoctorChecks(depsOverride = {}) {
|
|
|
220
227
|
return { id: 'openrouter-credit', name: 'OpenRouter credit', status: 'ok', message: `credit ok${remaining}`, hint: null };
|
|
221
228
|
}));
|
|
222
229
|
|
|
230
|
+
// v4.2 §4.7 C8: configured local / OpenAI-compatible providers (Ollama, LM
|
|
231
|
+
// Studio, vLLM, generic) — reachability only; warn, never error (a napping
|
|
232
|
+
// `ollama serve` is not a doctor failure).
|
|
233
|
+
checks.push(await guardAsync('local-providers', 'Local providers', () => localProvidersCheck.evaluateLocalProviders(d)));
|
|
234
|
+
|
|
223
235
|
// #43: project-root sanity — warns when cwd looks like an app/install dir or lacks project markers.
|
|
224
236
|
checks.push(guard('project-root', 'Project root', () => {
|
|
225
237
|
const dir = d.getCwd();
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `amicus init [--claude] [--desktop] [--json]` (v4.2 §4.8, C2). Re-runs the
|
|
3
|
+
* SAME registration core `npm install`'s postinstall runs (src/utils/
|
|
4
|
+
* claude-register.js — Task 15's extraction), with per-step status and a
|
|
5
|
+
* compact doctor summary (Task 14's summarizeDoctor). Useful after a failed
|
|
6
|
+
* postinstall, for plugin-channel / --ignore-scripts installs (which set
|
|
7
|
+
* AMICUS_SKIP_POSTINSTALL), or to repair deleted ~/.claude state. Never does
|
|
8
|
+
* keys/model setup or engine/electron provisioning — that is `amicus setup` /
|
|
9
|
+
* `amicus doctor --fix`.
|
|
10
|
+
*
|
|
11
|
+
* DI-injected (mirrors cli-handlers-doctor.js / cli-handlers-provider.js) so
|
|
12
|
+
* it is unit-testable without ever touching a real Claude Code / Claude
|
|
13
|
+
* Desktop config file.
|
|
14
|
+
*/
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
function realDeps() {
|
|
18
|
+
return {
|
|
19
|
+
reg: require('./utils/claude-register'),
|
|
20
|
+
runDoctorChecks: (o) => require('./cli-handlers-doctor').runDoctorChecks(o),
|
|
21
|
+
summarizeDoctor: (c) => require('./utils/doctor-summary').summarizeDoctor(c),
|
|
22
|
+
print: (l) => process.stdout.write(`${l}\n`),
|
|
23
|
+
emitJson: (o) => process.stdout.write(`${JSON.stringify(o, null, 2)}\n`),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Run one registration step, isolating a throw to just this step so the rest
|
|
29
|
+
* of `init` can still make progress — graceful degradation (a broken Claude
|
|
30
|
+
* Code registration must not skip an otherwise-working Claude Desktop one).
|
|
31
|
+
* @param {object} steps - mutated in place: steps[key] = status | 'failed: <msg>'
|
|
32
|
+
* @param {string} key
|
|
33
|
+
* @param {() => (string|void)} fn - returns a status string, or void for
|
|
34
|
+
* fire-and-forget steps (skills/legacyMigration), which report 'done'. A
|
|
35
|
+
* falsy return (including undefined, e.g. from a stale/incompatible dep)
|
|
36
|
+
* also reports the honest 'done' fallback rather than fabricating a
|
|
37
|
+
* specific added/updated/unchanged value it cannot back up.
|
|
38
|
+
* @returns {boolean} true if the step succeeded
|
|
39
|
+
*/
|
|
40
|
+
function runStep(steps, key, fn) {
|
|
41
|
+
try {
|
|
42
|
+
steps[key] = fn() || 'done';
|
|
43
|
+
return true;
|
|
44
|
+
} catch (e) {
|
|
45
|
+
steps[key] = `failed: ${e.message}`;
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {{_:string[], claude?:boolean, desktop?:boolean, json?:boolean}} args
|
|
52
|
+
* @param {object} [deps] DI overrides (reg/runDoctorChecks/summarizeDoctor/print/emitJson)
|
|
53
|
+
* @returns {Promise<number>} exit code (0 ok, 1 a target genuinely failed)
|
|
54
|
+
*/
|
|
55
|
+
async function handleInit(args, deps = {}) {
|
|
56
|
+
const d = { ...realDeps(), ...deps };
|
|
57
|
+
const both = !args.claude && !args.desktop;
|
|
58
|
+
const steps = {};
|
|
59
|
+
let ok = true;
|
|
60
|
+
|
|
61
|
+
// Fire-and-forget: best-effort skill copy, reported as a single combined step.
|
|
62
|
+
ok = runStep(steps, 'skills', () => { d.reg.installSkill(); d.reg.installCouncilSkill(); }) && ok;
|
|
63
|
+
if (both || args.claude) { ok = runStep(steps, 'claudeCode', () => d.reg.registerClaudeCode()) && ok; }
|
|
64
|
+
if (both || args.desktop) { ok = runStep(steps, 'claudeDesktop', () => d.reg.registerClaudeDesktop()) && ok; }
|
|
65
|
+
ok = runStep(steps, 'legacyMigration', () => { d.reg.migrateLegacyMcp(); }) && ok;
|
|
66
|
+
|
|
67
|
+
// Guarded polish: a bug in the doctor check / summary formatting must never
|
|
68
|
+
// crash `init` itself — registration already happened above; the summary
|
|
69
|
+
// is a best-effort bonus tacked on at the end.
|
|
70
|
+
let summary = '';
|
|
71
|
+
try { summary = d.summarizeDoctor(await d.runDoctorChecks()); } catch { summary = ''; }
|
|
72
|
+
|
|
73
|
+
if (args.json) {
|
|
74
|
+
d.emitJson({ ok, steps, ...(summary ? { summary } : {}) });
|
|
75
|
+
} else {
|
|
76
|
+
for (const [k, v] of Object.entries(steps)) { d.print(` ${k}: ${v}`); }
|
|
77
|
+
if (summary) { d.print(summary); }
|
|
78
|
+
if (!ok) { d.print('amicus init: one or more steps failed — see above'); }
|
|
79
|
+
}
|
|
80
|
+
return ok ? 0 : 1;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { handleInit };
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* amicus key <localId> — bearer lifecycle for a config-defined local /
|
|
3
|
+
* OpenAI-compatible provider (v4.2 §4.6, Task 11). Split out of
|
|
4
|
+
* cli-handlers.js to keep that file under the 300-line gate (mirrors the
|
|
5
|
+
* cli-handlers-abort.js extraction). Local ids never touch PROVIDER_ENV_MAP
|
|
6
|
+
* or VALIDATION_ENDPOINTS — they're config-defined (local-providers.js) and
|
|
7
|
+
* validated with a bearer-attached reachability probe instead.
|
|
8
|
+
*
|
|
9
|
+
* Errors here write to stderr and RETURN rather than process.exit(1): the
|
|
10
|
+
* malformed-env-var case is exercised by a test that spies on
|
|
11
|
+
* process.stderr.write without mocking process.exit, and a real exit() call
|
|
12
|
+
* tears down the whole Jest worker (confirmed while TDD'ing this file — the
|
|
13
|
+
* pre-Task-11 "Unknown provider" path really does kill an unmocked test run).
|
|
14
|
+
*/
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Mask a raw secret the same way api-key-store's readApiKeyHints does: first
|
|
19
|
+
* 8 chars visible, the rest replaced with bullets. Never returns the full value.
|
|
20
|
+
* @param {string} key
|
|
21
|
+
* @returns {string}
|
|
22
|
+
*/
|
|
23
|
+
function maskKey(key) {
|
|
24
|
+
const visible = key.slice(0, 8);
|
|
25
|
+
return visible + '•'.repeat(Math.max(0, Math.min(key.length - 8, 12)));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Render the 'Local providers:' block appended to 'amicus key' (no-args list
|
|
30
|
+
* view). A provider with no apiKeyEnv needs no bearer at all; one with
|
|
31
|
+
* apiKeyEnv shows a masked hint (never the raw token) or 'not set'.
|
|
32
|
+
* @param {Object<string,object>} localProviders id -> normalized entry (getLocalProviders())
|
|
33
|
+
* @param {Map<string,string>} envEntries file-backed env values (api-key-store's loadEnvEntries())
|
|
34
|
+
* @returns {string[]} lines to print (empty when there are no local providers)
|
|
35
|
+
*/
|
|
36
|
+
function formatLocalKeyList(localProviders, envEntries) {
|
|
37
|
+
const ids = Object.keys(localProviders);
|
|
38
|
+
if (ids.length === 0) { return []; }
|
|
39
|
+
const lines = ['', 'Local providers:'];
|
|
40
|
+
for (const id of ids) {
|
|
41
|
+
const entry = localProviders[id];
|
|
42
|
+
let status;
|
|
43
|
+
if (!entry.apiKeyEnv) {
|
|
44
|
+
status = 'no key required';
|
|
45
|
+
} else {
|
|
46
|
+
const raw = envEntries.get(entry.apiKeyEnv) || process.env[entry.apiKeyEnv] || '';
|
|
47
|
+
status = raw ? `✓ ${maskKey(raw)}` : '✗ not set';
|
|
48
|
+
}
|
|
49
|
+
lines.push(` ${id.padEnd(12)} ${status}`);
|
|
50
|
+
}
|
|
51
|
+
return lines;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* amicus key <localId> [<token>|--remove]. Called only after the caller has
|
|
56
|
+
* already confirmed `provider` is an own key of getLocalProviders() (never a
|
|
57
|
+
* bare map[id] — a provider named 'constructor' must resolve to its real
|
|
58
|
+
* entry, not the inherited Object.prototype.constructor).
|
|
59
|
+
* @param {object} entry normalized local-provider entry (getLocalProviders()[provider])
|
|
60
|
+
* @param {string} provider the local id
|
|
61
|
+
* @param {object} args parsed CLI args (args._[2] = token, args.remove, args.json)
|
|
62
|
+
*/
|
|
63
|
+
async function handleLocalKey(entry, provider, args) {
|
|
64
|
+
const { saveRawEnv, removeRawEnv } = require('./utils/api-key-store');
|
|
65
|
+
const { deriveKeyEnv } = require('./utils/local-providers');
|
|
66
|
+
const envVar = entry.apiKeyEnv || deriveKeyEnv(provider);
|
|
67
|
+
|
|
68
|
+
if (args.remove) {
|
|
69
|
+
removeRawEnv(envVar);
|
|
70
|
+
// B3 (whole-branch review): clear the apiKeyEnv stamp too, not just the .env
|
|
71
|
+
// line, so --remove is symmetric with `amicus key <token>` (which stamps
|
|
72
|
+
// apiKeyEnv onto config.providers[id] the first time a bearer is added — see
|
|
73
|
+
// below). Leaving apiKeyEnv on config after the .env line is gone bricks the
|
|
74
|
+
// provider: gateway-router.js's resolveLocal treats
|
|
75
|
+
// `entry.apiKeyEnv && !entry.keyPresent` as a hard no_local_key error, so a
|
|
76
|
+
// provider that started keyless would never again resolve without the user
|
|
77
|
+
// re-adding a key or removing/re-adding the whole provider — "remove
|
|
78
|
+
// doesn't undo add". config.providers[id] has no field recording WHY
|
|
79
|
+
// apiKeyEnv is set (auto-stamped by `key <token>` vs. required via
|
|
80
|
+
// `provider add --bearer-env`), so this can't be done selectively; clearing
|
|
81
|
+
// it unconditionally is the safest, most predictable behavior — --remove
|
|
82
|
+
// fully reverts the provider to the no-auth state it was in before `amicus
|
|
83
|
+
// key <token>`. A user who wants the provider to require auth again can
|
|
84
|
+
// reconfigure it (`provider add --bearer-env` or another `amicus key <token>`).
|
|
85
|
+
const { loadConfig, saveConfig } = require('./utils/config');
|
|
86
|
+
const config = loadConfig() || {};
|
|
87
|
+
if (config.providers && Object.prototype.hasOwnProperty.call(config.providers, provider) &&
|
|
88
|
+
config.providers[provider].apiKeyEnv) {
|
|
89
|
+
delete config.providers[provider].apiKeyEnv;
|
|
90
|
+
saveConfig(config);
|
|
91
|
+
}
|
|
92
|
+
if (args.json) { process.stdout.write(`${JSON.stringify({ ok: true, removed: provider })}\n`); }
|
|
93
|
+
else { process.stdout.write(`Removed bearer for '${provider}'.\n`); }
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const token = args._[2];
|
|
98
|
+
if (!token) {
|
|
99
|
+
process.stderr.write(`This local provider needs a bearer token: amicus key ${provider} <token>\n`);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// B2 (whole-branch review, security consistency): a bearer headed to a plain
|
|
104
|
+
// http:// non-loopback host travels in cleartext. `provider add` (doAdd) and
|
|
105
|
+
// the Electron save handler (ipc-setup-local.js) both warn about this; this
|
|
106
|
+
// surface never did. Reuse their exact-hostname check (not a new
|
|
107
|
+
// substring-based one) so 127.0.0.1.evil.com still correctly warns. Warn
|
|
108
|
+
// BEFORE the probe below, which is itself the first thing to send the token
|
|
109
|
+
// over the wire.
|
|
110
|
+
const { isPlaintextRemote } = require('./cli-handlers-provider');
|
|
111
|
+
if (isPlaintextRemote(entry.baseURL)) {
|
|
112
|
+
process.stderr.write('Warning: sending a bearer token over plain http:// to a non-loopback host transmits it in cleartext.\n');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const { probeLocalProvider } = require('./utils/local-probe');
|
|
116
|
+
const probe = await probeLocalProvider(entry, { timeoutMs: 2000, bearer: token });
|
|
117
|
+
|
|
118
|
+
// M12: saveRawEnv validates envVar against /^[A-Z][A-Z0-9_]*$/ and returns
|
|
119
|
+
// {success:false, error} WITHOUT throwing — bail BEFORE stamping apiKeyEnv
|
|
120
|
+
// onto config, mirroring the direct-vendor check in cli-handlers.js.
|
|
121
|
+
const saved = saveRawEnv(envVar, token);
|
|
122
|
+
if (saved && saved.success === false) {
|
|
123
|
+
process.stderr.write(`${saved.error}\n`);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// If the entry had no apiKeyEnv yet, stamp it so buildProviderModels interpolates it.
|
|
128
|
+
if (!entry.apiKeyEnv) {
|
|
129
|
+
const { loadConfig, saveConfig } = require('./utils/config');
|
|
130
|
+
const config = loadConfig() || {};
|
|
131
|
+
if (config.providers && Object.prototype.hasOwnProperty.call(config.providers, provider)) {
|
|
132
|
+
config.providers[provider].apiKeyEnv = envVar;
|
|
133
|
+
saveConfig(config);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const msg = probe.status === 'ok'
|
|
138
|
+
? `Bearer saved for '${provider}' (${probe.models.length} model(s) reachable).`
|
|
139
|
+
: `Bearer saved for '${provider}' (endpoint unreachable — check the server).`;
|
|
140
|
+
if (args.json) { process.stdout.write(`${JSON.stringify({ ok: true, provider, reachable: probe.status === 'ok' })}\n`); }
|
|
141
|
+
else { process.stdout.write(`${msg}\n`); }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = { handleLocalKey, formatLocalKeyList, maskKey };
|