@polderlabs/bizar 6.2.2 → 6.2.4

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.
@@ -1,25 +1,36 @@
1
1
  /**
2
2
  * cli/commands/setup-provider.mjs
3
3
  *
4
- * v6.2.2 — `bizar setup-provider` subcommand.
4
+ * v6.2.3 — `bizar setup-provider` subcommand.
5
5
  *
6
- * The Bizar installer no longer touches provider config. This command
7
- * is the supported way to add or update a provider in `~/.cline/cline.json`.
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".
8
19
  *
9
20
  * Usage:
10
- * bizar setup-provider # interactive; default 9Router
11
- * bizar setup-provider --list # print the model catalog
21
+ * bizar setup-provider # default 9Router gateway, litellm provider
22
+ * bizar setup-provider --list # print the live model catalog
12
23
  * bizar setup-provider --gateway <url> # override gateway URL
13
24
  * bizar setup-provider --key <key> # provider API key
14
- * bizar setup-provider --provider <name> # provider name in cline.json
25
+ * bizar setup-provider --provider <name> # built-in providerId (default: litellm)
26
+ * bizar setup-provider --discover # auto-detect a local gateway and configure
15
27
  * bizar setup-provider --remove <name> # remove a provider
16
28
  *
17
29
  * The default gateway is the local 9Router at http://localhost:20128/v1.
18
- * The catalog is fetched live from `${gateway}/v1/models` so the user
19
- * always gets the up-to-date list.
30
+ * The model catalog is fetched live from `${gateway}/v1/models`.
20
31
  */
21
32
  import chalk from 'chalk';
22
- import { readFileSync, writeFileSync, existsSync, renameSync, copyFileSync } from 'node:fs';
33
+ import { readFileSync, writeFileSync, existsSync, renameSync, copyFileSync, mkdirSync, readdirSync, statSync } from 'node:fs';
23
34
  import { join, dirname } from 'node:path';
24
35
  import { homedir } from 'node:os';
25
36
 
@@ -33,10 +44,38 @@ function clineDir() {
33
44
  return join(homedir(), '.cline');
34
45
  }
35
46
 
36
- const CLINE_DIR = clineDir();
37
- function clineJsonPath() { return join(clineDir(), 'cline.json'); }
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
+
38
62
  const DEFAULT_GATEWAY = 'http://localhost:20128/v1';
39
- const DEFAULT_PROVIDER = '9router';
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
+ ];
40
79
 
