amicus 4.1.1 → 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.
Files changed (41) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +44 -0
  3. package/README.md +20 -1
  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 +101 -6
  22. package/src/utils/api-key-store.js +12 -62
  23. package/src/utils/claude-register.js +267 -0
  24. package/src/utils/config.js +82 -10
  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 +13 -0
  28. package/src/utils/env-raw-store.js +111 -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/quick-picks.js +35 -12
  39. package/src/utils/route-error.js +10 -3
  40. package/src/utils/route-launch.js +8 -65
  41. package/src/utils/route-suggestions.js +85 -0
@@ -0,0 +1,212 @@
1
+ /**
2
+ * `amicus provider add|list|test|remove` (v4.2 §4.6): configure local /
3
+ * OpenAI-compatible providers (Ollama, LM Studio, vLLM, any OpenAI-compatible
4
+ * endpoint). Writes config.providers, stores bearer secrets in the 0600 .env
5
+ * (never in config), probes the endpoint, and runs the shared per-provider
6
+ * default picker on a reachable server. DI-injected (print/emitJson/warn/probe/
7
+ * readCache/runDefault) so it is unit-testable without a TTY or a live server;
8
+ * `--json` is fully non-interactive.
9
+ */
10
+ 'use strict';
11
+
12
+ const {
13
+ PRESETS, RESERVED_IDS, ID_RE, validateProviderEntry, deriveKeyEnv, getLocalProviders,
14
+ } = require('./utils/local-providers');
15
+
16
+ function realDeps() {
17
+ return {
18
+ loadConfig: () => require('./utils/config').loadConfig(),
19
+ saveConfig: (c) => require('./utils/config').saveConfig(c),
20
+ // Bearer persistence: saveRawEnv writes an arbitrary env-var NAME to the 0600
21
+ // .env (re-exported from api-key-store). Named per the Task 10 DI contract.
22
+ saveApiKey: (env, val) => require('./utils/api-key-store').saveRawEnv(env, val),
23
+ // Bearer cleanup (post-Task-11-review flow-gap fix): removeRawEnv deletes an
24
+ // arbitrary env-var NAME line from the same .env. Named to mirror saveApiKey.
25
+ removeApiKey: (env) => require('./utils/api-key-store').removeRawEnv(env),
26
+ probe: (e, o) => require('./utils/local-probe').probeLocalProvider(e, o),
27
+ readCache: () => require('./utils/model-catalog').readCache(),
28
+ runDefault: (id, o) => require('./utils/provider-default-prompt').runProviderDefaultFlow(id, o),
29
+ print: (l) => process.stdout.write(`${l}\n`),
30
+ emitJson: (o) => process.stdout.write(`${JSON.stringify(o, null, 2)}\n`),
31
+ warn: (l) => process.stderr.write(`${l}\n`),
32
+ };
33
+ }
34
+
35
+ /** Exact-hostname loopback test (substring matching would pass 127.0.0.1.evil.com). */
36
+ function isLoopbackUrl(baseURL) {
37
+ try {
38
+ return ['127.0.0.1', 'localhost', '::1', '[::1]'].includes(new URL(baseURL).hostname);
39
+ } catch { return false; }
40
+ }
41
+
42
+ /** A bearer here would traverse the network in cleartext (plain http, non-loopback). */
43
+ function isPlaintextRemote(baseURL) {
44
+ try { return new URL(baseURL).protocol === 'http:' && !isLoopbackUrl(baseURL); }
45
+ catch { return false; }
46
+ }
47
+
48
+ /** Build the entry from --preset or --url (+ optional bearer/pricing). */
49
+ function entryFromArgs(id, args) {
50
+ // hasOwnProperty guard: args.preset is user input; a bare PRESETS[args.preset]
51
+ // for 'constructor' would walk the prototype chain (the recurring v4.2 bug class).
52
+ const preset = (args.preset && Object.prototype.hasOwnProperty.call(PRESETS, args.preset))
53
+ ? PRESETS[args.preset] : null;
54
+ const baseURL = args.url || (preset && preset.baseURL);
55
+ const flavor = (preset && preset.flavor) || 'generic';
56
+ // D15/M13: every PRESETS entry carries a baseURL, so this fires only when NEITHER
57
+ // flag was given, or when --preset named something unknown — distinguish the two.
58
+ if (!baseURL) {
59
+ return { error: args.preset && !preset
60
+ ? `unknown --preset '${args.preset}' (expected: ${Object.keys(PRESETS).join('|')})`
61
+ : 'a --preset or --url is required' };
62
+ }
63
+ const entry = { type: 'openai-compatible', baseURL, flavor };
64
+ if (args['pricing-in'] !== undefined || args['pricing-out'] !== undefined) {
65
+ entry.pricing = { prompt: Number(args['pricing-in']) || 0, completion: Number(args['pricing-out']) || 0 };
66
+ }
67
+ if (args['bearer-env']) { entry.apiKeyEnv = args['bearer-env']; }
68
+ else if (args.bearer) { entry.apiKeyEnv = deriveKeyEnv(id); }
69
+ return { entry, bearerValue: args.bearer };
70
+ }
71
+
72
+ /** Warn when the cached catalog already carries an `openrouter/<id>/` namespace (decision 5). */
73
+ function shadowsGatewayNamespace(cache, id) {
74
+ return !!(cache && Array.isArray(cache.models) && cache.models.some(
75
+ (m) => m && typeof m.id === 'string' && m.id.startsWith(`openrouter/${id}/`)));
76
+ }
77
+
78
+ async function doAdd(id, args, d) {
79
+ // D7: the CLI owns id validation (validateProviderEntry is id-agnostic).
80
+ if (!ID_RE.test(id || '') || RESERVED_IDS.includes(id)) {
81
+ d.warn(`Invalid or reserved provider id: '${id}'.`); return 1;
82
+ }
83
+ const built = entryFromArgs(id, args);
84
+ if (built.error) { d.warn(built.error); return 1; }
85
+ const v = validateProviderEntry(built.entry); // enforces the http/https scheme allowlist
86
+ if (!v.ok) { d.warn(v.error); return 1; }
87
+ // Security (spec §4.10): a bearer over plain http to a non-loopback host is cleartext.
88
+ if (built.bearerValue && isPlaintextRemote(built.entry.baseURL)) {
89
+ d.warn('Warning: sending a bearer token over plain http:// to a non-loopback host transmits it in cleartext.');
90
+ }
91
+ if (shadowsGatewayNamespace(d.readCache(), id)) {
92
+ d.warn(`Note: '${id}' shadows an OpenRouter vendor namespace for bare model ids. ` +
93
+ `Use openrouter/${id}/... to reach OpenRouter.`);
94
+ }
95
+ // Persist the bearer FIRST so a rejected env-var name aborts before we write config.
96
+ // M12: saveRawEnv returns {success:false,error} WITHOUT throwing — mirror the
97
+ // direct-vendor path (cli-handlers.js:156) and bail before saveConfig.
98
+ if (built.entry.apiKeyEnv && built.bearerValue) {
99
+ const saved = d.saveApiKey(built.entry.apiKeyEnv, built.bearerValue);
100
+ if (saved && saved.success === false) { d.warn(saved.error); return 1; }
101
+ }
102
+ const config = d.loadConfig() || {};
103
+ config.providers = config.providers || {};
104
+ config.providers[id] = v.normalized;
105
+ // B7/D4: do NOT seed config.default here. A bare id is unresolvable by resolveModel
106
+ // unless config.aliases[id] exists, and only applyProviderDefault (via d.runDefault,
107
+ // on a successful probe) writes that pair (alias first, then default). Seeding it
108
+ // before the probe permanently breaks every later keyless start/fanout/continue.
109
+ d.saveConfig(config);
110
+ // Probe (best-effort; a failure never blocks the save — air-gap rule).
111
+ const probe = await d.probe({ ...v.normalized, id }, { timeoutMs: 2000, bearer: built.bearerValue });
112
+ if (probe.status === 'ok') {
113
+ d.print(`Added '${id}' — ${probe.models.length} model(s) found.`);
114
+ try {
115
+ const catalog = probe.models.map((mid) => ({ id: mid, pricing: v.normalized.pricing, local: true }));
116
+ const { summaryLine } = await d.runDefault(id, { interactive: false, catalog });
117
+ if (summaryLine) { d.print(summaryLine); }
118
+ } catch { /* picker is best-effort; a bug here must never fail an already-saved add */ }
119
+ } else {
120
+ d.warn(`Added '${id}' but the endpoint was unreachable — check the server and run \`amicus provider test ${id}\`.`);
121
+ }
122
+ if (args.json) { d.emitJson({ ok: true, id, reachable: probe.status === 'ok', models: probe.models }); }
123
+ return 0;
124
+ }
125
+
126
+ async function doTest(id, args, d) {
127
+ const map = getLocalProviders();
128
+ // hasOwnProperty guard (trap #6): map[id] for id==='constructor' is a proto walk.
129
+ if (!id || !Object.prototype.hasOwnProperty.call(map, id)) { d.warn(`No local provider '${id}'.`); return 1; }
130
+ const entry = map[id];
131
+ const bearer = entry.apiKeyEnv ? process.env[entry.apiKeyEnv] : undefined;
132
+ const probe = await d.probe(entry, { timeoutMs: 2000, bearer });
133
+ const ok = probe.status === 'ok';
134
+ // Never surface the Authorization header / token — only the boolean presence.
135
+ if (args.json) { d.emitJson({ ok, id, reachable: ok, models: probe.models, bearer: !!bearer }); }
136
+ else { d.print(ok ? `${id}: ${probe.models.length} model(s) @ ${entry.baseURL}` : `${id}: unreachable @ ${entry.baseURL}`); }
137
+ return ok ? 0 : 1;
138
+ }
139
+
140
+ function doList(args, d) {
141
+ const providers = Object.values(getLocalProviders()).map(
142
+ (e) => ({ id: e.id, baseURL: e.baseURL, flavor: e.flavor, bearer: !!e.apiKeyEnv }));
143
+ if (args.json) { d.emitJson({ providers }); }
144
+ else if (providers.length === 0) { d.print('No local providers configured. Add one: amicus provider add ollama --preset ollama'); }
145
+ else { for (const p of providers) { d.print(`${p.id} ${p.baseURL} [${p.flavor}]${p.bearer ? ' (bearer)' : ''}`); } }
146
+ return 0;
147
+ }
148
+
149
+ function doRemove(id, args, d) {
150
+ const config = d.loadConfig() || {};
151
+ const providers = config.providers || {};
152
+ // hasOwnProperty guards (trap #6): providers[id]/aliases[id] proto walks for 'constructor'.
153
+ if (!id || !Object.prototype.hasOwnProperty.call(providers, id)) { d.warn(`No local provider '${id}'.`); return 1; }
154
+ const entry = providers[id];
155
+ delete providers[id];
156
+ if (config.default === id) { delete config.default; }
157
+ if (config.aliases && Object.prototype.hasOwnProperty.call(config.aliases, id)) { delete config.aliases[id]; }
158
+ d.saveConfig(config);
159
+
160
+ // Flow-gap fix (post-Task-11-review): the old hint ("remove it with `amicus key
161
+ // <id> --remove`") pointed at a command that fails the instant the config entry
162
+ // above is gone -- isLocalProvider/getLocalProviders derive local-id status from
163
+ // config.providers on every call. Remove the bearer ourselves instead, UNLESS a
164
+ // sibling id still shares this apiKeyEnv via --bearer-env, in which case deleting
165
+ // the line would break that sibling. Iterate remaining OWN keys only (trap #6).
166
+ let bearerRemoved = false;
167
+ let sharedWith = null;
168
+ if (entry.apiKeyEnv) {
169
+ sharedWith = Object.keys(providers).find((otherId) => {
170
+ const other = providers[otherId];
171
+ return other && other.apiKeyEnv === entry.apiKeyEnv;
172
+ }) || null;
173
+ if (!sharedWith) {
174
+ d.removeApiKey(entry.apiKeyEnv);
175
+ bearerRemoved = true;
176
+ }
177
+ }
178
+
179
+ if (args.json) {
180
+ d.emitJson({ ok: true, removed: id, bearerRemoved });
181
+ } else if (bearerRemoved) {
182
+ d.print(`Removed '${id}' and its bearer '${entry.apiKeyEnv}'.`);
183
+ } else if (sharedWith) {
184
+ d.print(`Removed '${id}'. Kept '${entry.apiKeyEnv}' in .env — still used by provider '${sharedWith}'.`);
185
+ } else {
186
+ d.print(`Removed '${id}'.`);
187
+ }
188
+ return 0;
189
+ }
190
+
191
+ /**
192
+ * @param {object} args parsed CLI args (args._ = ['provider', <sub>, <id?>])
193
+ * @param {object} [deps] DI overrides (print/emitJson/warn/probe/readCache/...)
194
+ * @returns {Promise<number>} exit code
195
+ */
196
+ async function handleProvider(args, deps = {}) {
197
+ const d = { ...realDeps(), ...deps };
198
+ const sub = args._[1];
199
+ const id = args._[2];
200
+ if (sub === 'add') { return doAdd(id, args, d); }
201
+ if (sub === 'test') { return doTest(id, args, d); }
202
+ if (sub === 'list') { return doList(args, d); }
203
+ if (sub === 'remove') { return doRemove(id, args, d); }
204
+ d.warn('Usage: amicus provider add|list|test|remove [--json]');
205
+ return 1;
206
+ }
207
+
208
+ // isLoopbackUrl/isPlaintextRemote are also reused by cli-handlers-key-local.js's
209
+ // handleLocalKey (B2, whole-branch review) so the `amicus key <localId> <token>`
210
+ // surface warns about cleartext bearer transmission with the SAME exact-hostname
211
+ // check `provider add` uses here, instead of growing a second, divergent check.
212
+ module.exports = { handleProvider, isLoopbackUrl, isPlaintextRemote };
@@ -10,6 +10,9 @@
10
10
  // handleAbort moved to src/cli-handlers-abort.js (B21-rest: --json branch
