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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +44 -0
- package/README.md +20 -1
- package/bin/amicus.js +10 -0
- package/electron/ipc-setup-local.js +109 -0
- package/electron/ipc-setup.js +14 -2
- package/electron/preload-setup.js +3 -1
- package/electron/setup-ui-local-script.js +114 -0
- package/electron/setup-ui-local.js +75 -0
- package/electron/setup-ui-styles.js +15 -1
- package/electron/setup-ui.js +6 -1
- package/package.json +1 -1
- package/scripts/postinstall.js +12 -211
- package/src/cli-handlers-doctor.js +12 -0
- package/src/cli-handlers-init.js +83 -0
- package/src/cli-handlers-key-local.js +144 -0
- package/src/cli-handlers-provider.js +212 -0
- package/src/cli-handlers.js +31 -3
- package/src/cli.js +21 -0
- package/src/sidecar/setup-local.js +75 -0
- package/src/sidecar/setup.js +101 -6
- package/src/utils/api-key-store.js +12 -62
- package/src/utils/claude-register.js +267 -0
- package/src/utils/config.js +82 -10
- package/src/utils/doctor-local-providers-check.js +62 -0
- package/src/utils/doctor-summary.js +33 -0
- package/src/utils/env-loader.js +13 -0
- package/src/utils/env-raw-store.js +111 -0
- package/src/utils/gateway-router.js +65 -2
- package/src/utils/lifecycle.js +2 -1
- package/src/utils/local-probe.js +109 -0
- package/src/utils/local-providers.js +141 -0
- package/src/utils/model-catalog.js +6 -2
- package/src/utils/model-fetcher.js +11 -1
- package/src/utils/pricing.js +23 -9
- package/src/utils/provider-default-picker.js +17 -2
- package/src/utils/provider-default-prompt.js +7 -4
- package/src/utils/quick-picks.js +35 -12
- package/src/utils/route-error.js +10 -3
- package/src/utils/route-launch.js +8 -65
- package/src/utils/route-suggestions.js +85 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "4.
|
|
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
|
[](https://www.npmjs.com/package/amicus)
|
|
12
14
|
[](./LICENSE)
|
|
13
15
|
[](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.
|
|
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 };
|
package/electron/ipc-setup.js
CHANGED
|
@@ -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 — LM Studio · Ollama · 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() {
|
package/electron/setup-ui.js
CHANGED
|
@@ -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.
|
|
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": [
|