41
80
  function readJsonSafe(file) {
42
81
  try {
@@ -48,6 +87,7 @@ function readJsonSafe(file) {
48
87
  }
49
88
 
50
89
  function writeJsonAtomic(file, data) {
90
+ mkdirSync(dirname(file), { recursive: true });
51
91
  const tmp = `${file}.tmp`;
52
92
  writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
53
93
  renameSync(tmp, file);
@@ -77,86 +117,173 @@ function looksLikeEnvVar(s) {
77
117
  return /^[A-Z][A-Z0-9_]{1,}$/.test(s);
78
118
  }
79
119
 
80
- /** Build the cline.json provider block. */
81
- export function buildProviderBlock({ name, gateway, apiKey, models }) {
82
- // Cline supports two ways to express a model:
83
- // - just a string modelId under `models`
84
- // - an object with metadata (interleaved, reasoning, etc.)
85
- // We emit the simplest possible form: the bare modelId. The user can
86
- // add metadata later by editing the file.
87
- const modelsObj = {};
88
- for (const m of models) {
89
- modelsObj[m] = {};
90
- }
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 }) {
91
127
  return {
92
- [name]: {
93
- baseUrl: gateway,
128
+ settings: {
129
+ provider: name,
130
+ model,
94
131
  apiKey: looksLikeEnvVar(apiKey) ? `\${env:${apiKey}}` : apiKey,
95
- models: modelsObj,
132
+ baseUrl: gateway,
133
+ ...(reason ? { reasoning: { enabled: true, effort: 'medium' } } : { reasoning: { enabled: false, effort: 'medium' } }),
96
134
  },
135
+ updatedAt: new Date().toISOString(),
136
+ tokenSource: 'manual',
97
137
  };
98
138
  }
99
139
 
100
- /** Apply a provider block atomically to ~/.cline/cline.json. */
101
- export function applyProviderBlock(block) {
102
- if (!existsSync(clineJsonPath())) {
103
- throw new Error(
104
- `${clineJsonPath()} not found. Run \`bizar install\` first to set up the Bizar scaffolding.`,
105
- );
106
- }
107
- // Back up the original before mutating.
108
- const backup = `${clineJsonPath()}.bak`;
109
- try { copyFileSync(clineJsonPath(), backup); } catch { /* best-effort */ }
110
-
111
- const cfg = readJsonSafe(clineJsonPath(), {});
112
- if (!cfg.provider || typeof cfg.provider !== 'object') cfg.provider = {};
113
- for (const [name, body] of Object.entries(block)) {
114
- cfg.provider[name] = body;
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);
115
147
  }
116
- writeJsonAtomic(clineJsonPath(), cfg);
117
- return { name: Object.keys(block)[0], path: clineJsonPath(), backup };
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 };
118
163
  }
119
164
 
120
- /** Remove a provider by name. */
165
+ /** Remove a provider by name from the settings file. */
121
166
  export function removeProvider(name) {
122
- if (!existsSync(clineJsonPath())) {
123
- throw new Error(`${clineJsonPath()} not found.`);
124
- }
125
- const cfg = readJsonSafe(clineJsonPath(), {});
126
- if (!cfg.provider || !cfg.provider[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]) {
127
171
  return { removed: false, name, reason: 'not present' };
128
172
  }
129
- delete cfg.provider[name];
130
- writeJsonAtomic(clineJsonPath(), cfg);
131
- return { removed: true, name, path: clineJsonPath() };
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;
132
246
  }
133
247
 
134
248
  export function showSetupProviderHelp() {
135
249
  console.log(`
136
- bizar setup-provider — Configure a provider in cline.json
250
+ bizar setup-provider — Configure a provider in Cline's settings
137
251
 
138
252
  Usage:
139
- bizar setup-provider Interactive setup (default: 9Router gateway)
253
+ bizar setup-provider Interactive setup (default: litellm + 9Router gateway)
140
254
  bizar setup-provider --list Print the live model catalog
141
255
  bizar setup-provider --gateway <url> Override the gateway URL (default: http://localhost:20128/v1)
142
256
  bizar setup-provider --key <key> Provider API key
143
257
  - All-caps-with-underscores → \${env:KEY}
144
258
  - Otherwise → literal value
145
- bizar setup-provider --provider <name> Provider name in cline.json (default: 9router)
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
146
263
  bizar setup-provider --remove <name> Remove a provider by name
147
264
  bizar setup-provider --help Show this help
148
265
 
149
266
  Description:
150
267
  Since v6.2.2 the Bizar installer no longer touches provider config.
151
- Use this command to add or update a provider in ~/.cline/cline.json.
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).
152
271
 
153
- The default gateway is the local 9Router at http://localhost:20128/v1.
154
- The model catalog is fetched live from \${gateway}/v1/models.
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\\"."
155
281
 
156
282
  Examples:
157
283
  bizar setup-provider
158
284
  bizar setup-provider --list
159
- bizar setup-provider --gateway https://api.anthropic.com/v1 --key sk-ant-...
285
+ bizar setup-provider --discover
286
+ bizar setup-provider --provider ollama --gateway http://localhost:11434/v1
160
287
 
161
288
  Related:
162
289
  bizar connect Interactive TUI for provider setup
@@ -170,12 +297,15 @@ export function parseFlags(args) {
170
297
  for (let i = 0; i < args.length; i++) {
171
298
  const a = args[i];
172
299
  if (a === '--list') flags.list = true;
300
+ else if (a === '--discover') flags.discover = true;
173
301
  else if (a === '--gateway' && i + 1 < args.length) { flags.gateway = args[++i]; }
174
302
  else if (a.startsWith('--gateway=')) flags.gateway = a.slice('--gateway='.length);
175
303
  else if (a === '--key' && i + 1 < args.length) { flags.key = args[++i]; }
176
304
  else if (a.startsWith('--key=')) flags.key = a.slice('--key='.length);
177
305
  else if (a === '--provider' && i + 1 < args.length) { flags.provider = args[++i]; }
178
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);
179
309
  else if (a === '--remove' && i + 1 < args.length) { flags.remove = args[++i]; }
180
310
  else if (a.startsWith('--remove=')) flags.remove = a.slice('--remove='.length);
181
311
  }
@@ -189,6 +319,19 @@ export async function runSetupProvider(args = []) {
189
319
  }
190
320
  const flags = parseFlags(args);
191
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
+
192
335
  // --list — print the catalog and exit
193
336
  if (flags.list) {
194
337
  const gateway = flags.gateway || DEFAULT_GATEWAY;
@@ -234,8 +377,27 @@ export async function runSetupProvider(args = []) {
234
377
  return;
235
378
  }
236
379
 
237
- // Default flow set up a provider
238
- const gateway = flags.gateway || DEFAULT_GATEWAY;
380
+ // --discoverauto-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
+
239
401
  const name = flags.provider || DEFAULT_PROVIDER;
240
402
  const apiKey = flags.key || process.env.NINEROUTER_KEY || process.env.OPENAI_API_KEY || 'sk-replace-me';
241
403
 
@@ -256,27 +418,39 @@ export async function runSetupProvider(args = []) {
256
418
 
257
419
  const finalKey = flags.key || apiKey;
258
420
 
259
- console.log(chalk.cyan(`\n Fetching model catalog from ${gateway}...`));
260
- let models = [];
261
- try {
262
- const catalog = await listGatewayModels(gateway);
263
- models = catalog.map((m) => m.id);
264
- console.log(chalk.green(` ✓ Found ${models.length} model(s)`));
265
- } catch (err) {
266
- console.log(chalk.yellow(` ! Could not fetch catalog (${err.message})`));
267
- console.log(chalk.yellow(` Continuing with a minimal catalog.`));
268
- models = ['minimax/MiniMax-M3', 'minimax/MiniMax-M2.7'];
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
+ }
269
440
  }
270
441
 
271
- const block = buildProviderBlock({ name, gateway, apiKey: finalKey, models });
442
+ const body = buildProviderBlock({ name, gateway, apiKey: finalKey, model });
272
443
  try {
273
- const result = applyProviderBlock(block);
444
+ const result = applyProviderBlock(name, body);
274
445
  console.log(chalk.green(`\n ✓ Provider '${result.name}' written to ${result.path}`));
275
446
  console.log(chalk.dim(` backup: ${result.backup}`));
276
447
  console.log(chalk.dim(` baseUrl: ${gateway}`));
277
- console.log(chalk.dim(` models: ${models.length}`));
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)}`));
278
450
  console.log('');
279
- console.log(chalk.cyan(' Next: run `bizar validate` to confirm everything is wired up.'));
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.'));
280
454
  } catch (err) {
281
455
  console.log(chalk.red(` ✗ ${err.message}`));
282
456
  process.exit(1);