11
11
  // needed headroom this file didn't have). Re-exported below for compatibility.
12
12
  const { handleAbort } = require('./cli-handlers-abort');
13
+ // handleKey's local-provider branch (v4.2 Task 11) lives in its own module for
14
+ // the same reason — this file had no headroom left for the new bearer flow.
15
+ const { handleLocalKey, formatLocalKeyList } = require('./cli-handlers-key-local');
13
16
 
14
17
  /**
15
18
  * Handle 'amicus setup' command
@@ -100,10 +103,15 @@ async function handleMcp() {
100
103
  /**
101
104
  * Handle 'amicus key' command
102
105
  * Lists, saves, or removes API keys for a provider without opening the Electron wizard.
106
+ * Local (openai-compatible) providers are config-defined (local-providers.js)
107
+ * rather than in PROVIDER_ENV_MAP, so they're handled by handleLocalKey — a
108
+ * bearer probe/store flow, not the VALIDATION_ENDPOINTS ping the 5 direct
109
+ * vendors use.
103
110
  */
104
111
  async function handleKey(args) {
105
- const { readApiKeys, readApiKeyHints, saveApiKey, removeApiKey, PROVIDER_ENV_MAP } = require('./utils/api-key-store');
112
+ const { readApiKeys, readApiKeyHints, saveApiKey, removeApiKey, loadEnvEntries, PROVIDER_ENV_MAP } = require('./utils/api-key-store');
106
113
  const { validateApiKey } = require('./utils/api-key-validation');
114
+ const { getLocalProviders } = require('./utils/local-providers');
107
115
 
108
116
  const provider = args._[1];
109
117
  const keyArg = args._[2];
@@ -119,12 +127,32 @@ async function handleKey(args) {
119
127
  const status = configured[p] ? `✓ ${hints[p]}` : '✗ not set';
120
128
  console.log(` ${p.padEnd(12)} ${status}`);
121
129
  }
130
+ const localProviders = getLocalProviders();
131
+ if (Object.keys(localProviders).length > 0) {
132
+ for (const line of formatLocalKeyList(localProviders, loadEnvEntries())) { console.log(line); }
133
+ }
122
134
  console.log('');
123
135
  return;
124
136
  }
