@polderlabs/bizar 6.2.1 → 6.2.2

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/cli/bin.mjs CHANGED
@@ -129,6 +129,7 @@ function showHelp() {
129
129
  eval Evaluate AI agent outputs against golden fixtures
130
130
  plan [v6.0.0+] Reserved for future plan management
131
131
  validate Validate the Bizar install (21 checks)
132
+ setup-provider Configure a provider in cline.json (since v6.2.2 installer doesn't touch providers)
132
133
 
133
134
  Examples:
134
135
  bizar install
@@ -205,6 +206,7 @@ async function main() {
205
206
  'audit', 'init', 'export', 'test-gate', 'dev-link', 'dev-unlink',
206
207
  'doctor', 'repair', 'heads-up', 'bg', 'digest', 'backup', 'restore',
207
208
  'agent-browser', 'update', 'providers', 'plan', 'validate',
209
+ 'setup-provider',
208
210
  ]);
209
211
  const UTIL_ALIASES = new Set(['dashboard', 'agent-browser-up']);
210
212
  let mod;
@@ -515,6 +517,23 @@ async function main() {
515
517
  break;
516
518
  }
517
519
 
520
+ case 'setup-provider': {
521
+ const mod = await importCommand('setup-provider');
522
+ if (!mod) {
523
+ console.error(chalk.red(` ✗ Could not load setup-provider command module`));
524
+ process.exit(EXIT_ERROR);
525
+ return;
526
+ }
527
+ dbg('loaded command module:', 'setup-provider');
528
+ const found = await mod.run(cmd, cmdArgs, isHelpRequest);
529
+ if (!found) {
530
+ console.error(chalk.red(` ✗ Unknown command: ${cmd}`));
531
+ showHelp();
532
+ process.exit(EXIT_ERROR);
533
+ }
534
+ break;
535
+ }
536
+
518
537
  default: {
519
538
  console.error(chalk.red(` ✗ Unknown command: ${cmd}`));
520
539
  showHelp();
@@ -0,0 +1,292 @@
1
+ /**
2
+ * cli/commands/setup-provider.mjs
3
+ *
4
+ * v6.2.2 — `bizar setup-provider` subcommand.
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`.
8
+ *
9
+ * Usage:
10
+ * bizar setup-provider # interactive; default 9Router
11
+ * bizar setup-provider --list # print the model catalog
12
+ * bizar setup-provider --gateway <url> # override gateway URL
13
+ * bizar setup-provider --key <key> # provider API key
14
+ * bizar setup-provider --provider <name> # provider name in cline.json
15
+ * bizar setup-provider --remove <name> # remove a provider
16
+ *
17
+ * 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.
20
+ */
21
+ import chalk from 'chalk';
22
+ import { readFileSync, writeFileSync, existsSync, renameSync, copyFileSync } from 'node:fs';
23
+ import { join, dirname } from 'node:path';
24
+ import { homedir } from 'node:os';
25
+
26
+ function clineDir() {
27
+ if (process.env.CLINE_DIR && process.env.CLINE_DIR.trim()) {
28
+ return process.env.CLINE_DIR.trim();
29
+ }
30
+ if (process.platform === 'win32') {
31
+ return join(process.env.APPDATA || homedir(), 'cline');
32
+ }
33
+ return join(homedir(), '.cline');
34
+ }
35
+
36
+ const CLINE_DIR = clineDir();
37
+ function clineJsonPath() { return join(clineDir(), 'cline.json'); }
38
+ const DEFAULT_GATEWAY = 'http://localhost:20128/v1';
39
+ const DEFAULT_PROVIDER = '9router';
40
+
41
+ function readJsonSafe(file) {
42
+ try {
43
+ if (!existsSync(file)) return null;
44
+ return JSON.parse(readFileSync(file, 'utf8'));
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ function writeJsonAtomic(file, data) {
51
+ const tmp = `${file}.tmp`;
52
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
53
+ renameSync(tmp, file);
54
+ }
55
+
56
+ /** Pretty-print the live model catalog from `${gateway}/v1/models`. */
57
+ export async function listGatewayModels(gateway) {
58
+ const url = `${gateway.replace(/\/$/, '')}/models`;
59
+ const ac = new AbortController();
60
+ const timer = setTimeout(() => ac.abort(), 10000);
61
+ try {
62
+ const res = await fetch(url, { signal: ac.signal });
63
+ if (!res.ok) throw new Error(`GET ${url} returned HTTP ${res.status}`);
64
+ const body = await res.json();
65
+ const data = Array.isArray(body?.data) ? body.data : [];
66
+ return data.map((m) => ({
67
+ id: m.id,
68
+ ownedBy: m.owned_by || '(unknown)',
69
+ }));
70
+ } finally {
71
+ clearTimeout(timer);
72
+ }
73
+ }
74
+
75
+ /** Heuristic: an all-caps-with-underscores string is an env var name. */
76
+ function looksLikeEnvVar(s) {
77
+ return /^[A-Z][A-Z0-9_]{1,}$/.test(s);
78
+ }
79
+
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
+ }
91
+ return {
92
+ [name]: {
93
+ baseUrl: gateway,
94
+ apiKey: looksLikeEnvVar(apiKey) ? `\${env:${apiKey}}` : apiKey,
95
+ models: modelsObj,
96
+ },
97
+ };
98
+ }
99
+
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;
115
+ }
116
+ writeJsonAtomic(clineJsonPath(), cfg);
117
+ return { name: Object.keys(block)[0], path: clineJsonPath(), backup };
118
+ }
119
+
120
+ /** Remove a provider by name. */
121
+ 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]) {
127
+ return { removed: false, name, reason: 'not present' };
128
+ }
129
+ delete cfg.provider[name];
130
+ writeJsonAtomic(clineJsonPath(), cfg);
131
+ return { removed: true, name, path: clineJsonPath() };
132
+ }
133
+
134
+ export function showSetupProviderHelp() {
135
+ console.log(`
136
+ bizar setup-provider — Configure a provider in cline.json
137
+
138
+ Usage:
139
+ bizar setup-provider Interactive setup (default: 9Router gateway)
140
+ bizar setup-provider --list Print the live model catalog
141
+ bizar setup-provider --gateway <url> Override the gateway URL (default: http://localhost:20128/v1)
142
+ bizar setup-provider --key <key> Provider API key
143
+ - All-caps-with-underscores → \${env:KEY}
144
+ - Otherwise → literal value
145
+ bizar setup-provider --provider <name> Provider name in cline.json (default: 9router)
146
+ bizar setup-provider --remove <name> Remove a provider by name
147
+ bizar setup-provider --help Show this help
148
+
149
+ Description:
150
+ 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.
152
+
153
+ The default gateway is the local 9Router at http://localhost:20128/v1.
154
+ The model catalog is fetched live from \${gateway}/v1/models.
155
+
156
+ Examples:
157
+ bizar setup-provider
158
+ bizar setup-provider --list
159
+ bizar setup-provider --gateway https://api.anthropic.com/v1 --key sk-ant-...
160
+
161
+ Related:
162
+ bizar connect Interactive TUI for provider setup
163
+ bizar install Installs the Bizar scaffolding (NOT provider config)
164
+ bizar validate Confirms provider config is wired correctly
165
+ `);
166
+ }
167
+
168
+ export function parseFlags(args) {
169
+ const flags = {};
170
+ for (let i = 0; i < args.length; i++) {
171
+ const a = args[i];
172
+ if (a === '--list') flags.list = true;
173
+ else if (a === '--gateway' && i + 1 < args.length) { flags.gateway = args[++i]; }
174
+ else if (a.startsWith('--gateway=')) flags.gateway = a.slice('--gateway='.length);
175
+ else if (a === '--key' && i + 1 < args.length) { flags.key = args[++i]; }
176
+ else if (a.startsWith('--key=')) flags.key = a.slice('--key='.length);
177
+ else if (a === '--provider' && i + 1 < args.length) { flags.provider = args[++i]; }
178
+ else if (a.startsWith('--provider=')) flags.provider = a.slice('--provider='.length);
179
+ else if (a === '--remove' && i + 1 < args.length) { flags.remove = args[++i]; }
180
+ else if (a.startsWith('--remove=')) flags.remove = a.slice('--remove='.length);
181
+ }
182
+ return flags;
183
+ }
184
+
185
+ export async function runSetupProvider(args = []) {
186
+ if (args.includes('--help') || args.includes('-h')) {
187
+ showSetupProviderHelp();
188
+ return;
189
+ }
190
+ const flags = parseFlags(args);
191
+
192
+ // --list — print the catalog and exit
193
+ if (flags.list) {
194
+ const gateway = flags.gateway || DEFAULT_GATEWAY;
195
+ console.log(chalk.cyan(`\n Model catalog from ${gateway}/models:\n`));
196
+ try {
197
+ const models = await listGatewayModels(gateway);
198
+ if (models.length === 0) {
199
+ console.log(chalk.yellow(' (catalog is empty)'));
200
+ return;
201
+ }
202
+ // Group by owner.
203
+ const byOwner = new Map();
204
+ for (const m of models) {
205
+ if (!byOwner.has(m.ownedBy)) byOwner.set(m.ownedBy, []);
206
+ byOwner.get(m.ownedBy).push(m.id);
207
+ }
208
+ for (const [owner, ids] of byOwner) {
209
+ console.log(chalk.bold(` ${owner}:`));
210
+ for (const id of ids) {
211
+ console.log(` ${id}`);
212
+ }
213
+ }
214
+ } catch (err) {
215
+ console.log(chalk.red(` ✗ Failed to fetch catalog: ${err.message}`));
216
+ process.exit(1);
217
+ }
218
+ return;
219
+ }
220
+
221
+ // --remove <name>
222
+ if (flags.remove) {
223
+ try {
224
+ const r = removeProvider(flags.remove);
225
+ if (r.removed) {
226
+ console.log(chalk.green(` ✓ Removed provider '${r.name}' from ${r.path}`));
227
+ } else {
228
+ console.log(chalk.yellow(` ! Provider '${r.name}' was not present`));
229
+ }
230
+ } catch (err) {
231
+ console.log(chalk.red(` ✗ ${err.message}`));
232
+ process.exit(1);
233
+ }
234
+ return;
235
+ }
236
+
237
+ // Default flow — set up a provider
238
+ const gateway = flags.gateway || DEFAULT_GATEWAY;
239
+ const name = flags.provider || DEFAULT_PROVIDER;
240
+ const apiKey = flags.key || process.env.NINEROUTER_KEY || process.env.OPENAI_API_KEY || 'sk-replace-me';
241
+
242
+ // Interactive prompt for the key if missing and we're on a TTY.
243
+ if (!flags.key && !process.env.NINEROUTER_KEY && !process.env.OPENAI_API_KEY &&
244
+ process.stdin.isTTY && process.stdout.isTTY) {
245
+ const { createInterface } = await import('node:readline/promises');
246
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
247
+ try {
248
+ const answer = await rl.question(` Enter API key for ${name} (or env var name like NINEROUTER_KEY): `);
249
+ if (answer.trim()) {
250
+ Object.assign(flags, parseFlags(['--key', answer.trim()]));
251
+ }
252
+ } finally {
253
+ rl.close();
254
+ }
255
+ }
256
+
257
+ const finalKey = flags.key || apiKey;
258
+
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'];
269
+ }
270
+
271
+ const block = buildProviderBlock({ name, gateway, apiKey: finalKey, models });
272
+ try {
273
+ const result = applyProviderBlock(block);
274
+ console.log(chalk.green(`\n ✓ Provider '${result.name}' written to ${result.path}`));
275
+ console.log(chalk.dim(` backup: ${result.backup}`));
276
+ console.log(chalk.dim(` baseUrl: ${gateway}`));
277
+ console.log(chalk.dim(` models: ${models.length}`));
278
+ console.log('');
279
+ console.log(chalk.cyan(' Next: run `bizar validate` to confirm everything is wired up.'));
280
+ } catch (err) {
281
+ console.log(chalk.red(` ✗ ${err.message}`));
282
+ process.exit(1);
283
+ }
284
+ }
285
+
286
+ // ── run() entry point (used by bin.mjs dispatcher) ─────────────────────────
287
+
288
+ export async function run(name, args, isHelpRequest) {
289
+ if (name !== 'setup-provider') return false;
290
+ await runSetupProvider(args);
291
+ return true;
292
+ }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * cli/commands/setup-provider.test.mjs
3
+ *
4
+ * v6.2.2 — Unit tests for the setup-provider subcommand.
5
+ *
6
+ * Exercises the pure helpers (`buildProviderBlock`, `parseFlags`)
7
+ * and the filesystem mutations (`applyProviderBlock`, `removeProvider`)
8
+ * with a mocked CLINE_DIR.
9
+ */
10
+ import { test, describe, beforeEach, afterEach } from 'node:test';
11
+ import assert from 'node:assert/strict';
12
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { join } from 'node:path';
15
+
16
+ const {
17
+ buildProviderBlock,
18
+ applyProviderBlock,
19
+ removeProvider,
20
+ listGatewayModels,
21
+ parseFlags,
22
+ } = await import('./setup-provider.mjs');
23
+
24
+ const ORIG_CLINE_DIR = process.env.CLINE_DIR;
25
+ let workDir;
26
+
27
+ function freshWorkDir() {
28
+ workDir = mkdtempSync(join(tmpdir(), 'bizar-setup-provider-'));
29
+ process.env.CLINE_DIR = workDir;
30
+ return workDir;
31
+ }
32
+
33
+ function makeFakeClineJson(root, body = {}) {
34
+ const cfg = {
35
+ $schema: 'https://docs.cline.bot/config.json',
36
+ plugin: [],
37
+ default_agent: 'odin',
38
+ permission: 'allow',
39
+ snapshot: false,
40
+ ...body,
41
+ };
42
+ writeFileSync(join(root, 'cline.json'), JSON.stringify(cfg, null, 2));
43
+ }
44
+
45
+ beforeEach(() => {
46
+ workDir = freshWorkDir();
47
+ });
48
+
49
+ afterEach(() => {
50
+ if (workDir && existsSync(workDir)) rmSync(workDir, { recursive: true, force: true });
51
+ if (ORIG_CLINE_DIR === undefined) delete process.env.CLINE_DIR;
52
+ else process.env.CLINE_DIR = ORIG_CLINE_DIR;
53
+ });
54
+
55
+ describe('buildProviderBlock()', () => {
56
+ test('emits a baseUrl, apiKey, and bare modelIds', () => {
57
+ const block = buildProviderBlock({
58
+ name: '9router',
59
+ gateway: 'http://localhost:20128/v1',
60
+ apiKey: 'sk-test',
61
+ models: ['minimax/MiniMax-M3', 'minimax/MiniMax-M2.7'],
62
+ });
63
+ assert.ok(block['9router']);
64
+ assert.equal(block['9router'].baseUrl, 'http://localhost:20128/v1');
65
+ assert.equal(block['9router'].apiKey, 'sk-test');
66
+ assert.deepEqual(Object.keys(block['9router'].models), ['minimax/MiniMax-M3', 'minimax/MiniMax-M2.7']);
67
+ });
68
+
69
+ test('wraps env-var-style keys in ${env:...}', () => {
70
+ const block = buildProviderBlock({
71
+ name: '9router',
72
+ gateway: 'http://localhost:20128/v1',
73
+ apiKey: 'NINEROUTER_KEY',
74
+ models: ['minimax/MiniMax-M3'],
75
+ });
76
+ assert.equal(block['9router'].apiKey, '${env:NINEROUTER_KEY}');
77
+ });
78
+
79
+ test('keeps literal keys as-is when not env-var shaped', () => {
80
+ const block = buildProviderBlock({
81
+ name: 'custom',
82
+ gateway: 'https://x.example/v1',
83
+ apiKey: 'sk-abc-123',
84
+ models: ['m1'],
85
+ });
86
+ assert.equal(block['custom'].apiKey, 'sk-abc-123');
87
+ });
88
+ });
89
+
90
+ describe('applyProviderBlock()', () => {
91
+ test('writes a new provider to ~/.cline/cline.json', () => {
92
+ makeFakeClineJson(workDir);
93
+ const block = buildProviderBlock({
94
+ name: '9router',
95
+ gateway: 'http://localhost:20128/v1',
96
+ apiKey: 'sk-test',
97
+ models: ['minimax/MiniMax-M3'],
98
+ });
99
+ const r = applyProviderBlock(block);
100
+ assert.equal(r.name, '9router');
101
+ const cfg = JSON.parse(readFileSync(join(workDir, 'cline.json'), 'utf8'));
102
+ assert.ok(cfg.provider['9router']);
103
+ assert.equal(cfg.provider['9router'].baseUrl, 'http://localhost:20128/v1');
104
+ });
105
+
106
+ test('preserves other provider entries when adding a new one', () => {
107
+ makeFakeClineJson(workDir, {
108
+ provider: { existing: { baseUrl: 'https://a', apiKey: 'k', models: { m: {} } } },
109
+ });
110
+ const block = buildProviderBlock({
111
+ name: '9router',
112
+ gateway: 'http://localhost:20128/v1',
113
+ apiKey: 'sk-test',
114
+ models: ['minimax/MiniMax-M3'],
115
+ });
116
+ applyProviderBlock(block);
117
+ const cfg = JSON.parse(readFileSync(join(workDir, 'cline.json'), 'utf8'));
118
+ assert.ok(cfg.provider.existing, 'existing provider preserved');
119
+ assert.ok(cfg.provider['9router'], 'new provider added');
120
+ });
121
+
122
+ test('overwrites an existing provider with the same name', () => {
123
+ makeFakeClineJson(workDir, {
124
+ provider: { '9router': { baseUrl: 'https://old', apiKey: 'old', models: {} } },
125
+ });
126
+ const block = buildProviderBlock({
127
+ name: '9router',
128
+ gateway: 'http://localhost:20128/v1',
129
+ apiKey: 'new',
130
+ models: ['minimax/MiniMax-M3'],
131
+ });
132
+ applyProviderBlock(block);
133
+ const cfg = JSON.parse(readFileSync(join(workDir, 'cline.json'), 'utf8'));
134
+ assert.equal(cfg.provider['9router'].baseUrl, 'http://localhost:20128/v1');
135
+ assert.equal(cfg.provider['9router'].apiKey, 'new');
136
+ assert.ok(cfg.provider['9router'].models['minimax/MiniMax-M3']);
137
+ });
138
+
139
+ test('throws when cline.json is missing', () => {
140
+ // No makeFakeClineJson call
141
+ const block = buildProviderBlock({
142
+ name: '9router',
143
+ gateway: 'http://localhost:20128/v1',
144
+ apiKey: 'sk-test',
145
+ models: ['minimax/MiniMax-M3'],
146
+ });
147
+ assert.throws(() => applyProviderBlock(block), /not found/);
148
+ });
149
+
150
+ test('creates a backup file', () => {
151
+ makeFakeClineJson(workDir, {
152
+ plugin: [['./plugins/bizar', {}]],
153
+ });
154
+ const block = buildProviderBlock({
155
+ name: '9router',
156
+ gateway: 'http://localhost:20128/v1',
157
+ apiKey: 'sk-test',
158
+ models: ['minimax/MiniMax-M3'],
159
+ });
160
+ const r = applyProviderBlock(block);
161
+ assert.ok(existsSync(r.backup), 'backup file created');
162
+ const backup = JSON.parse(readFileSync(r.backup, 'utf8'));
163
+ assert.deepEqual(backup.plugin, [['./plugins/bizar', {}]]);
164
+ });
165
+ });
166
+
167
+ describe('removeProvider()', () => {
168
+ test('removes a named provider', () => {
169
+ makeFakeClineJson(workDir, {
170
+ provider: {
171
+ a: { baseUrl: 'https://a', apiKey: 'k', models: {} },
172
+ b: { baseUrl: 'https://b', apiKey: 'k', models: {} },
173
+ },
174
+ });
175
+ const r = removeProvider('a');
176
+ assert.equal(r.removed, true);
177
+ const cfg = JSON.parse(readFileSync(join(workDir, 'cline.json'), 'utf8'));
178
+ assert.equal(cfg.provider.a, undefined);
179
+ assert.ok(cfg.provider.b, 'untouched provider preserved');
180
+ });
181
+
182
+ test('returns removed=false for a missing provider', () => {
183
+ makeFakeClineJson(workDir);
184
+ const r = removeProvider('nonexistent');
185
+ assert.equal(r.removed, false);
186
+ assert.equal(r.reason, 'not present');
187
+ });
188
+ });
189
+
190
+ describe('parseFlags()', () => {
191
+ test('parses --gateway, --key, --provider with space separator', () => {
192
+ const f = parseFlags(['--gateway', 'http://x', '--key', 'sk-x', '--provider', 'foo']);
193
+ assert.equal(f.gateway, 'http://x');
194
+ assert.equal(f.key, 'sk-x');
195
+ assert.equal(f.provider, 'foo');
196
+ });
197
+
198
+ test('parses --gateway=, --key=, --provider= equals form', () => {
199
+ const f = parseFlags(['--gateway=http://x', '--key=sk-x', '--provider=foo']);
200
+ assert.equal(f.gateway, 'http://x');
201
+ assert.equal(f.key, 'sk-x');
202
+ assert.equal(f.provider, 'foo');
203
+ });
204
+
205
+ test('parses --list and --remove', () => {
206
+ const f = parseFlags(['--list']);
207
+ assert.equal(f.list, true);
208
+ const g = parseFlags(['--remove', 'myprov']);
209
+ assert.equal(g.remove, 'myprov');
210
+ });
211
+ });
@@ -51,7 +51,7 @@ const REQUIRED_COMMANDS = [
51
51
  'audit.md', 'bizar.md', 'explain.md', 'init.md', 'learn.md',
52
52
  'plan.md', 'plow-through.md', 'pr-review.md', 'tailscale-serve.md',
53
53
  'visual-plan.md',
54
- 'team.md', 'test.md', 'validate.md',
54
+ 'team.md', 'test.md', 'validate.md', 'setup-provider.md',
55
55
  ];
