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
|
@@ -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 };
|
package/src/cli-handlers.js
CHANGED
|
@@ -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
|
-
//
|
|
127
|
-
|
|
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 };
|
package/src/sidecar/setup.js
CHANGED
|
@@ -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] =
|
|
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,
|