125
137
 
126
- // Validate provider
127
- if (!PROVIDER_ENV_MAP[provider]) {
138
+ // Local providers (Ollama/LM Studio/vLLM/any OpenAI-compatible endpoint) are
139
+ // config-defined, not in PROVIDER_ENV_MAP — branch BEFORE the direct-vendor
140
+ // check below. hasOwnProperty guard (recurring v4.2 bug class): a provider
141
+ // literally named 'constructor' is a valid, non-reserved id (ID_RE/
142
+ // RESERVED_IDS in local-providers.js) and must resolve to its real entry,
143
+ // not the inherited Object.prototype.constructor.
144
+ const localProviders = getLocalProviders();
145
+ if (Object.prototype.hasOwnProperty.call(localProviders, provider)) {
146
+ return handleLocalKey(localProviders[provider], provider, args);
147
+ }
148
+
149
+ // Validate provider. hasOwnProperty guard (same prototype-chain bug class the
150
+ // local-provider branch above guards against): a bare PROVIDER_ENV_MAP[provider]
151
+ // for provider === 'constructor' reads the inherited Object.prototype.constructor
152
+ // (the Object function — truthy), which would wrongly treat an UNCONFIGURED
153
+ // 'constructor' as a known direct vendor and crash later in validateApiKey
154
+ // instead of reporting Unknown provider (caught by this task's own test suite).
155
+ if (!Object.prototype.hasOwnProperty.call(PROVIDER_ENV_MAP, provider)) {
128
156
  console.error(`Error: Unknown provider "${provider}". Known providers: ${Object.keys(PROVIDER_ENV_MAP).join(', ')}`);
129
157
  process.exit(1);
130
158
  }
package/src/cli.js CHANGED
@@ -141,6 +141,8 @@ function isBooleanFlag(key) {
141
141
  'fix', // doctor: self-heal fixable checks in place (#56)
142
142
  'strict', // models --check: exit non-zero on curated per-gateway drift (#gwid Task 6)
143
143
  'render', // council verdict: also refresh report.html next to the decided verdict
144
+ 'claude', // init: register for Claude Code only (Task 15)
145
+ 'desktop', // init: register for Claude Desktop only (Task 15)
144
146
  ];
145
147
  return booleanFlags.includes(key);
146
148
  }