56
56
 
57
57
  // v6.2.1 — Cline-native hook scripts (executable, shebang, no extension).
@@ -266,20 +266,22 @@ const CHECKS = {
266
266
  },
267
267
 
268
268
  'provider-config': async () => {
269
+ // v6.2.2 — The installer no longer adds a provider block. The user
270
+ // must configure their own provider. This check is therefore
271
+ // ALWAYS lenient (returns a hint, never fails). It just reports
272
+ // what's in cline.json so the user can see the current state.
269
273
  const cfg = readJsonSafe(clineJsonPath());
270
- if (!cfg?.provider) throw new Error('no provider block in cline.json');
271
- const nine = cfg.provider['9router'];
272
- if (nine && nine.baseUrl) {
273
- const models = nine.models || {};
274
- const count = Object.keys(models).length;
275
- if (count === 0) throw new Error('provider.9router has no models');
276
- return `provider.9router (${count} models, baseUrl=${nine.baseUrl})`;
274
+ if (!cfg?.provider || Object.keys(cfg.provider).length === 0) {
275
+ return 'no provider configured — user must add one. `bizar install` no longer touches provider config (v6.2.2+).';
277
276
  }
278
- const minimax = cfg.provider.minimax;
279
- if (!minimax) {
280
- throw new Error('no provider.9router AND no provider.minimax');
281
- }
282
- return 'provider.minimax (legacy fallback)';
277
+ const names = Object.keys(cfg.provider);
278
+ const summary = names.map((n) => {
279
+ const p = cfg.provider[n];
280
+ const url = p?.baseUrl || p?.options?.baseURL || '(no baseUrl)';
281
+ const modelCount = Object.keys(p?.models || {}).length;
282
+ return `${n} (${modelCount} models, baseUrl=${url})`;
283
+ });
284
+ return `user-configured providers: ${summary.join('; ')}`;
283
285
  },
284
286
 
285
287
  '9router-reachable': async () => {
@@ -372,7 +374,10 @@ const CHECK_ORDER = [
372
374
  '9router-reachable',
373
375
  ];
374
376
 
375
- const LENIENT_CHECKS = new Set(['9router-reachable']);
377
+ const LENIENT_CHECKS = new Set([
378
+ '9router-reachable',
379
+ 'provider-config', // v6.2.2+ — installer no longer touches provider config; user must configure
380
+ ]);
376
381
 
377
382
  export function showValidateHelp() {
378
383
  console.log(`
@@ -68,7 +68,7 @@ function makeFakeClineInstall(root) {
68
68
  'audit.md', 'bizar.md', 'explain.md', 'init.md', 'learn.md',
69
69
  'plan.md', 'plow-through.md', 'pr-review.md', 'tailscale-serve.md',
70
70
  'visual-plan.md',
71
- 'team.md', 'test.md', 'validate.md',
71
+ 'team.md', 'test.md', 'validate.md', 'setup-provider.md',
72
72
  ]) {
73
73
  writeFileSync(join(cmdsDir, f), '# fake command');
74
74
  }
@@ -205,28 +205,54 @@ describe('runValidate() — JSON output', () => {
205
205
  assert.equal(exitCode, 1, 'should fail when enableAgentTeams is false');
206
206
  });
207
207
 
208
- test('fails when provider config is missing', async () => {
208
+ test('passes when no provider is configured (v6.2.2+ — user must configure)', async () => {
209
+ // v6.2.2: the installer no longer touches provider config. The
210
+ // user must add their own provider. The provider-config check
211
+ // is therefore ALWAYS lenient — it just reports state.
209
212
  const cfg = JSON.parse(readFileSync(join(workDir, 'cline.json'), 'utf8'));
210
213
  delete cfg.provider;
211
214
  writeFileSync(join(workDir, 'cline.json'), JSON.stringify(cfg, null, 2));
212
- const { exitCode } = await captureRun({ json: true });
213
- assert.equal(exitCode, 1);
215
+ const { exitCode, stdout } = await captureRun({ json: true });
216
+ assert.equal(exitCode, 0, 'no-provider must not fail validation (v6.2.2+ contract)');
217
+ const parsed = JSON.parse(stdout);
218
+ const p = parsed.results.find((r) => r.name === 'provider-config');
219
+ assert.ok(p, 'provider-config check should be present');
220
+ assert.equal(p.ok, true, 'provider-config always returns ok (lenient)');
221
+ assert.match(p.message, /no provider configured/);
214
222
  });
215
223
 
216
- test('passes when only 9router is configured (no minimax)', async () => {
224
+ test('reports user-configured providers when present', async () => {
225
+ // User has set up their own provider (anything goes).
217
226
  const cfg = JSON.parse(readFileSync(join(workDir, 'cline.json'), 'utf8'));
218
- delete cfg.provider.minimax;
227
+ cfg.provider = {
228
+ 'my-custom-provider': {
229
+ baseUrl: 'https://my-llm.example/v1',
230
+ apiKey: 'sk-test',
231
+ models: { 'my-model-1': {}, 'my-model-2': {} },
232
+ },
233
+ };
219
234
  writeFileSync(join(workDir, 'cline.json'), JSON.stringify(cfg, null, 2));
220
- const { exitCode } = await captureRun({ json: true });
235
+ const { exitCode, stdout } = await captureRun({ json: true });
221
236
  assert.equal(exitCode, 0);
237
+ const parsed = JSON.parse(stdout);
238
+ const p = parsed.results.find((r) => r.name === 'provider-config');
239
+ assert.match(p.message, /my-custom-provider/);
240
+ assert.match(p.message, /2 models/);
222
241
  });
223
242
 
224
- test('passes when only minimax is configured (no 9router)', async () => {
243
+ test('reports both providers if user has multiple', async () => {
225
244
  const cfg = JSON.parse(readFileSync(join(workDir, 'cline.json'), 'utf8'));
226
- delete cfg.provider['9router'];
245
+ cfg.provider = {
246
+ 'a': { baseUrl: 'https://a.example/v1', models: { 'm1': {} } },
247
+ 'b': { baseUrl: 'https://b.example/v1', models: { 'm2': {}, 'm3': {} } },
248
+ };
227
249
  writeFileSync(join(workDir, 'cline.json'), JSON.stringify(cfg, null, 2));
228
- const { exitCode } = await captureRun({ json: true });
250
+ const { exitCode, stdout } = await captureRun({ json: true });
229
251
  assert.equal(exitCode, 0);
252
+ const parsed = JSON.parse(stdout);
253
+ const p = parsed.results.find((r) => r.name === 'provider-config');
254
+ assert.match(p.message, /\ba\b/);
255
+ assert.match(p.message, /\bb\b/);
230
256
  });
231
257
 
232
258
  test('default mode is lenient on 9router-unreachable', async () => {