amicus 4.1.2 → 4.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.
Files changed (40) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +48 -0
  3. package/README.md +21 -2
  4. package/bin/amicus.js +10 -0
  5. package/electron/ipc-setup-local.js +109 -0
  6. package/electron/ipc-setup.js +14 -2
  7. package/electron/preload-setup.js +3 -1
  8. package/electron/setup-ui-local-script.js +114 -0
  9. package/electron/setup-ui-local.js +75 -0
  10. package/electron/setup-ui-styles.js +15 -1
  11. package/electron/setup-ui.js +6 -1
  12. package/package.json +1 -1
  13. package/scripts/postinstall.js +12 -211
  14. package/src/cli-handlers-doctor.js +12 -0
  15. package/src/cli-handlers-init.js +83 -0
  16. package/src/cli-handlers-key-local.js +144 -0
  17. package/src/cli-handlers-provider.js +212 -0
  18. package/src/cli-handlers.js +31 -3
  19. package/src/cli.js +21 -0
  20. package/src/sidecar/setup-local.js +75 -0
  21. package/src/sidecar/setup.js +99 -3
  22. package/src/utils/api-key-store.js +17 -64
  23. package/src/utils/claude-register.js +267 -0
  24. package/src/utils/config.js +53 -1
  25. package/src/utils/doctor-local-providers-check.js +62 -0
  26. package/src/utils/doctor-summary.js +33 -0
  27. package/src/utils/env-loader.js +12 -0
  28. package/src/utils/env-raw-store.js +120 -0
  29. package/src/utils/gateway-router.js +65 -2
  30. package/src/utils/lifecycle.js +2 -1
  31. package/src/utils/local-probe.js +109 -0
  32. package/src/utils/local-providers.js +141 -0
  33. package/src/utils/model-catalog.js +6 -2
  34. package/src/utils/model-fetcher.js +11 -1
  35. package/src/utils/pricing.js +23 -9
  36. package/src/utils/provider-default-picker.js +17 -2
  37. package/src/utils/provider-default-prompt.js +7 -4
  38. package/src/utils/route-error.js +10 -3
  39. package/src/utils/route-launch.js +8 -65
  40. package/src/utils/route-suggestions.js +85 -0
@@ -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 { isAmicusMcpConfig } = require('../src/utils/mcp-self-identity');
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 (plugin channel handles registration).');
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 };