@@ -375,8 +377,10 @@ Commands:
375
377
  <provider> <apikey> Validate and save a key
376
378
  <provider> --remove Remove a saved key
377
379
  (no args) List all configured providers
380
+ provider Add/list/test/remove local OpenAI-compatible providers (--json)
378
381
  update Update to latest version
379
382
  mcp Start MCP server (stdio transport)
383
+ init Re-run skill install + MCP registration on demand [--claude] [--desktop] [--json]
380
384
  `;
381
385
 
382
386
  // Per-command option blocks, keyed by the invoked subcommand. Insertion order
@@ -555,10 +559,27 @@ Usage for 'key':
555
559
  key <provider> <apikey> Validate and save a key
556
560
  key <provider> --remove Remove a saved key
557
561
  key List all configured providers
562
+ `,
563
+ provider: `
564
+ Options for 'provider':
565
+ provider add <id> --preset ollama|lmstudio|vllm Add a local server from a preset
566
+ provider add <id> --url <baseURL> [--bearer-env VAR | --bearer <token>]
567
+ [--pricing-in <$/tok> --pricing-out <$/tok>]
568
+ provider list | test <id> | remove <id> Manage local providers (all support --json)
569
+ Local providers run at $0 through Ollama / LM Studio / vLLM / any OpenAI-compatible endpoint.
558
570
  `,
