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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "4.1.1",
3
+ "version": "4.2.0",
4
4
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
5
5
  "author": {
6
6
  "name": "Christian Wagner"
package/CHANGELOG.md CHANGED
@@ -5,6 +5,50 @@ All notable changes to Amicus are documented here. Format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [4.2.0] - 2026-07-23
9
+
10
+ ### Added
11
+
12
+ - **Local / OpenAI-compatible providers, at $0.** `amicus provider add <id> --preset
13
+ ollama|lmstudio|vllm` (or `--url <baseURL>` for any other OpenAI-compatible endpoint)
14
+ configures Ollama, LM Studio, vLLM, or a self-hosted server as a first-class model source —
15
+ `provider list|test|remove` manage them (all support `--json`). Local models price at a real,
16
+ explicit `$0` tier (not "unpriced"), so the budget gate, `amicus spend`, and the default-model
17
+ picker all treat them as free. `amicus key <localId> <token>` manages a local provider's bearer
18
+ (stored in the `0600` `.env`, never in `config.json`); `amicus doctor` gains a
19
+ `local-providers` reachability check (warn, never error); the setup wizard (readline and
20
+ Electron) offers to add one.
21
+ - **Local inference completes instead of hanging forever.** Local (`@ai-sdk/openai-compatible`)
22
+ provider blocks now carry a 5-minute engine request `timeout` and a non-empty `apiKey`
23
+ (`{env:VAR}` for a configured bearer, the literal `not-needed` otherwise), so `amicus
24
+ start`/`fanout`/`council` against Ollama/LM Studio/vLLM now complete. Local models need enough
25
+ context loaded to fit the ~26k-token agent prompt (**~32k** is a safe target — LM Studio's
26
+ ~16k default is not) and are slower than cloud to first token (30–90s prefill on a cold model
27
+ is normal) — see [`amicus provider`](docs/usage.md#amicus-provider).
28
+ - **`amicus init [--claude] [--desktop] [--json]`** re-runs the same skill-install + MCP
29
+ registration that `npm install`'s postinstall runs, on demand — for plugin-channel /
30
+ `--ignore-scripts` installs (which skip the postinstall), a failed postinstall, or repairing
31
+ deleted `~/.claude` state.
32
+
33
+ ## [4.1.2] - 2026-07-22
34
+
35
+ ### Fixed
36
+
37
+ - **Claude model aliases no longer drift between the direct API and OpenRouter.** The two
38
+ gateways spell the same model differently — OpenRouter serves `anthropic/claude-opus-4.8`,
39
+ the direct Anthropic API serves `anthropic/claude-opus-4-8` — and three places converted
40
+ between the two by adding or removing the `openrouter/` prefix. That is only sound when the
41
+ rest of the id is identical, which for Claude it is not. Two user-visible consequences: a fresh
42
+ `amicus setup` wrote an `opus` alias that `amicus doctor` then reported as stale (the exact
43
+ warning 4.1.1 shipped to remove — and it fired even with an Anthropic-only catalog, not just
44
+ for OpenRouter users), and the pre-registered fallback catalog handed to a long-lived shared
45
+ server carried two ids OpenRouter does not serve while omitting the two it does. All three
46
+ sites now read each gateway's authored route instead of deriving one from the other, and an
47
+ alias you have overridden yourself is left alone rather than inheriting a curated route.
48
+ Affects `opus` and `haiku`; every other alias is spelled identically on both gateways or is
49
+ OpenRouter-only. Default routing is direct-first and was never affected — no run selected a
50
+ wrong model.
51
+
8
52
  ## [4.1.1] - 2026-07-21
9
53
 
10
54
  ### Fixed
package/README.md CHANGED
@@ -8,6 +8,8 @@
8
8
 
9
9
  Hand Claude a plan, a design, a diff, an architecture decision, a manuscript — anything — and say *council review this*: Amicus routes it through several models from different families, has them anonymously cross-review each other, and a non-Claude chair synthesizes a verdict you turn into accept/deny edits. Or skip the ceremony and **fork** a single conversation to Gemini, GPT, DeepSeek, or any other model — it works in parallel with full context, and you **fold** the result back when you're ready. Claude orchestrates throughout; you stay in your editor.
10
10
 
11
+ **The same council — more ways to run it:** take it **headless in CI** with no Claude runtime, sharpen it with a **debate round**, or run it on **free local models** (Ollama, LM Studio, vLLM) at **$0** — private and offline.
12
+
11
13
  [![npm version](https://img.shields.io/npm/v/amicus?color=D97757&labelColor=1A1C29)](https://www.npmjs.com/package/amicus)
12
14
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue?labelColor=1A1C29)](./LICENSE)
13
15
  [![Node.js](https://img.shields.io/badge/node-%3E%3D18-brightgreen?labelColor=1A1C29)](https://nodejs.org)
@@ -22,6 +24,7 @@ Hand Claude a plan, a design, a diff, an architecture decision, a manuscript —
22
24
  ## Table of Contents
23
25
 
24
26
  - [What is Amicus](#what-is-amicus)
27
+ - [Ways to run the council](#ways-to-run-the-council)
25
28
  - [Quick start](#quick-start)
26
29
  - [Requirements & Dependencies](#requirements--dependencies)
27
30
  - [The Council](#the-council)
@@ -55,6 +58,17 @@ Claude is the orchestrator. The council and chat skills run *on top of* the engi
55
58
 
56
59
  ---
57
60
 
61
+ ## Ways to run the council
62
+
63
+ The council is the hero — start with the everyday way, and reach for the more powerful ways when you need them:
64
+
65
+ - **Just ask, in Claude Code.** Hand Claude a plan, diff, design, or manuscript and say *"council review this."* The `second-opinion` skill runs the whole ritual in your session — several models review independently → anonymized cross-review → a non-Claude chair verdict → tiered accept/deny edits — with no setup beyond your API keys. This is how most people use it. → [The Council](#the-council)
66
+ - **Headless, in CI, with no Claude runtime.** `amicus council run --prompt-file plan.md --council free` runs that same pipeline in one command — reviews → cross-review → tally → chair verdict — writing `verdict.json` and `report.html`. It needs no Claude session, so it drops straight into CI. → [Headless council](./docs/council.md#amicus-council-run)
67
+ - **With a debate round.** Add `--debate` and every Contested or Disputed finding goes back to its raiser to **defend, amend, or withdraw** while the disputing judges re-vote — exactly one rebuttal round, then the final tally. → [The Council](#the-council)
68
+ - **On free, local, private models — at $0.** Point the council (and sidecars) at an OpenAI-compatible server already running on your machine — Ollama, LM Studio, or vLLM — with `amicus provider add`. No API key, no per-token bill, nothing leaves your machine, and it works offline. → [`amicus provider`](./docs/usage.md#amicus-provider)
69
+
70
+ ---
71
+
58
72
  ## Quick start
59
73
 
60
74
  > **Two install channels — read this first.** Amicus ships two ways, and CLI commands look different in each:
@@ -99,6 +113,8 @@ For the **npm** and **install-script** paths, a postinstall auto-configures ever
99
113
  - Registers the **MCP server** in Claude Code and in Claude Desktop / Cowork, so the Amicus tools appear natively.
100
114
  - Installs **both skills** into `~/.claude/skills/` — `second-opinion` (the council) and `sidecar` (the chat skill).
101
115
 
116
+ > **Skipped the postinstall?** `--ignore-scripts` npm installs never run it, and the plugin channel skips it by design (Claude Code registers the plugin's MCP server and skills itself). Either way, run `amicus init` (plugin channel: `npx -y amicus@latest init`) any time to (re)register on demand — e.g. to also wire up Claude Desktop, which the plugin path doesn't touch. See [`amicus init`](./docs/usage.md#amicus-init).
117
+
102
118
  **Configure:**
103
119
 
104
120
  ```bash
@@ -167,6 +183,7 @@ Everything you need before your first run, and what's optional.
167
183
  **Model API keys** — at least one is required
168
184
 
169
185
  - **OpenRouter** covers the most models with one key, or use a **direct Google / OpenAI / Anthropic / DeepSeek** key. Add one with `amicus setup` or `amicus key <provider> <key>`; keys live in `~/.config/amicus/.env`. The supported env vars are `OPENROUTER_API_KEY`, `GOOGLE_GENERATIVE_AI_API_KEY` (the legacy `GEMINI_API_KEY` is still accepted), `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, and `DEEPSEEK_API_KEY`.
186
+ - **Or skip cloud keys entirely and run local, at $0.** Point Amicus at an OpenAI-compatible server already running on your machine — LM Studio, Ollama, or vLLM — and there's no API key, no per-token bill: `amicus provider add lmstudio --preset lmstudio` (LM Studio), `--preset ollama` (Ollama), or `--preset vllm` (vLLM). See [`amicus provider`](./docs/usage.md#amicus-provider).
170
187
  - ⚠️ **OpenRouter keys need purchased credits.** A brand-new, zero-credit key *passes* setup's live validation but then fails at runtime with a **402** on the first real call. Buy a small amount of credit before you run a council.
171
188
 
172
189
  **Electron — optional (GUI only)**
@@ -300,12 +317,14 @@ amicus update
300
317
  | `amicus doctor` | Diagnose your setup — keys, default model, catalog, aliases, OpenCode binary, Electron, skills, MCP registration, OpenRouter credit (`--json`; `--fix` self-heals what it can). |
301
318
  | `amicus spend` | Cross-run cost rollup from the spend ledger — total + per-model spend, tokens, and source mix, most-expensive first (`--since 7d` windows it; `--json` for a versioned doc; shows remaining OpenRouter credit when a key is configured). |
302
319
  | `amicus key` | Manage API keys non-interactively: `amicus key <provider> <key>` saves after live validation; `--remove`; bare `amicus key` lists providers. |
320
+ | `amicus provider` | Add/list/test/remove local, OpenAI-compatible providers (LM Studio, Ollama, vLLM) — configured with `--preset` or `--url`, at **$0** marginal cost (`--json` on every subcommand). |
303
321
  | `amicus council` | Council math: `tally <input.json>` (deterministic tiers + ledger append), `stats` (reviewer reliability), `report <verdict.json> [--md\|--html]`, `validate <file>` (findings-block check, exit 0/2/1), `verdict <tally.json> [--decisions <d.json>] [-o <out.json>]` (build + write verdict.json). Presets: `save <name> --models a,b,c`, `list [--json]`, `show <name> [--json]` — see [The Council](#the-council) for the built-in `free`/`budget`/`frontier` benches. |
304
322
  | `amicus council run` | The headless council engine: Stage-1 reviews → anonymized cross-review → deterministic tally → non-Claude chair verdict, in one command with no Claude runtime. Add `--debate` for a Stage-2.5 rebuttal round (raisers defend/amend/withdraw, disputing judges re-vote) and `--claude-review <file>` to enter Claude's own review as judged review N+1. Writes a run directory with `verdict.json` (including `overallVerdict`) and `report.html` — see [docs/council.md](./docs/council.md#amicus-council-run). |
305
323
  | `amicus abort` | Abort a running session (or `--all`). |
306
324
  | `amicus setup` | Configure default model, API keys, and aliases. |
307
325
  | `amicus update` | Update to the latest version. |
308
326
  | `amicus mcp` | Start the MCP server (stdio transport). |
327
+ | `amicus init` | Re-run skill install + MCP registration on demand — `--claude`/`--desktop` to scope it, `--json` for per-step status. For plugin-channel / `--ignore-scripts` installs (which skip the npm postinstall), a failed postinstall, or repairing deleted `~/.claude` state. |
309
328
 
310
329
  The `am` alias is interchangeable with `amicus` everywhere.
311
330
 
@@ -329,7 +348,7 @@ $ amicus status demo123 --json
329
348
  "taskId": "demo123",
330
349
  "status": "complete",
331
350
  "elapsed": "5m 0s",
332
- "version": "4.1.1",
351
+ "version": "4.2.0",
333
352
  "model": "google/gemini-2.5-flash",
334
353
  "phase": "terminal"
335
354
  }
package/bin/amicus.js CHANGED
@@ -117,11 +117,21 @@ async function main() {
117
117
  exitCode = await handleDoctor(args);
118
118
  break;
119
119
  }
120
+ case 'init': {
121
+ const { handleInit } = require('../src/cli-handlers-init');
122
+ exitCode = await handleInit(args);
123
+ break;
124
+ }
120
125
  case 'spend': {
121
126
  const { handleSpend } = require('../src/cli-handlers-spend');
122
127
  exitCode = await handleSpend(args);
123
128
  break;
124
129
  }
130
+ case 'provider': {
131
+ const { handleProvider } = require('../src/cli-handlers-provider');
132
+ exitCode = await handleProvider(args);
133
+ break;
134
+ }
125
135
  case 'setup':
126
136
  await handleSetup(args);
127
137
  break;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * IPC handlers for the Electron wizard's "Local server" card (Task 13, v4.2 §4.6).
3
+ *
4
+ * Split out of ipc-setup.js purely to keep that file under the 300-line size gate
5
+ * (mirrors RULING D14's extraction of src/sidecar/setup-local.js out of setup.js for
6
+ * the identical reason). registerLocalProviderHandlers is called from
7
+ * registerSetupHandlers with the same (possibly test-injected) ipcMain, so these two
8
+ * channels are registered on exactly the instance the rest of the setup-mode IPC
9
+ * surface uses — this split changes no behavior, only file layout.
10
+ *
11
+ * setup:probe-local reuses Task 2's probeLocalProvider (the same reachability check
12
+ * `amicus provider test` and the readline wizard's handleProvider already share) and
13
+ * deliberately returns only a model COUNT — never the model list or the
14
+ * bearer/Authorization header — so nothing sensitive crosses the preload bridge
15
+ * beyond what the renderer asked for.
16
+ *
17
+ * setup:save-local-provider reuses the same validateProviderEntry/deriveKeyEnv
18
+ * (Task 1) and saveRawEnv (api-key-store.js) the CLI's `amicus provider add` uses,
19
+ * so a bearer always lands in the 0600 .env under its derived env-var NAME and
20
+ * config.json only ever carries that name (never the secret). Per B7/D4,
21
+ * applyProviderDefault (provider-default-picker.js) remains the SOLE writer of
22
+ * config.default — this handler never seeds it, even on a successful save.
23
+ */
24
+ 'use strict';
25
+
26
+ const { logger } = require('../src/utils/logger');
27
+
28
+ /**
29
+ * Exact-hostname loopback + scheme check (mirrors cli-handlers-provider.js's
30
+ * isLoopbackUrl/isPlaintextRemote pair). Duplicated locally rather than imported —
31
+ * leaf-module convention (see local-providers.js's RESERVED_IDS comment) — since
32
+ * those helpers are private to the CLI-oriented module.
33
+ * @param {string} baseURL
34
+ * @returns {boolean} true when a bearer sent here would travel in cleartext
35
+ */
36
+ function isPlaintextRemoteBearer(baseURL) {
37
+ try {
38
+ const u = new URL(baseURL);
39
+ const loopback = ['127.0.0.1', 'localhost', '::1', '[::1]'].includes(u.hostname);
40
+ return u.protocol === 'http:' && !loopback;
41
+ } catch { return false; }
42
+ }
43
+
44
+ /**
45
+ * @param {object} ipcMain - electron's ipcMain, or the test double
46
+ * registerSetupHandlers was called with — same instance the rest of
47
+ * ipc-setup.js's handlers are registered on.
48
+ */
49
+ function registerLocalProviderHandlers(ipcMain) {
50
+ // Probe handler (Test connection button): gated through validateProviderEntry
51
+ // first so an invalid-input vs. unreachable-server distinction never depends on
52
+ // the network layer.
53
+ ipcMain.handle('setup:probe-local', async (_event, entry) => {
54
+ try {
55
+ const { validateProviderEntry } = require('../src/utils/local-providers');
56
+ const v = validateProviderEntry({ type: 'openai-compatible', baseURL: entry.baseURL, flavor: entry.flavor });
57
+ if (!v.ok) { return { ok: false, error: v.error }; }
58
+ const { probeLocalProvider } = require('../src/utils/local-probe');
59
+ const probe = await probeLocalProvider({ ...v.normalized, id: 'probe' }, { timeoutMs: 2000, bearer: entry.bearer });
60
+ return probe.status === 'ok' ? { ok: true, count: probe.models.length } : { ok: false, error: 'unreachable' };
61
+ } catch (e) { return { ok: false, error: e.message }; }
62
+ });
63
+
64
+ // Save handler (Save button): validate -> persist bearer to .env (if any) ->
65
+ // write config.providers[id]. Reserved/malformed ids are rejected here because
66
+ // validateProviderEntry is id-agnostic (the map key carries the id).
67
+ ipcMain.handle('setup:save-local-provider', async (_event, entry) => {
68
+ try {
69
+ const { validateProviderEntry, deriveKeyEnv, RESERVED_IDS, ID_RE } = require('../src/utils/local-providers');
70
+ const id = entry && typeof entry.id === 'string' ? entry.id.trim() : '';
71
+ if (!ID_RE.test(id) || RESERVED_IDS.includes(id)) {
72
+ return { ok: false, error: `Invalid or reserved provider id: '${id}'` };
73
+ }
74
+ const draft = { type: 'openai-compatible', baseURL: entry.baseURL, flavor: entry.flavor };
75
+ if (entry.pricing) { draft.pricing = entry.pricing; }
76
+ const v = validateProviderEntry(draft);
77
+ if (!v.ok) { return { ok: false, error: v.error }; }
78
+ const normalized = v.normalized;
79
+ // Bearer: value -> .env under the derived env var; config carries the NAME only.
80
+ if (entry.bearer) {
81
+ const envVar = deriveKeyEnv(id);
82
+ const { saveRawEnv } = require('../src/utils/api-key-store');
83
+ const saved = saveRawEnv(envVar, entry.bearer);
84
+ if (!saved.success) { return { ok: false, error: saved.error }; }
85
+ normalized.apiKeyEnv = envVar;
86
+ }
87
+ const { loadConfig, saveConfig } = require('../src/utils/config');
88
+ const config = loadConfig() || {};
89
+ config.providers = config.providers || {};
90
+ config.providers[id] = normalized; // undefined name/apiKeyEnv keys are dropped by JSON.stringify
91
+ // B7/D4: applyProviderDefault is the SOLE writer of config.default. A bare id with no
92
+ // matching config.aliases[id] is unresolvable by resolveModel() and permanently breaks
93
+ // every later keyless launch. Do NOT seed it here.
94
+ saveConfig(config);
95
+ const result = { ok: true, id, bearer: !!entry.bearer };
96
+ // Security posture parity with the CLI (cli-handlers-provider.js's doAdd):
97
+ // warn, never block, a bearer headed over plain http to a non-loopback host.
98
+ if (entry.bearer && isPlaintextRemoteBearer(entry.baseURL)) {
99
+ result.warning = 'Sending a bearer token over plain http:// to a non-loopback host transmits it in cleartext.';
100
+ }
101
+ return result;
102
+ } catch (err) {
103
+ logger.error('save-local-provider handler error', { error: err.message });
104
+ return { ok: false, error: err.message };
105
+ }
106
+ });
107
+ }
108
+
109
+ module.exports = { registerLocalProviderHandlers, isPlaintextRemoteBearer };
@@ -10,14 +10,21 @@
10
10
  * uncached live fetch.)
11
11
  */
12
12
 
13
- const { ipcMain } = require('electron');
14
13
  const { logger } = require('../src/utils/logger');
14
+ const { registerLocalProviderHandlers } = require('./ipc-setup-local');
15
15
 
16
16
  /**
17
17
  * Register all setup-related IPC handlers
18
18
  * @param {function} getMainWindow - Returns the current main BrowserWindow
19
+ * @param {object} [injected] - DI overrides
20
+ * @param {object} [injected.ipcMain] - Electron ipcMain (or a test double exposing
21
+ * .handle). Defaults to the real `require('electron').ipcMain` — production call
22
+ * sites (electron/main.js) pass none. Task 13 / C9: this parameter shadows what
23
+ * used to be a module-level `const { ipcMain } = require('electron');`, which is
24
+ * removed rather than left dead now that every ipcMain.handle(...) call below
25
+ * resolves through this local binding instead.
19
26
  */
20
- function registerSetupHandlers(getMainWindow) {
27
+ function registerSetupHandlers(getMainWindow, { ipcMain = require('electron').ipcMain } = {}) {
21
28
  ipcMain.handle('sidecar:validate-key', async (_event, provider, key) => {
22
29
  try {
23
30
  const { validateApiKey } = require('../src/utils/api-key-store');
@@ -237,6 +244,11 @@ function registerSetupHandlers(getMainWindow) {
237
244
  }));
238
245
  } catch (_err) { return []; }
239
246
  });
247
+
248
+ // Task 13 (v4.2 §4.6): Electron wizard "Local server" card — probe + save.
249
+ // Extracted to ipc-setup-local.js to keep this file under the size gate (see its
250
+ // header comment); registered on the SAME (possibly injected) ipcMain as above.
251
+ registerLocalProviderHandlers(ipcMain);
240
252
  }
241
253
 
242
254
  module.exports = { registerSetupHandlers };
@@ -23,7 +23,9 @@ contextBridge.exposeInMainWorld('sidecarSetup', {
23
23
  'sidecar:get-catalog',
24
24
  'sidecar:refresh-catalog',
25
25
  'sidecar:fetch-free-models',
26
- 'sidecar:set-provider-default'
26
+ 'sidecar:set-provider-default',
27
+ 'setup:probe-local',
28
+ 'setup:save-local-provider'
27
29
  ];
28
30
  if (!allowedChannels.includes(channel)) {
29
31
  throw new Error(`IPC channel not allowed: ${channel}`);
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Setup UI — Local server widget runtime script (Task 13, v4.2 §4.6).
3
+ *
4
+ * Companion to setup-ui-local.js's HTML. Preset chips fill the URL field and track
5
+ * a flavor (defaulting to 'generic' the moment the URL no longer matches any chip's
6
+ * preset value — mirrors cli-handlers-provider.js's entryFromArgs: `(preset &&
7
+ * preset.flavor) || 'generic'`). "Test connection" invokes setup:probe-local and
8
+ * renders the returned model count or error; Save invokes setup:save-local-provider
9
+ * and renders its result (including the plaintext-bearer warning, D-posture parity
10
+ * with the CLI's doAdd). Model/error strings from the response are rendered via
11
+ * textContent only — never innerHTML — mirroring the no-innerHTML-for-catalog-data
12
+ * convention setup-ui-provider-default.js documents.
13
+ */
14
+ 'use strict';
15
+
16
+ /**
17
+ * Build the local-server widget JS for inline inclusion in the wizard script.
18
+ * @returns {string} JavaScript source (no <script> tags)
19
+ */
20
+ function buildLocalScript() {
21
+ return `
22
+ // Task 13: Local server card (Ollama / LM Studio / vLLM / any OpenAI-compatible).
23
+ (function() {
24
+ var idInput = document.getElementById('local-id-input');
25
+ var urlInput = document.getElementById('local-url-input');
26
+ var bearerInput = document.getElementById('local-bearer-input');
27
+ var testBtn = document.getElementById('local-test-btn');
28
+ var saveBtn = document.getElementById('local-save-btn');
29
+ var statusMsg = document.getElementById('local-status-msg');
30
+ var chips = Array.prototype.slice.call(document.querySelectorAll('.local-chip'));
31
+ var selectedFlavor = 'generic';
32
+
33
+ function setStatus(text, cls) {
34
+ if (!statusMsg) { return; }
35
+ statusMsg.textContent = text || '';
36
+ statusMsg.className = cls || '';
37
+ }
38
+
39
+ chips.forEach(function(chip) {
40
+ chip.addEventListener('click', function() {
41
+ chips.forEach(function(c) { c.classList.remove('active'); });
42
+ chip.classList.add('active');
43
+ var preset = chip.getAttribute('data-preset');
44
+ if (urlInput) { urlInput.value = chip.getAttribute('data-url'); }
45
+ if (idInput && !idInput.value.trim()) { idInput.value = preset; }
46
+ selectedFlavor = chip.getAttribute('data-flavor');
47
+ setStatus('', '');
48
+ });
49
+ });
50
+
51
+ if (urlInput) {
52
+ urlInput.addEventListener('input', function() {
53
+ var matched = chips.filter(function(c) { return c.getAttribute('data-url') === urlInput.value; });
54
+ selectedFlavor = matched.length > 0 ? matched[0].getAttribute('data-flavor') : 'generic';
55
+ if (matched.length === 0) { chips.forEach(function(c) { c.classList.remove('active'); }); }
56
+ });
57
+ }
58
+
59
+ if (testBtn) {
60
+ testBtn.addEventListener('click', async function() {
61
+ var url = (urlInput && urlInput.value.trim()) || '';
62
+ if (!url) { setStatus('Enter a server URL first', 'status-invalid'); return; }
63
+ testBtn.disabled = true; testBtn.textContent = 'Testing...';
64
+ setStatus('', '');
65
+ try {
66
+ var res = await window.sidecarSetup.invoke('setup:probe-local', {
67
+ baseURL: url, flavor: selectedFlavor, bearer: (bearerInput && bearerInput.value) || undefined,
68
+ });
69
+ if (res && res.ok) {
70
+ setStatus(res.count + ' model' + (res.count === 1 ? '' : 's') + ' found \\u2713', 'status-valid');
71
+ } else {
72
+ setStatus((res && res.error) || 'Unreachable', 'status-invalid');
73
+ }
74
+ } catch (_e) {
75
+ setStatus('Connection failed', 'status-invalid');
76
+ }
77
+ testBtn.disabled = false; testBtn.textContent = 'Test connection';
78
+ });
79
+ }
80
+
81
+ if (saveBtn) {
82
+ saveBtn.addEventListener('click', async function() {
83
+ var id = (idInput && idInput.value.trim()) || '';
84
+ var url = (urlInput && urlInput.value.trim()) || '';
85
+ if (!id || !url) { setStatus('Provider ID and server URL are required', 'status-invalid'); return; }
86
+ saveBtn.disabled = true; saveBtn.textContent = 'Saving...';
87
+ try {
88
+ var res = await window.sidecarSetup.invoke('setup:save-local-provider', {
89
+ id: id, baseURL: url, flavor: selectedFlavor, bearer: (bearerInput && bearerInput.value) || undefined,
90
+ });
91
+ if (res && res.ok) {
92
+ // A returned warning (plaintext-bearer-to-remote-host, ipc-setup.js) is
93
+ // shown with the same visual weight as an error even though the save
94
+ // itself succeeded -- it is safety-relevant, not merely informational.
95
+ setStatus(res.warning || 'Saved \\u2713', res.warning ? 'status-invalid' : 'status-valid');
96
+ // Configuring a local provider counts toward "at least one provider
97
+ // ready" the same way a cloud API key does, so Step 1's Next button
98
+ // unlocks for a local-only setup too.
99
+ if (typeof configuredKeys === 'object') { configuredKeys[id] = true; }
100
+ if (typeof updateNextState === 'function') { updateNextState(); }
101
+ } else {
102
+ setStatus((res && res.error) || 'Save failed', 'status-invalid');
103
+ }
104
+ } catch (_e) {
105
+ setStatus('Save failed', 'status-invalid');
106
+ }
107
+ saveBtn.disabled = false; saveBtn.textContent = 'Save local server';
108
+ });
109
+ }
110
+ })();
111
+ `;
112
+ }
113
+
114
+ module.exports = { buildLocalScript };
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Setup UI — "Local server" step-1 add-on widget (Task 13, v4.2 §4.6).
3
+ *
4
+ * The Electron-wizard equivalent of Task 12's readline `addLocalProviderInteractive`
5
+ * (src/sidecar/setup-local.js): lets the user point the wizard at a self-hosted
6
+ * OpenAI-compatible server (Ollama, LM Studio, vLLM, or any other) without leaving
7
+ * the GUI. LM Studio leads the chip order and the widget copy — a LOCKED parity
8
+ * constraint (the readline wizard's own preset prompt order is `ollama / lmstudio /
9
+ * vllm`, but this card is not required to mirror that order).
10
+ *
11
+ * RULING B12/D8: `electron/setup-ui-keys.js`'s provider cards (buildKeysStepHTML)
12
+ * are bare id/name/description buttons feeding ONE shared key-section — there is no
13
+ * per-provider card with its own fields to extend, and this widget needs two
14
+ * simultaneous fields (URL + bearer) plus N preset chips plus a differently-behaved
15
+ * Test button. So, per the convention `setup-ui-provider-default.js` and
16
+ * `setup-ui-council.js` already establish, this is its own `setup-ui-*.js` +
17
+ * `*-script.js` pair, spliced into `setup-ui.js`'s step-1 assembly, rather than
18
+ * grown into setup-ui-keys.js / setup-ui-keys-script.js.
19
+ *
20
+ * Consumes (at render time): `PRESETS` (src/utils/local-providers.js, Task 1) for the
21
+ * three built-in chip URLs/flavors — single-sourced rather than re-hardcoded here.
22
+ * The runtime script (setup-ui-local-script.js) additionally consumes the
23
+ * `setup:probe-local` / `setup:save-local-provider` IPC handlers (ipc-setup.js,
24
+ * Task 13) at click time.
25
+ */
26
+ 'use strict';
27
+
28
+ const { PRESETS } = require('../src/utils/local-providers');
29
+
30
+ /** LOCKED parity constraint: LM Studio leads (chip order + widget title/copy). */
31
+ const CHIP_ORDER = [
32
+ { id: 'lmstudio', label: 'LM Studio' },
33
+ { id: 'ollama', label: 'Ollama' },
34
+ { id: 'vllm', label: 'vLLM' },
35
+ ];
36
+
37
+ /**
38
+ * Build the HTML fragment for the Local server widget (spliced into wizard-step-1,
39
+ * beneath the key step and the per-provider default picker section).
40
+ * @returns {string} HTML fragment (not a full document)
41
+ */
42
+ function buildLocalSectionHTML() {
43
+ const chips = CHIP_ORDER.map((c) => {
44
+ const preset = PRESETS[c.id];
45
+ return `<button type="button" class="local-chip" data-preset="${c.id}" ` +
46
+ `data-url="${preset.baseURL}" data-flavor="${preset.flavor}">${c.label}</button>`;
47
+ }).join('\n ');
48
+
49
+ return `<div class="local-section" id="local-section">
50
+ <div class="local-divider">Local server &mdash; LM Studio &middot; Ollama &middot; vLLM</div>
51
+ <p class="local-subtitle">Connect a self-hosted OpenAI-compatible server. No API key required for most.</p>
52
+ <div class="local-chips" id="local-chips">
53
+ ${chips}
54
+ </div>
55
+ <label class="field-label" for="local-id-input">Provider ID</label>
56
+ <div class="input-row">
57
+ <input id="local-id-input" type="text" placeholder="lmstudio" autocomplete="off" spellcheck="false">
58
+ </div>
59
+ <label class="field-label" for="local-url-input">Server URL</label>
60
+ <div class="input-row">
61
+ <input id="local-url-input" type="text" placeholder="http://127.0.0.1:1234/v1" autocomplete="off" spellcheck="false">
62
+ </div>
63
+ <label class="field-label" for="local-bearer-input">Bearer token (optional)</label>
64
+ <div class="input-row">
65
+ <input id="local-bearer-input" type="password" placeholder="" autocomplete="off" spellcheck="false">
66
+ <button class="test-btn" id="local-test-btn" type="button">Test connection</button>
67
+ </div>
68
+ <div class="key-actions">
69
+ <span id="local-status-msg"></span>
70
+ <button class="nav-btn" id="local-save-btn" type="button">Save local server</button>
71
+ </div>
72
+ </div>`;
73
+ }
74
+
75
+ module.exports = { buildLocalSectionHTML, CHIP_ORDER };
@@ -427,7 +427,21 @@ function __rawWizardCSS() {
427
427
  border-radius: var(--r-6);
428
428
  }
429
429
  .provider-default-row:hover { background: var(--surface-hover); }
430
- .provider-default-row input[type="radio"] { accent-color: var(--accent); flex-shrink: 0; }`;
430
+ .provider-default-row input[type="radio"] { accent-color: var(--accent); flex-shrink: 0; }
431
+
432
+ /* Local server card (Step 1, Task 13) -- grep -ri chip electron/ had zero hits before this */
433
+ .local-section { margin-top: 14px; border-top: 1px solid var(--border); padding-top: 12px; }
434
+ .local-divider { font-size: 12px; font-weight: 600; color: var(--text-muted); margin-bottom: 4px; }
435
+ .local-subtitle { color: var(--text-faint); font-size: 11px; margin-bottom: 8px; }
436
+ .local-chips { display: flex; gap: 6px; margin-bottom: 10px; flex-wrap: wrap; }
437
+ .local-chip {
438
+ padding: 4px 10px; font-size: 11px; font-weight: 500;
439
+ background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-4);
440
+ color: var(--text-muted); cursor: pointer;
441
+ transition: border-color var(--dur-fast), color var(--dur-fast), background var(--dur-fast);
442
+ }
443
+ .local-chip:hover { border-color: var(--border-strong); color: var(--text); }
444
+ .local-chip.active { background: var(--accent); color: var(--accent-text); border-color: var(--accent); }`;
431
445
  }
432
446
 
433
447
  function buildWizardCSS() {
@@ -7,6 +7,8 @@ const { buildKeysScript } = require('./setup-ui-keys-script');
7
7
  const { buildAliasScript } = require('./setup-ui-alias-script');
8
8
  const { buildCouncilSectionHTML, buildCouncilScript } = require('./setup-ui-council');
9
9
  const { buildProviderDefaultSectionHTML, buildProviderDefaultScript } = require('./setup-ui-provider-default');
10
+ const { buildLocalSectionHTML } = require('./setup-ui-local');
11
+ const { buildLocalScript } = require('./setup-ui-local-script');
10
12
  const { getDefaultAliases } = require('../src/utils/config');
11
13
  const { getBrandName } = require('./toolbar');
12
14
  const { resolveQuickPicks } = require('../src/utils/quick-picks');
@@ -41,7 +43,7 @@ function buildSetupHTML(options = {}) {
41
43
  <div class="header"><svg width="18" height="18" viewBox="0 0 32 32" fill="none"><path d="M4 8H19"/><path d="M4 11H14L19 8"/><path d="M4 14H13L19 8"/><path d="M4 17H12L19 8"/><path d="M4 20H11L19 8"/><path d="M4 23H10L19 8"/><path class="brand-main" d="M19 8H28"/></svg><span class="header-title">${brandName} Setup</span></div>
42
44
  <div class="progress-bar"><div class="progress-step active" id="step-1"><span class="progress-dot">1</span><span>API Keys</span></div><div class="progress-connector"></div><div class="progress-step" id="step-2"><span class="progress-dot">2</span><span>Models</span></div><div class="progress-connector"></div><div class="progress-step" id="step-3"><span class="progress-dot">3</span><span>Routing</span></div><div class="progress-connector"></div><div class="progress-step" id="step-4"><span class="progress-dot">4</span><span>Review</span></div></div>
43
45
  <div class="content">
44
- <div class="wizard-step visible" id="wizard-step-1"><div id="import-notice"></div>${keysHtml}${buildProviderDefaultSectionHTML()}</div>
46
+ <div class="wizard-step visible" id="wizard-step-1"><div id="import-notice"></div>${keysHtml}${buildProviderDefaultSectionHTML()}${buildLocalSectionHTML()}</div>
45
47
  <div class="wizard-step" id="wizard-step-2">${modelHtml}${buildCouncilSectionHTML()}</div>
46
48
  <div class="wizard-step" id="wizard-step-3">${aliasHtml}</div>
47
49
  <div class="wizard-step" id="wizard-step-4">
@@ -65,6 +67,7 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
65
67
  const aliasJs = buildAliasScript();
66
68
  const councilJs = buildCouncilScript();
67
69
  const providerDefaultJs = buildProviderDefaultScript();
70
+ const localJs = buildLocalScript();
68
71
  return `<script>
69
72
  window.onerror = function(msg, src, line, col, err) { console.error('WIZARD ERROR:', msg, 'at', src, line, col, err); };
70
73
  window.onunhandledrejection = function(e) { console.error('WIZARD UNHANDLED REJECTION:', e.reason); };
@@ -578,6 +581,8 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
578
581
  ${councilJs}
579
582
 
580
583
  ${providerDefaultJs}
584
+
585
+ ${localJs}
581
586
  </script>`;
582
587
  }
583
588
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "4.1.1",
3
+ "version": "4.2.0",
4
4
  "mcpName": "io.github.BourbonDog/amicus",
5
5
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
6
6
  "keywords": [