@polderlabs/bizar 6.2.1 → 6.2.3

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.
@@ -0,0 +1,466 @@
1
+ /**
2
+ * cli/commands/setup-provider.mjs
3
+ *
4
+ * v6.2.3 — `bizar setup-provider` subcommand.
5
+ *
6
+ * v6.2.2 wrote to `~/.cline/cline.json` (the harness config). That was
7
+ * the wrong place — the Cline CLI and kanban mode read from
8
+ * `~/.cline/data/settings/providers.json` (the real Cline settings
9
+ * file). v6.2.3 fixes this.
10
+ *
11
+ * The default providerId is `litellm` because:
12
+ * - It's a real entry in Cline's built-in provider catalog (works
13
+ * in both Cline CLI AND kanban mode)
14
+ * - It's `family: "openai-compatible"` and accepts a custom
15
+ * baseUrl — the perfect match for the local 9Router gateway
16
+ * - The previous v6.2.2 default `9router` (and the user's hand-
17
+ * written `openai-compatible`) are NOT in Cline's catalog, so
18
+ * kanban mode fails with "Unknown or disabled provider".
19
+ *
20
+ * Usage:
21
+ * bizar setup-provider # default 9Router gateway, litellm provider
22
+ * bizar setup-provider --list # print the live model catalog
23
+ * bizar setup-provider --gateway <url> # override gateway URL
24
+ * bizar setup-provider --key <key> # provider API key
25
+ * bizar setup-provider --provider <name> # built-in providerId (default: litellm)
26
+ * bizar setup-provider --discover # auto-detect a local gateway and configure
27
+ * bizar setup-provider --remove <name> # remove a provider
28
+ *
29
+ * The default gateway is the local 9Router at http://localhost:20128/v1.
30
+ * The model catalog is fetched live from `${gateway}/v1/models`.
31
+ */
32
+ import chalk from 'chalk';
33
+ import { readFileSync, writeFileSync, existsSync, renameSync, copyFileSync, mkdirSync, readdirSync, statSync } from 'node:fs';
34
+ import { join, dirname } from 'node:path';
35
+ import { homedir } from 'node:os';
36
+
37
+ function clineDir() {
38
+ if (process.env.CLINE_DIR && process.env.CLINE_DIR.trim()) {
39
+ return process.env.CLINE_DIR.trim();
40
+ }
41
+ if (process.platform === 'win32') {
42
+ return join(process.env.APPDATA || homedir(), 'cline');
43
+ }
44
+ return join(homedir(), '.cline');
45
+ }
46
+
47
+ function dataDir() {
48
+ if (process.env.CLINE_DATA_DIR && process.env.CLINE_DATA_DIR.trim()) {
49
+ return process.env.CLINE_DATA_DIR.trim();
50
+ }
51
+ return join(clineDir(), 'data');
52
+ }
53
+
54
+ /** Path to the Cline settings file the CLI + kanban actually read. */
55
+ function settingsPath() {
56
+ if (process.env.CLINE_PROVIDER_SETTINGS_PATH && process.env.CLINE_PROVIDER_SETTINGS_PATH.trim()) {
57
+ return process.env.CLINE_PROVIDER_SETTINGS_PATH.trim();
58
+ }
59
+ return join(dataDir(), 'settings', 'providers.json');
60
+ }
61
+
62
+ const DEFAULT_GATEWAY = 'http://localhost:20128/v1';
63
+ // v6.2.3 — `litellm` is the right built-in providerId for a custom
64
+ // OpenAI-compatible endpoint. It's in Cline's catalog (so kanban
65
+ // mode recognizes it) and accepts a custom baseUrl.
66
+ const DEFAULT_PROVIDER = 'litellm';
67
+
68
+ // v6.2.3 — Built-in Cline providerIds that support `family: "openai-compatible"`
69
+ // with a custom baseUrl. These are the ones the user can pick for
70
+ // "I have my own OpenAI-compatible endpoint". `9router`, `openai-compatible`
71
+ // and other fake IDs are NOT in this list — they fail in kanban mode.
72
+ export const OPENAI_COMPATIBLE_PROVIDERS = [
73
+ 'litellm', // best fit: defaults to localhost:4000, openai-responses
74
+ 'cline', // Cline's own API (can be overridden)
75
+ 'ollama', // localhost:11434
76
+ 'lmstudio', // localhost:1234
77
+ 'huggingface', // api-inference.huggingface.co
78
+ ];
79
+
80
+ function readJsonSafe(file) {
81
+ try {
82
+ if (!existsSync(file)) return null;
83
+ return JSON.parse(readFileSync(file, 'utf8'));
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ function writeJsonAtomic(file, data) {
90
+ mkdirSync(dirname(file), { recursive: true });
91
+ const tmp = `${file}.tmp`;
92
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
93
+ renameSync(tmp, file);
94
+ }
95
+
96
+ /** Pretty-print the live model catalog from `${gateway}/v1/models`. */
97
+ export async function listGatewayModels(gateway) {
98
+ const url = `${gateway.replace(/\/$/, '')}/models`;
99
+ const ac = new AbortController();
100
+ const timer = setTimeout(() => ac.abort(), 10000);
101
+ try {
102
+ const res = await fetch(url, { signal: ac.signal });
103
+ if (!res.ok) throw new Error(`GET ${url} returned HTTP ${res.status}`);
104
+ const body = await res.json();
105
+ const data = Array.isArray(body?.data) ? body.data : [];
106
+ return data.map((m) => ({
107
+ id: m.id,
108
+ ownedBy: m.owned_by || '(unknown)',
109
+ }));
110
+ } finally {
111
+ clearTimeout(timer);
112
+ }
113
+ }
114
+
115
+ /** Heuristic: an all-caps-with-underscores string is an env var name. */
116
+ function looksLikeEnvVar(s) {
117
+ return /^[A-Z][A-Z0-9_]{1,}$/.test(s);
118
+ }
119
+
120
+ /**
121
+ * v6.2.3 — Build the Cline settings.json entry for one provider.
122
+ *
123
+ * The shape matches `~/.cline/data/settings/providers.json`:
124
+ * { "version": 1, "lastUsedProvider": "litellm", "providers": { "litellm": { ... } } }
125
+ */
126
+ export function buildProviderBlock({ name, gateway, apiKey, model, reason = false }) {
127
+ return {
128
+ settings: {
129
+ provider: name,
130
+ model,
131
+ apiKey: looksLikeEnvVar(apiKey) ? `\${env:${apiKey}}` : apiKey,
132
+ baseUrl: gateway,
133
+ ...(reason ? { reasoning: { enabled: true, effort: 'medium' } } : { reasoning: { enabled: false, effort: 'medium' } }),
134
+ },
135
+ updatedAt: new Date().toISOString(),
136
+ tokenSource: 'manual',
137
+ };
138
+ }
139
+
140
+ /** Apply (create or update) one provider in the settings file. */
141
+ export function applyProviderBlock(name, body) {
142
+ const file = settingsPath();
143
+ if (!existsSync(file)) {
144
+ // Bootstrap with a minimal but valid providers.json
145
+ const bootstrap = { version: 1, providers: {}, lastUsedProvider: name };
146
+ writeJsonAtomic(file, bootstrap);
147
+ }
148
+ const backup = `${file}.bak`;
149
+ try { copyFileSync(file, backup); } catch { /* best-effort */ }
150
+
151
+ const cfg = readJsonSafe(file, { version: 1, providers: {} });
152
+ if (!cfg.providers || typeof cfg.providers !== 'object') cfg.providers = {};
153
+ cfg.providers[name] = body;
154
+ // v6.2.3 — Always set lastUsedProvider to the most recently applied
155
+ // provider. The user is actively configuring this one; they want
156
+ // it to be the active one. (Previous v6.2.2 only set it when missing,
157
+ // which left stale `lastUsedProvider: "openai-compatible"` entries
158
+ // in the file when the user upgraded.)
159
+ cfg.lastUsedProvider = name;
160
+ cfg.version = cfg.version || 1;
161
+ writeJsonAtomic(file, cfg);
162
+ return { name, path: file, backup };
163
+ }
164
+
165
+ /** Remove a provider by name from the settings file. */
166
+ export function removeProvider(name) {
167
+ const file = settingsPath();
168
+ if (!existsSync(file)) return { removed: false, name, reason: 'no settings file' };
169
+ const cfg = readJsonSafe(file, { version: 1, providers: {} });
170
+ if (!cfg.providers || !cfg.providers[name]) {
171
+ return { removed: false, name, reason: 'not present' };
172
+ }
173
+ delete cfg.providers[name];
174
+ if (cfg.lastUsedProvider === name) {
175
+ cfg.lastUsedProvider = Object.keys(cfg.providers)[0] || undefined;
176
+ }
177
+ writeJsonAtomic(file, cfg);
178
+ return { removed: true, name, path: file };
179
+ }
180
+
181
+ /**
182
+ * v6.2.3 — Detect the broken `openai-compatible` providerId pattern
183
+ * from earlier installs and the Cline v6.0.1 migration shim.
184
+ *
185
+ * Background: v6.0.1's Cline auto-migration created synthetic providers
186
+ * with `provider: "openai-compatible"` (the `family`, not a real
187
+ * `id`). Cline CLI accepts this, but Cline kanban mode looks up the
188
+ * provider in its built-in catalog and fails with:
189
+ * "Unknown or disabled provider \"openai-compatible\"."
190
+ *
191
+ * The fix: if the settings file has a `provider: "openai-compatible"`
192
+ * entry, rename it to a real catalog ID (default `litellm`) so
193
+ * kanban mode recognizes it. We do this IN-PLACE without losing the
194
+ * user's baseUrl, apiKey, or model.
195
+ */
196
+ export function migrateLegacyOpenaiCompatible(targetName = 'litellm') {
197
+ const file = settingsPath();
198
+ if (!existsSync(file)) return { migrated: false, reason: 'no settings file' };
199
+ const cfg = readJsonSafe(file, { version: 1, providers: {} });
200
+ const broken = cfg.providers && cfg.providers['openai-compatible'];
201
+ if (!broken) return { migrated: false, reason: 'no legacy entry' };
202
+
203
+ // Move the entry to a real providerId.
204
+ const newBody = { ...broken };
205
+ // Strip the `provider: "openai-compatible"` field (replaced by the
206
+ // real providerId at the top level) and keep the rest (baseUrl,
207
+ // apiKey, model, reasoning, etc.).
208
+ if (newBody.settings) {
209
+ newBody.settings.provider = targetName;
210
+ }
211
+ cfg.providers[targetName] = newBody;
212
+ delete cfg.providers['openai-compatible'];
213
+ // If the broken entry was the active one, switch the new one in.
214
+ if (cfg.lastUsedProvider === 'openai-compatible' || !cfg.lastUsedProvider) {
215
+ cfg.lastUsedProvider = targetName;
216
+ }
217
+ writeJsonAtomic(file, cfg);
218
+ return { migrated: true, from: 'openai-compatible', to: targetName, path: file };
219
+ }
220
+
221
+ /**
222
+ * v6.2.3 — `bizar setup-provider --discover`.
223
+ *
224
+ * Probe a list of well-known local gateway URLs and use the first one
225
+ * that responds. Then write the provider block with the discovered URL.
226
+ */
227
+ export async function discoverLocalGateway() {
228
+ const candidates = [
229
+ 'http://localhost:20128/v1', // 9Router (Bizar default)
230
+ 'http://127.0.0.1:20128/v1',
231
+ 'http://localhost:4000/v1', // LiteLLM
232
+ 'http://localhost:11434/v1', // Ollama
233
+ 'http://localhost:1234/v1', // LM Studio
234
+ ];
235
+ for (const url of candidates) {
236
+ try {
237
+ const models = await listGatewayModels(url);
238
+ if (models.length > 0) {
239
+ return { url, modelCount: models.length, models };
240
+ }
241
+ } catch {
242
+ // Try the next one
243
+ }
244
+ }
245
+ return null;
246
+ }
247
+
248
+ export function showSetupProviderHelp() {
249
+ console.log(`
250
+ bizar setup-provider — Configure a provider in Cline's settings
251
+
252
+ Usage:
253
+ bizar setup-provider Interactive setup (default: litellm + 9Router gateway)
254
+ bizar setup-provider --list Print the live model catalog
255
+ bizar setup-provider --gateway <url> Override the gateway URL (default: http://localhost:20128/v1)
256
+ bizar setup-provider --key <key> Provider API key
257
+ - All-caps-with-underscores → \${env:KEY}
258
+ - Otherwise → literal value
259
+ bizar setup-provider --provider <name> Built-in providerId (default: litellm)
260
+ See OPENAI_COMPATIBLE_PROVIDERS for the supported list
261
+ bizar setup-provider --model <id> Model ID (default: first model from \${gateway}/v1/models)
262
+ bizar setup-provider --discover Auto-detect a local gateway and configure
263
+ bizar setup-provider --remove <name> Remove a provider by name
264
+ bizar setup-provider --help Show this help
265
+
266
+ Description:
267
+ Since v6.2.2 the Bizar installer no longer touches provider config.
268
+ Use this command to add or update a provider in
269
+ \`~/.cline/data/settings/providers.json\` (the file the Cline CLI
270
+ and kanban mode both read).
271
+
272
+ v6.2.3 changed the default providerId from \`9router\` to \`litellm\`.
273
+ \`litellm\` is a real entry in Cline's built-in catalog (so it works
274
+ in kanban mode), \`family: "openai-compatible"\`, and accepts a
275
+ custom baseUrl — perfect for the local 9Router gateway.
276
+
277
+ The previous v6.2.2 default \`9router\` and any hand-written
278
+ providerId like \`openai-compatible\` are NOT in Cline's catalog,
279
+ so they fail in kanban mode with:
280
+ "Unknown or disabled provider \\"openai-compatible\\"."
281
+
282
+ Examples:
283
+ bizar setup-provider
284
+ bizar setup-provider --list
285
+ bizar setup-provider --discover
286
+ bizar setup-provider --provider ollama --gateway http://localhost:11434/v1
287
+
288
+ Related:
289
+ bizar connect Interactive TUI for provider setup
290
+ bizar install Installs the Bizar scaffolding (NOT provider config)
291
+ bizar validate Confirms provider config is wired correctly
292
+ `);
293
+ }
294
+
295
+ export function parseFlags(args) {
296
+ const flags = {};
297
+ for (let i = 0; i < args.length; i++) {
298
+ const a = args[i];
299
+ if (a === '--list') flags.list = true;
300
+ else if (a === '--discover') flags.discover = true;
301
+ else if (a === '--gateway' && i + 1 < args.length) { flags.gateway = args[++i]; }
302
+ else if (a.startsWith('--gateway=')) flags.gateway = a.slice('--gateway='.length);
303
+ else if (a === '--key' && i + 1 < args.length) { flags.key = args[++i]; }
304
+ else if (a.startsWith('--key=')) flags.key = a.slice('--key='.length);
305
+ else if (a === '--provider' && i + 1 < args.length) { flags.provider = args[++i]; }
306
+ else if (a.startsWith('--provider=')) flags.provider = a.slice('--provider='.length);
307
+ else if (a === '--model' && i + 1 < args.length) { flags.model = args[++i]; }
308
+ else if (a.startsWith('--model=')) flags.model = a.slice('--model='.length);
309
+ else if (a === '--remove' && i + 1 < args.length) { flags.remove = args[++i]; }
310
+ else if (a.startsWith('--remove=')) flags.remove = a.slice('--remove='.length);
311
+ }
312
+ return flags;
313
+ }
314
+
315
+ export async function runSetupProvider(args = []) {
316
+ if (args.includes('--help') || args.includes('-h')) {
317
+ showSetupProviderHelp();
318
+ return;
319
+ }
320
+ const flags = parseFlags(args);
321
+
322
+ // v6.2.3 — Auto-migrate the legacy `openai-compatible` providerId
323
+ // shim. This runs on every invocation (except --help/--list/--remove)
324
+ // because it's idempotent and fixes a real bug. Users who hit
325
+ // "Unknown or disabled provider openai-compatible" in kanban mode
326
+ // get healed automatically the next time they run setup-provider.
327
+ if (!flags.list && !flags.remove) {
328
+ const mig = migrateLegacyOpenaiCompatible(DEFAULT_PROVIDER);
329
+ if (mig.migrated) {
330
+ console.log(chalk.yellow(`\n ⚠ Migrated legacy '${mig.from}' → '${mig.to}'`));
331
+ console.log(chalk.yellow(' (kanban mode requires a built-in providerId; litellm works for any OpenAI-compatible endpoint.)'));
332
+ }
333
+ }
334
+
335
+ // --list — print the catalog and exit
336
+ if (flags.list) {
337
+ const gateway = flags.gateway || DEFAULT_GATEWAY;
338
+ console.log(chalk.cyan(`\n Model catalog from ${gateway}/models:\n`));
339
+ try {
340
+ const models = await listGatewayModels(gateway);
341
+ if (models.length === 0) {
342
+ console.log(chalk.yellow(' (catalog is empty)'));
343
+ return;
344
+ }
345
+ // Group by owner.
346
+ const byOwner = new Map();
347
+ for (const m of models) {
348
+ if (!byOwner.has(m.ownedBy)) byOwner.set(m.ownedBy, []);
349
+ byOwner.get(m.ownedBy).push(m.id);
350
+ }
351
+ for (const [owner, ids] of byOwner) {
352
+ console.log(chalk.bold(` ${owner}:`));
353
+ for (const id of ids) {
354
+ console.log(` ${id}`);
355
+ }
356
+ }
357
+ } catch (err) {
358
+ console.log(chalk.red(` ✗ Failed to fetch catalog: ${err.message}`));
359
+ process.exit(1);
360
+ }
361
+ return;
362
+ }
363
+
364
+ // --remove <name>
365
+ if (flags.remove) {
366
+ try {
367
+ const r = removeProvider(flags.remove);
368
+ if (r.removed) {
369
+ console.log(chalk.green(` ✓ Removed provider '${r.name}' from ${r.path}`));
370
+ } else {
371
+ console.log(chalk.yellow(` ! Provider '${r.name}' was not present`));
372
+ }
373
+ } catch (err) {
374
+ console.log(chalk.red(` ✗ ${err.message}`));
375
+ process.exit(1);
376
+ }
377
+ return;
378
+ }
379
+
380
+ // --discover — auto-detect a local gateway
381
+ let gateway = flags.gateway;
382
+ if (flags.discover || (!gateway && !flags.key)) {
383
+ console.log(chalk.cyan('\n Probing for local gateways...'));
384
+ const found = await discoverLocalGateway();
385
+ if (found) {
386
+ gateway = found.url;
387
+ console.log(chalk.green(` ✓ Found ${found.modelCount} model(s) at ${found.url}`));
388
+ } else if (flags.discover) {
389
+ console.log(chalk.red(' ✗ No local gateway found. Tried:'));
390
+ for (const c of ['20128 (9Router)', '4000 (LiteLLM)', '11434 (Ollama)', '1234 (LM Studio)']) {
391
+ console.log(chalk.red(` - localhost:${c.split(' ')[0]}`));
392
+ }
393
+ process.exit(1);
394
+ } else {
395
+ gateway = DEFAULT_GATEWAY;
396
+ console.log(chalk.dim(` ! No local gateway detected, using default ${gateway}`));
397
+ }
398
+ }
399
+ gateway = gateway || DEFAULT_GATEWAY;
400
+
401
+ const name = flags.provider || DEFAULT_PROVIDER;
402
+ const apiKey = flags.key || process.env.NINEROUTER_KEY || process.env.OPENAI_API_KEY || 'sk-replace-me';
403
+
404
+ // Interactive prompt for the key if missing and we're on a TTY.
405
+ if (!flags.key && !process.env.NINEROUTER_KEY && !process.env.OPENAI_API_KEY &&
406
+ process.stdin.isTTY && process.stdout.isTTY) {
407
+ const { createInterface } = await import('node:readline/promises');
408
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
409
+ try {
410
+ const answer = await rl.question(` Enter API key for ${name} (or env var name like NINEROUTER_KEY): `);
411
+ if (answer.trim()) {
412
+ Object.assign(flags, parseFlags(['--key', answer.trim()]));
413
+ }
414
+ } finally {
415
+ rl.close();
416
+ }
417
+ }
418
+
419
+ const finalKey = flags.key || apiKey;
420
+
421
+ // Pick the model: explicit > first from catalog > sensible default.
422
+ let model = flags.model;
423
+ if (!model) {
424
+ console.log(chalk.cyan(`\n Fetching model catalog from ${gateway}...`));
425
+ try {
426
+ const catalog = await listGatewayModels(gateway);
427
+ if (catalog.length > 0) {
428
+ // Prefer minimaxcustom/MiniMax-M3 if available (the Bizar default model)
429
+ const preferred = catalog.find((m) => m.id === 'minimaxcustom/MiniMax-M3');
430
+ model = preferred ? preferred.id : catalog[0].id;
431
+ console.log(chalk.green(` ✓ Found ${catalog.length} model(s); using '${model}'`));
432
+ } else {
433
+ model = 'minimaxcustom/MiniMax-M3';
434
+ console.log(chalk.yellow(` ! Empty catalog; defaulting to ${model}`));
435
+ }
436
+ } catch (err) {
437
+ model = 'minimaxcustom/MiniMax-M3';
438
+ console.log(chalk.yellow(` ! Could not fetch catalog (${err.message}); defaulting to ${model}`));
439
+ }
440
+ }
441
+
442
+ const body = buildProviderBlock({ name, gateway, apiKey: finalKey, model });
443
+ try {
444
+ const result = applyProviderBlock(name, body);
445
+ console.log(chalk.green(`\n ✓ Provider '${result.name}' written to ${result.path}`));
446
+ console.log(chalk.dim(` backup: ${result.backup}`));
447
+ console.log(chalk.dim(` baseUrl: ${gateway}`));
448
+ console.log(chalk.dim(` model: ${model}`));
449
+ console.log(chalk.dim(` apiKey: ${looksLikeEnvVar(finalKey) ? `\${env:${finalKey}}` : (finalKey.length > 12 ? finalKey.slice(0, 8) + '...' : finalKey)}`));
450
+ console.log('');
451
+ console.log(chalk.cyan(' Next:'));
452
+ console.log(chalk.cyan(' • Run `bizar validate` to confirm everything is wired up.'));
453
+ console.log(chalk.cyan(' • The same provider now works in both `cline` CLI and `cline --kanban` mode.'));
454
+ } catch (err) {
455
+ console.log(chalk.red(` ✗ ${err.message}`));
456
+ process.exit(1);
457
+ }
458
+ }
459
+
460
+ // ── run() entry point (used by bin.mjs dispatcher) ─────────────────────────
461
+
462
+ export async function run(name, args, isHelpRequest) {
463
+ if (name !== 'setup-provider') return false;
464
+ await runSetupProvider(args);
465
+ return true;
466
+ }