559
571
  mcp: `
560
572
  Usage for 'mcp':
561
573
  mcp Start the MCP server (stdio transport)
574
+ `,
575
+ init: `
576
+ Options for 'init':
577
+ --claude Register for Claude Code only (skip Claude Desktop)
578
+ --desktop Register for Claude Desktop only (skip Claude Code)
579
+ --json Emit per-step status as JSON
580
+ Runs skill install + MCP registration on demand (for plugin-channel /
581
+ --ignore-scripts installs, a failed postinstall, or repairing deleted
582
+ ~/.claude state). No flags registers both Claude Code and Claude Desktop.
562
583
  `
563
584
  };
564
585
 
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The readline setup wizard's local / self-hosted provider add step (v4.2 §4.6, Task 12).
3
+ *
4
+ * Split out of setup.js per RULING D14: setup.js is 516 lines and sits on
5
+ * check-file-sizes.js's exclude list (grandfathered — the gate can never flag
6
+ * further growth there), so this extraction is unconditional, not gated on a
7
+ * line-count check.
8
+ *
9
+ * Collects an id + preset (or a custom base URL) and delegates EVERY write to
10
+ * handleProvider(['provider', 'add', id], deps) (RULING D7), so the wizard and
11
+ * `amicus provider add` cannot drift apart: id-format + RESERVED_IDS
12
+ * validation, the plaintext-bearer warning, the shadow-namespace warning, the
13
+ * 2s reachability probe, and the shared per-provider default picker all come
14
+ * from that single path. handleProvider's own probe-success branch calls
15
+ * runProviderDefaultFlow non-interactively (no extra readline prompts here)
16
+ * and, per D4, remains the sole writer of config.default — this module never
17
+ * seeds it directly.
18
+ *
19
+ * Transport-agnostic by injection (mirrors provider-default-prompt.js): `ask`
20
+ * and `print` are passed in by the caller rather than imported, because
21
+ * setup.js defines neither as a bare identifier (B8) — its real idiom is the
22
+ * module-level `askQuestion(rl, prompt)` plus bare `console.log`. Taking them
23
+ * as parameters keeps this module callable without a TTY.
24
+ *
25
+ * Guarded end-to-end (house best-effort rule, mirrors provisionElectron /
26
+ * maybeMigrationNotice): any failure — a rejected probe, a validation error,
27
+ * a thrown handleProvider — is swallowed here. This optional wizard step must
28
+ * never abort the rest of `amicus setup`.
29
+ */
30
+
31
+ 'use strict';
32
+
33
+ const { PRESETS } = require('../utils/local-providers');
34
+
35
+ /**
36
+ * @param {(question: string) => Promise<string>} ask reads one line of input
37
+ * @param {(line: string) => void} print writes one line of output
38
+ * @param {object} [deps] forwarded verbatim to handleProvider (tests inject
39
+ * `probe`/`loadConfig`/`saveConfig`/etc.; production passes {} so
40
+ * handleProvider's own realDeps() perform the real I/O)
41
+ * @returns {Promise<void>}
42
+ */
43
+ async function addLocalProviderInteractive(ask, print, deps = {}) {
44
+ try {
45
+ const id = (await ask('Provider id (e.g. lmstudio, ollama, mylab): ')).trim();
46
+ if (!id) { return; }
47
+
48
+ const presetKey = (await ask('Preset? [ollama / lmstudio / vllm / none]: ')).trim().toLowerCase();
49
+ const args = { _: ['provider', 'add', id] };
50
+ // hasOwnProperty guard (trap #6/#8): a bare PRESETS[presetKey] for
51
+ // presetKey === 'constructor' would read the inherited
52
+ // Object.prototype.constructor (truthy) and wrongly skip the URL prompt
53
+ // below — the same recurring v4.2 bug class already guarded in
54
+ // cli-handlers-provider.js's entryFromArgs and local-providers.js.
55
+ if (Object.prototype.hasOwnProperty.call(PRESETS, presetKey)) {
56
+ args.preset = presetKey;
57
+ } else {
58
+ // D15: every preset already carries a baseURL, so a URL prompt is only
59
+ // needed for 'none' / a blank / an unrecognized preset name.
60
+ args.url = (await ask('Base URL (e.g. http://127.0.0.1:11434/v1): ')).trim();
61
+ }
62
+
63
+ // D7: one call performs id/format validation + save + probe + the shared
64
+ // default picker, and prints its own "Added '<id>' — N model(s) found."
65
+ // / unreachable message through the injected print/warn — both point at
66
+ // the same callback so nothing silently goes to realDeps' stderr instead
67
+ // of the wizard's own output stream.
68
+ const { handleProvider } = require('../cli-handlers-provider');
69
+ await handleProvider(args, { print, warn: print, ...deps });
70
+ } catch (_err) {
71
+ // Best-effort: a bug in this optional wizard step must never abort setup.
72
+ }
73
+ }
74
+
75
+ module.exports = { addLocalProviderInteractive };
@@ -144,6 +144,20 @@ async function launchWizard() {
144
144
  return launchSetupWindow();
145
145
  }
146
146
 
147
+ /**
148
+ * Local / self-hosted provider add step for the readline wizard (v4.2 §4.6,
149
+ * Task 12). Thin re-export -- RULING D14 keeps the real implementation out of
150
+ * this file (516 lines, grandfathered on check-file-sizes.js's exclude list).
151
+ * Owning module: ./setup-local.
152
+ * @param {(question: string) => Promise<string>} ask
153
+ * @param {(line: string) => void} print
154
+ * @param {object} [deps]
155
+ * @returns {Promise<void>}
156
+ */
157
+ function addLocalProviderInteractive(ask, print, deps) {
158
+ return require('./setup-local').addLocalProviderInteractive(ask, print, deps);
159
+ }
160
+
147
161
  /**
148
162
  * Standalone API key setup — launches the Electron window directly
149
163
  * Used by `sidecar setup --api-keys`
@@ -191,6 +205,11 @@ async function seedCatalog(print) {
191
205
  * (`runReadlineSetup` already fetches one for the per-provider phase) --
192
206
  * reused as-is to avoid a second `getCatalog()` round trip. Falls back to
193
207
  * fetching its own when omitted (e.g. direct unit-test callers).
208
+ * @returns {Promise<boolean>} true when the branch actually seeded a council
209
+ * (a completed setup -- the caller then prints the C8 doctor finale, Finding
210
+ * 2 post-review); false when it aborted early (no OPENROUTER_API_KEY, or
211
+ * fewer than 2 models picked) -- parity with the invalid-choice branch,
212
+ * which also configured nothing and so also gets no finale.
194
213
  */
195
214
  async function runFreeCouncilBranch(rl, catalogArg) {
196
215
  const keys = detectApiKeys();
@@ -198,7 +217,7 @@ async function runFreeCouncilBranch(rl, catalogArg) {
198
217
  console.log('');
199
218
  console.log('A free council needs OPENROUTER_API_KEY (free models route only through OpenRouter).');
200
219
  console.log('Set OPENROUTER_API_KEY and re-run: amicus setup. No changes made.');
201
- return;
220
+ return false;
202
221
  }
203
222
  const { listFreeModels, suggestFreeCouncil, PINNED_FREE_MODELS } = require('../utils/free-models');
204
223
  let catalog = [];
@@ -232,7 +251,7 @@ async function runFreeCouncilBranch(rl, catalogArg) {
232
251
  }
233
252
  if (pickIds.length < 2) {
234
253
  console.log('A council needs at least 2 models. No changes made.');
235
- return;
254
+ return false;
236
255
  }
237
256
  const { council } = seedFreeCouncil(pickIds);
238
257
  await seedCatalog();
@@ -243,6 +262,7 @@ async function runFreeCouncilBranch(rl, catalogArg) {
243
262
  console.log('');
244
263
  console.log('Heads up (free tier): rate-limited & quality-variable; some models 404');
245
264
  console.log('unless you enable data-sharing at openrouter.ai/settings/privacy.');
265
+ return true;
246
266
  }
247
267
 
248
268
  /**
@@ -293,6 +313,35 @@ async function runProviderDefaultPickers(rl, foundKeys, catalog) {
293
313
  return written;
294
314
  }
295
315
 
316
+ /**
317
+ * C8 (v4.2 §4.7) -- print the compact doctor summary at the wizard's finale.
318
+ * Shared by runReadlineSetup and the Electron-success path of
319
+ * runInteractiveSetup. Best-effort / guarded: a doctor bug (or a check that
320
+ * throws) must never abort setup or change its outcome -- setup has already
321
+ * done its job by the time this runs, so a failure here is swallowed.
322
+ *
323
+ * Injectable (post-review hardening, M14-class fix): `deps.runDoctorChecks`
324
+ * lets tests replace the real doctor directly instead of relying on jest's
325
+ * module-mock resolution coincidentally intercepting this function's own
326
+ * lazy require (see tests/sidecar/setup.test.js's `cli-handlers-doctor`
327
+ * mock, added for exactly this reason). Every production call site
328
+ * (runReadlineSetup / runInteractiveSetup) calls this with no args, so the
329
+ * default -- the real, network-probing runDoctorChecks -- is always what
330
+ * actually ships.
331
+ * @param {{runDoctorChecks?: () => Promise<Array<object>>}} [deps]
332
+ */
333
+ async function printDoctorFinale(deps = {}) {
334
+ try {
335
+ const runDoctorChecks = deps.runDoctorChecks || require('../cli-handlers-doctor').runDoctorChecks;
336
+ const { summarizeDoctor } = require('../utils/doctor-summary');
337
+ const checks = await runDoctorChecks();
338
+ console.log('');
339
+ console.log(summarizeDoctor(checks));
340
+ } catch (err) {
341
+ logger.debug('Doctor finale skipped', { error: err.message });
342
+ }
343
+ }
344
+
296
345
  /**
297
346
  * Run the readline-based setup wizard (headless fallback)
298
347
  *
@@ -302,6 +351,7 @@ async function runProviderDefaultPickers(rl, foundKeys, catalog) {
302
351
  * 3. Mode selection (standard or free council)
303
352
  * 4. Default model selection from live quick-picks (read-modify-write, no clobber)
304
353
  * 5. Config file save
354
+ * 6. C8: a compact `amicus doctor` summary, best-effort
305
355
  */
306
356
  async function runReadlineSetup() {
307
357
  const rl = readline.createInterface({
@@ -328,6 +378,17 @@ async function runReadlineSetup() {
328
378
  console.log('Set OPENROUTER_API_KEY to get started, or run: amicus setup');
329
379
  console.log(`Not sure what's wrong? ${runDoctor}`);
330
380
  }
381
+ // Local / self-hosted providers (v4.2 §4.6) already configured (e.g. via a
382
+ // prior `amicus provider add`) are listed alongside detected keys. Guarded:
383
+ // getLocalProviders() is documented never-fatal, but a listing step must
384
+ // not be able to block setup even if that contract is ever violated.
385
+ try {
386
+ const { getLocalProviders } = require('../utils/local-providers');
387
+ const localIds = Object.keys(getLocalProviders());
388
+ if (localIds.length > 0) {
389
+ console.log(`Local providers configured: ${localIds.join(', ')}`);
390
+ }
391
+ } catch (_err) { /* best-effort: never block setup over this listing */ }
331
392
  console.log('');
332
393
 
333
394
  // #38 — non-blocking zero-credit / free-tier OpenRouter warning. Never
@@ -346,15 +407,38 @@ async function runReadlineSetup() {
346
407
  // it never clobbers a vendor alias this phase just wrote (Fix 2).
347
408
  const vendorAliasesWritten = await runProviderDefaultPickers(rl, foundKeys, catalog);
348
409
 
410
+ // Task 12 (v4.2 §4.6): offer to add a local / self-hosted server. Pinned
411
+ // insertion point -- after the per-provider pickers, before the mode
412
+ // prompt, so all provider configuration stays grouped ahead of the
413
+ // standard/free-council branch. Guarded (house best-effort rule, mirrors
414
+ // provisionElectron/maybeMigrationNotice): addLocalProviderInteractive
415
+ // already swallows its own errors, but this optional step must not be
416
+ // able to abort the wizard even if that inner guard is ever weakened.
417
+ try {
418
+ const wantLocal = await askQuestion(rl,
419
+ 'Add a local / self-hosted server (Ollama, LM Studio, vLLM)? (y/N): ');
420
+ if (wantLocal.toLowerCase() === 'y' || wantLocal.toLowerCase() === 'yes') {
421
+ await addLocalProviderInteractive((q) => askQuestion(rl, q), console.log, {});
422
+ }
423
+ } catch (err) {
424
+ logger.debug('Local-provider wizard step skipped', { error: err.message });
425
+ }
426
+
349
427
  const mode = await askQuestion(rl,
350
428
  'Setup mode — 1) Standard (pick a default model) 2) Free OpenRouter council: ');
351
429
  if (mode === '2') {
352
- await runFreeCouncilBranch(rl, catalog);
430
+ const completed = await runFreeCouncilBranch(rl, catalog);
431
+ if (completed) {
432
+ // C8 (Finding 2, post-review): parity with the standard path below --
433
+ // a completed free-council setup gets the doctor finale too. An
434
+ // aborted attempt (no key / <2 picks) configured nothing, so -- like
435
+ // the invalid-choice branch just below -- it gets none.
436
+ await printDoctorFinale();
437
+ }
353
438
  return;
354
439
  }
355
440
 
356
- const { resolveQuickPicks, toLiveSeedAliases } = require('../utils/quick-picks');
357
- const { toCanonicalDefault } = require('../utils/curated-models');
441
+ const { resolveQuickPicks, toLiveSeedAliases, toStorableRoute } = require('../utils/quick-picks');
358
442
  const picks = resolveQuickPicks(catalog);
359
443
 
360
444
  console.log('Choose your default model:');
@@ -386,7 +470,7 @@ async function runReadlineSetup() {
386
470
  // choice), but the alias's VALUE must stay the vendor phase's tier choice --
387
471
  // skip the curated-flagship upgrade so it isn't discarded.
388
472
  if (pick && !chosen.noUpgrade && !vendorAliasesWritten.has(chosen.alias)) {
389
- cfg.aliases[chosen.alias] = toCanonicalDefault(pick.routes.openrouter || Object.values(pick.routes)[0]);
473
+ cfg.aliases[chosen.alias] = toStorableRoute(pick);
390
474
  } else if (cfg.aliases[chosen.alias] === undefined) {
391
475
  const fallback = getDefaultAliases()[chosen.alias];
392
476
  if (fallback !== undefined) { cfg.aliases[chosen.alias] = fallback; }
@@ -401,6 +485,9 @@ async function runReadlineSetup() {
401
485
  console.log(`Default model set to: ${cfg.default}`);
402
486
  console.log(`Config saved (${Object.keys(cfg.aliases).length} aliases).`);
403
487
  console.log(`Config path: ${path.join(getConfigDir(), 'config.json')}`);
488
+
489
+ // C8: compact doctor summary, best-effort (see printDoctorFinale).
490
+ await printDoctorFinale();
404
491
  } finally {
405
492
  rl.close();
406
493
  }
@@ -439,6 +526,13 @@ async function runInteractiveSetup() {
439
526
  if (keyLabel) { console.log(keyLabel); }
440
527
  if (modelLabel) { console.log(modelLabel); }
441
528
  console.log(`Config: ${configPath}`);
529
+
530
+ // C8: compact doctor summary, best-effort (see printDoctorFinale).
531
+ // runReadlineSetup prints its own further down -- only the Electron
532
+ // success path needs it added here explicitly; the fallback below
533
+ // delegates to runReadlineSetup and inherits its finale, so adding it
534
+ // here too would double-print.
535
+ await printDoctorFinale();
442
536
  return;
443
537
  }
444
538
  } catch (err) {
@@ -504,6 +598,7 @@ function seedFreeCouncil(pickIds) {
504
598
 
505
599
  module.exports = {
506
600
  addAlias,
601
+ addLocalProviderInteractive,
507
602
  createDefaultConfig,
508
603
  deriveFreeAlias,
509
604
  detectApiKeys,