@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,11 +1,16 @@
1
1
  /**
2
2
  * cli/commands/setup-provider.test.mjs
3
3
  *
4
- * v6.2.2 — Unit tests for the setup-provider subcommand.
4
+ * v6.2.3 — Unit tests for the setup-provider subcommand.
5
5
  *
6
- * Exercises the pure helpers (`buildProviderBlock`, `parseFlags`)
7
- * and the filesystem mutations (`applyProviderBlock`, `removeProvider`)
8
- * with a mocked CLINE_DIR.
6
+ * v6.2.3 changed the storage backend from `~/.cline/cline.json` to
7
+ * `~/.cline/data/settings/providers.json` (the file the Cline CLI
8
+ * and kanban mode actually read). The default providerId changed
9
+ * from `9router` to `litellm` (a real entry in Cline's catalog).
10
+ *
11
+ * Exercises the pure helpers (`buildProviderBlock`, `parseFlags`,
12
+ * `OPENAI_COMPATIBLE_PROVIDERS`) and the filesystem mutations
13
+ * (`applyProviderBlock`, `removeProvider`) with a mocked CLINE_DIR.
9
14
  */
10
15
  import { test, describe, beforeEach, afterEach } from 'node:test';
11
16
  import assert from 'node:assert/strict';
@@ -19,27 +24,41 @@ const {
19
24
  removeProvider,
20
25
  listGatewayModels,
21
26
  parseFlags,
27
+ OPENAI_COMPATIBLE_PROVIDERS,
22
28
  } = await import('./setup-provider.mjs');
23
29
 
24
30
  const ORIG_CLINE_DIR = process.env.CLINE_DIR;
31
+ const ORIG_DATA_DIR = process.env.CLINE_DATA_DIR;
32
+ const ORIG_PROVIDER_PATH = process.env.CLINE_PROVIDER_SETTINGS_PATH;
25
33
  let workDir;
26
34
 
27
35
  function freshWorkDir() {
28
36
  workDir = mkdtempSync(join(tmpdir(), 'bizar-setup-provider-'));
37
+ // CLINE_DIR is the *base* dir (e.g. ~/.cline). The settings file
38
+ // lives at `${CLINE_DIR}/data/settings/providers.json`. Mocking
39
+ // CLINE_DIR is the easiest way to redirect the whole tree.
29
40
  process.env.CLINE_DIR = workDir;
41
+ // Clear overrides that could change the resolution path.
42
+ delete process.env.CLINE_DATA_DIR;
43
+ delete process.env.CLINE_PROVIDER_SETTINGS_PATH;
30
44
  return workDir;
31
45
  }
32
46
 
33
- function makeFakeClineJson(root, body = {}) {
47
+ function settingsFile() {
48
+ return join(workDir, 'data', 'settings', 'providers.json');
49
+ }
50
+
51
+ function makeFakeSettings(root, body = {}) {
34
52
  const cfg = {
35
- $schema: 'https://docs.cline.bot/config.json',
36
- plugin: [],
37
- default_agent: 'odin',
38
- permission: 'allow',
39
- snapshot: false,
53
+ version: 1,
54
+ providers: {},
55
+ lastUsedProvider: 'litellm',
40
56
  ...body,
41
57
  };
42
- writeFileSync(join(root, 'cline.json'), JSON.stringify(cfg, null, 2));
58
+ const file = join(root, 'data', 'settings', 'providers.json');
59
+ mkdirSync(join(root, 'data', 'settings'), { recursive: true });
60
+ writeFileSync(file, JSON.stringify(cfg, null, 2));
61
+ return file;
43
62
  }
44
63
 
45
64
  beforeEach(() => {
@@ -50,137 +69,215 @@ afterEach(() => {
50
69
  if (workDir && existsSync(workDir)) rmSync(workDir, { recursive: true, force: true });
51
70
  if (ORIG_CLINE_DIR === undefined) delete process.env.CLINE_DIR;
52
71
  else process.env.CLINE_DIR = ORIG_CLINE_DIR;
72
+ if (ORIG_DATA_DIR === undefined) delete process.env.CLINE_DATA_DIR;
73
+ else process.env.CLINE_DATA_DIR = ORIG_DATA_DIR;
74
+ if (ORIG_PROVIDER_PATH === undefined) delete process.env.CLINE_PROVIDER_SETTINGS_PATH;
75
+ else process.env.CLINE_PROVIDER_SETTINGS_PATH = ORIG_PROVIDER_PATH;
76
+ });
77
+
78
+ describe('OPENAI_COMPATIBLE_PROVIDERS', () => {
79
+ test('includes litellm (the new v6.2.3 default)', () => {
80
+ assert.ok(OPENAI_COMPATIBLE_PROVIDERS.includes('litellm'),
81
+ 'litellm must be the canonical openai-compatible providerId');
82
+ });
83
+ test('does NOT include the fake "openai-compatible" or "9router" IDs', () => {
84
+ assert.equal(OPENAI_COMPATIBLE_PROVIDERS.includes('openai-compatible'), false,
85
+ 'openai-compatible is a family, not a valid providerId');
86
+ assert.equal(OPENAI_COMPATIBLE_PROVIDERS.includes('9router'), false,
87
+ '9router is not a built-in Cline providerId (would fail in kanban)');
88
+ });
53
89
  });
54
90
 
55
91
  describe('buildProviderBlock()', () => {
56
- test('emits a baseUrl, apiKey, and bare modelIds', () => {
92
+ test('emits a Cline-settings-shaped block (version 1 + settings + updatedAt)', () => {
57
93
  const block = buildProviderBlock({
58
- name: '9router',
94
+ name: 'litellm',
59
95
  gateway: 'http://localhost:20128/v1',
60
96
  apiKey: 'sk-test',
61
- models: ['minimax/MiniMax-M3', 'minimax/MiniMax-M2.7'],
97
+ model: 'minimaxcustom/MiniMax-M3',
62
98
  });
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']);
99
+ assert.ok(block.settings);
100
+ assert.equal(block.settings.provider, 'litellm');
101
+ assert.equal(block.settings.model, 'minimaxcustom/MiniMax-M3');
102
+ assert.equal(block.settings.apiKey, 'sk-test');
103
+ assert.equal(block.settings.baseUrl, 'http://localhost:20128/v1');
104
+ assert.ok(block.settings.reasoning);
105
+ assert.equal(block.settings.reasoning.enabled, false);
106
+ assert.ok(block.updatedAt);
107
+ assert.match(block.updatedAt, /^\d{4}-\d{2}-\d{2}T/);
108
+ assert.equal(block.tokenSource, 'manual');
67
109
  });
68
110
 
69
111
  test('wraps env-var-style keys in ${env:...}', () => {
70
112
  const block = buildProviderBlock({
71
- name: '9router',
113
+ name: 'litellm',
72
114
  gateway: 'http://localhost:20128/v1',
73
115
  apiKey: 'NINEROUTER_KEY',
74
- models: ['minimax/MiniMax-M3'],
116
+ model: 'm1',
75
117
  });
76
- assert.equal(block['9router'].apiKey, '${env:NINEROUTER_KEY}');
118
+ assert.equal(block.settings.apiKey, '${env:NINEROUTER_KEY}');
77
119
  });
78
120
 
79
- test('keeps literal keys as-is when not env-var shaped', () => {
121
+ test('sets reasoning.enabled=true when reason=true', () => {
80
122
  const block = buildProviderBlock({
81
- name: 'custom',
82
- gateway: 'https://x.example/v1',
83
- apiKey: 'sk-abc-123',
84
- models: ['m1'],
123
+ name: 'litellm', gateway: 'http://x', apiKey: 'k', model: 'm', reason: true,
85
124
  });
86
- assert.equal(block['custom'].apiKey, 'sk-abc-123');
125
+ assert.equal(block.settings.reasoning.enabled, true);
87
126
  });
88
127
  });
89
128
 
90
129
  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'],
130
+ test('writes to ${CLINE_DIR}/data/settings/providers.json (NOT ~/.cline/cline.json)', () => {
131
+ const body = buildProviderBlock({
132
+ name: 'litellm', gateway: 'http://localhost:20128/v1',
133
+ apiKey: 'sk-test', model: 'minimaxcustom/MiniMax-M3',
98
134
  });
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');
135
+ const r = applyProviderBlock('litellm', body);
136
+ const expected = settingsFile();
137
+ assert.equal(r.path, expected, 'must write to the Cline settings file');
138
+ assert.ok(existsSync(expected));
104
139
  });
105
140
 
106
- test('preserves other provider entries when adding a new one', () => {
107
- makeFakeClineJson(workDir, {
108
- provider: { existing: { baseUrl: 'https://a', apiKey: 'k', models: { m: {} } } },
141
+ test('bootstraps the settings file when it does not exist', () => {
142
+ // No makeFakeSettings call.
143
+ const body = buildProviderBlock({
144
+ name: 'litellm', gateway: 'http://x', apiKey: 'k', model: 'm',
109
145
  });
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');
146
+ const r = applyProviderBlock('litellm', body);
147
+ assert.ok(existsSync(r.path));
148
+ const cfg = JSON.parse(readFileSync(r.path, 'utf8'));
149
+ assert.equal(cfg.version, 1);
150
+ assert.equal(cfg.lastUsedProvider, 'litellm');
151
+ assert.ok(cfg.providers.litellm);
120
152
  });
121
153
 
122
- test('overwrites an existing provider with the same name', () => {
123
- makeFakeClineJson(workDir, {
124
- provider: { '9router': { baseUrl: 'https://old', apiKey: 'old', models: {} } },
154
+ test('preserves other provider entries when adding a new one', () => {
155
+ makeFakeSettings(workDir, {
156
+ providers: { ollama: { settings: { provider: 'ollama', model: 'llama3' } } },
125
157
  });
126
- const block = buildProviderBlock({
127
- name: '9router',
128
- gateway: 'http://localhost:20128/v1',
129
- apiKey: 'new',
130
- models: ['minimax/MiniMax-M3'],
158
+ const body = buildProviderBlock({
159
+ name: 'litellm', gateway: 'http://x', apiKey: 'k', model: 'm',
131
160
  });
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']);
161
+ applyProviderBlock('litellm', body);
162
+ const cfg = JSON.parse(readFileSync(settingsFile(), 'utf8'));
163
+ assert.ok(cfg.providers.ollama, 'existing provider preserved');
164
+ assert.ok(cfg.providers.litellm, 'new provider added');
137
165
  });
138
166
 
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'],
167
+ test('overwrites an existing provider with the same name', () => {
168
+ makeFakeSettings(workDir, {
169
+ providers: { litellm: { settings: { provider: 'litellm', baseUrl: 'https://old', model: 'old' } } },
146
170
  });
147
- assert.throws(() => applyProviderBlock(block), /not found/);
171
+ const body = buildProviderBlock({
172
+ name: 'litellm', gateway: 'http://new', apiKey: 'new', model: 'new',
173
+ });
174
+ applyProviderBlock('litellm', body);
175
+ const cfg = JSON.parse(readFileSync(settingsFile(), 'utf8'));
176
+ assert.equal(cfg.providers.litellm.settings.baseUrl, 'http://new');
177
+ assert.equal(cfg.providers.litellm.settings.model, 'new');
148
178
  });
149
179
 
150
180
  test('creates a backup file', () => {
151
- makeFakeClineJson(workDir, {
152
- plugin: [['./plugins/bizar', {}]],
181
+ makeFakeSettings(workDir, {
182
+ providers: { x: { settings: { provider: 'x' } } },
153
183
  });
154
- const block = buildProviderBlock({
155
- name: '9router',
156
- gateway: 'http://localhost:20128/v1',
157
- apiKey: 'sk-test',
158
- models: ['minimax/MiniMax-M3'],
184
+ const body = buildProviderBlock({
185
+ name: 'litellm', gateway: 'http://x', apiKey: 'k', model: 'm',
159
186
  });
160
- const r = applyProviderBlock(block);
187
+ const r = applyProviderBlock('litellm', body);
161
188
  assert.ok(existsSync(r.backup), 'backup file created');
162
189
  const backup = JSON.parse(readFileSync(r.backup, 'utf8'));
163
- assert.deepEqual(backup.plugin, [['./plugins/bizar', {}]]);
190
+ assert.ok(backup.providers.x, 'backup contains original entry');
191
+ });
192
+ });
193
+
194
+ describe('migrateLegacyOpenaiCompatible()', async () => {
195
+ const { migrateLegacyOpenaiCompatible } = await import('./setup-provider.mjs');
196
+
197
+ test('renames an openai-compatible entry to litellm (preserving baseUrl/apiKey/model)', () => {
198
+ makeFakeSettings(workDir, {
199
+ providers: {
200
+ 'openai-compatible': {
201
+ settings: {
202
+ provider: 'openai-compatible',
203
+ baseUrl: 'http://localhost:20128/v1',
204
+ apiKey: 'sk-test',
205
+ model: 'minimaxcustom/MiniMax-M3',
206
+ reasoning: { enabled: false, effort: 'medium' },
207
+ },
208
+ updatedAt: '2026-07-09T21:20:00.000Z',
209
+ tokenSource: 'manual',
210
+ },
211
+ },
212
+ lastUsedProvider: 'openai-compatible',
213
+ });
214
+ const r = migrateLegacyOpenaiCompatible('litellm');
215
+ assert.equal(r.migrated, true);
216
+ assert.equal(r.from, 'openai-compatible');
217
+ assert.equal(r.to, 'litellm');
218
+ const cfg = JSON.parse(readFileSync(settingsFile(), 'utf8'));
219
+ assert.equal(cfg.providers['openai-compatible'], undefined, 'old key removed');
220
+ assert.ok(cfg.providers.litellm, 'new key added');
221
+ assert.equal(cfg.providers.litellm.settings.baseUrl, 'http://localhost:20128/v1', 'baseUrl preserved');
222
+ assert.equal(cfg.providers.litellm.settings.apiKey, 'sk-test', 'apiKey preserved');
223
+ assert.equal(cfg.providers.litellm.settings.model, 'minimaxcustom/MiniMax-M3', 'model preserved');
224
+ assert.equal(cfg.lastUsedProvider, 'litellm', 'active provider switched');
225
+ });
226
+
227
+ test('no-op when no legacy entry exists', () => {
228
+ makeFakeSettings(workDir, {
229
+ providers: { litellm: { settings: { provider: 'litellm' } } },
230
+ });
231
+ const r = migrateLegacyOpenaiCompatible('litellm');
232
+ assert.equal(r.migrated, false);
233
+ assert.equal(r.reason, 'no legacy entry');
234
+ });
235
+
236
+ test('no-op when settings file missing', () => {
237
+ // No makeFakeSettings call
238
+ const r = migrateLegacyOpenaiCompatible('litellm');
239
+ assert.equal(r.migrated, false);
240
+ assert.equal(r.reason, 'no settings file');
241
+ });
242
+
243
+ test('preserves lastUsedProvider when it pointed at a different entry', () => {
244
+ makeFakeSettings(workDir, {
245
+ providers: {
246
+ 'openai-compatible': { settings: { provider: 'openai-compatible', baseUrl: 'http://a' } },
247
+ ollama: { settings: { provider: 'ollama' } },
248
+ },
249
+ lastUsedProvider: 'ollama',
250
+ });
251
+ migrateLegacyOpenaiCompatible('litellm');
252
+ const cfg = JSON.parse(readFileSync(settingsFile(), 'utf8'));
253
+ // Should NOT clobber the active provider if it wasn't the broken one
254
+ assert.equal(cfg.lastUsedProvider, 'ollama', 'active ollama preserved');
164
255
  });
165
256
  });
166
257
 
167
258
  describe('removeProvider()', () => {
168
259
  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: {} },
260
+ makeFakeSettings(workDir, {
261
+ providers: {
262
+ a: { settings: { provider: 'a' } },
263
+ b: { settings: { provider: 'b' } },
173
264
  },
174
265
  });
175
266
  const r = removeProvider('a');
176
267
  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');
268
+ const cfg = JSON.parse(readFileSync(settingsFile(), 'utf8'));
269
+ assert.equal(cfg.providers.a, undefined);
270
+ assert.ok(cfg.providers.b, 'untouched provider preserved');
271
+ });
272
+
273
+ test('returns removed=false when settings file is missing', () => {
274
+ const r = removeProvider('nope');
275
+ assert.equal(r.removed, false);
276
+ assert.equal(r.reason, 'no settings file');
180
277
  });
181
278
 
182
279
  test('returns removed=false for a missing provider', () => {
183
- makeFakeClineJson(workDir);
280
+ makeFakeSettings(workDir);
184
281
  const r = removeProvider('nonexistent');
185
282
  assert.equal(r.removed, false);
186
283
  assert.equal(r.reason, 'not present');
@@ -188,23 +285,26 @@ describe('removeProvider()', () => {
188
285
  });
189
286
 
190
287
  describe('parseFlags()', () => {
191
- test('parses --gateway, --key, --provider with space separator', () => {
192
- const f = parseFlags(['--gateway', 'http://x', '--key', 'sk-x', '--provider', 'foo']);
288
+ test('parses --gateway, --key, --provider, --model with space separator', () => {
289
+ const f = parseFlags(['--gateway', 'http://x', '--key', 'sk-x', '--provider', 'litellm', '--model', 'm1']);
193
290
  assert.equal(f.gateway, 'http://x');
194
291
  assert.equal(f.key, 'sk-x');
195
- assert.equal(f.provider, 'foo');
292
+ assert.equal(f.provider, 'litellm');
293
+ assert.equal(f.model, 'm1');
196
294
  });
197
295
 
198
- test('parses --gateway=, --key=, --provider= equals form', () => {
199
- const f = parseFlags(['--gateway=http://x', '--key=sk-x', '--provider=foo']);
296
+ test('parses equals form', () => {
297
+ const f = parseFlags(['--gateway=http://x', '--key=sk-x', '--provider=litellm', '--model=m1']);
200
298
  assert.equal(f.gateway, 'http://x');
201
299
  assert.equal(f.key, 'sk-x');
202
- assert.equal(f.provider, 'foo');
300
+ assert.equal(f.provider, 'litellm');
301
+ assert.equal(f.model, 'm1');
203
302
  });
204
303
 
205
- test('parses --list and --remove', () => {
206
- const f = parseFlags(['--list']);
304
+ test('parses --list, --discover, --remove', () => {
305
+ const f = parseFlags(['--list', '--discover']);
207
306
  assert.equal(f.list, true);
307
+ assert.equal(f.discover, true);
208
308
  const g = parseFlags(['--remove', 'myprov']);
209
309
  assert.equal(g.remove, 'myprov');
210
310
  });
@@ -65,6 +65,23 @@ const REQUIRED_HOOKS = [
65
65
 
66
66
  const REQUIRED_RUNTIME_DEPS = ['zod', '@cline/sdk', '@cline/core', '@cline/shared'];
67
67
 
68
+ // v6.2.3 — Built-in Cline providerIds that are safe for any
69
+ // "OpenAI-compatible endpoint" use case. The harness warns about
70
+ // any providerId NOT in this set (since they fail in kanban mode).
71
+ const OPENAI_COMPATIBLE_BUILTIN_IDS = new Set([
72
+ 'litellm', 'cline', 'ollama', 'lmstudio', 'huggingface',
73
+ // Plus the first-class providers (also OpenAI-compatible):
74
+ 'openrouter', 'deepseek', 'xai', 'together', 'fireworks',
75
+ 'groq', 'cerebras', 'sambanova', 'nebius', 'baseten',
76
+ 'requesty', 'vercel-ai-gateway', 'v0', 'aihubmix', 'hicap',
77
+ 'nousResearch', 'huawei-cloud-maas', 'qwen', 'qwen-code',
78
+ 'doubao', 'zai', 'zai-coding-plan', 'moonshot', 'wandb',
79
+ 'xiaomi', 'kilo', 'oca', 'asksage', 'sapaicore',
80
+ // Non-OpenAI-compatible (kept so we don't false-positive):
81
+ 'anthropic', 'google', 'bedrock', 'vertex', 'gemini',
82
+ 'openai', 'openai-native', 'openai-codex',
83
+ ]);
84
+
68
85
  function check(name, fn) {
69
86
  return Promise.resolve()
70
87
  .then(fn)
@@ -284,6 +301,50 @@ const CHECKS = {
284
301
  return `user-configured providers: ${summary.join('; ')}`;
285
302
  },
286
303
 
304
+ 'cline-settings-provider': async () => {
305
+ // v6.2.3 — The Cline CLI and kanban mode read from
306
+ // `~/.cline/data/settings/providers.json`. The harness config
307
+ // (`cline.json`) is a separate thing. This check inspects the
308
+ // actual settings file and:
309
+ // 1. Warns if the user has a legacy `openai-compatible` providerId
310
+ // (from the v6.0.1 Cline auto-migration shim). Kanban mode
311
+ // fails on this with "Unknown or disabled provider". Run
312
+ // `bizar setup-provider` to auto-migrate.
313
+ // 2. Reports the active `lastUsedProvider` so the user can
314
+ // see which provider Cline will actually use on the next
315
+ // session.
316
+ const file = join(homedir(), '.cline', 'data', 'settings', 'providers.json');
317
+ if (!existsSync(file)) {
318
+ return 'no settings file at ~/.cline/data/settings/providers.json — run `bizar setup-provider` to add one';
319
+ }
320
+ const settings = readJsonSafe(file);
321
+ if (!settings || !settings.providers) {
322
+ return 'settings file is empty or invalid — run `bizar setup-provider`';
323
+ }
324
+ const names = Object.keys(settings.providers);
325
+ if (names.length === 0) {
326
+ throw new Error('no providers configured in Cline settings — run `bizar setup-provider`');
327
+ }
328
+ const issues = [];
329
+ if (names.includes('openai-compatible')) {
330
+ issues.push(`legacy 'openai-compatible' providerId present (broken in kanban mode) — run \`bizar setup-provider\` to auto-migrate to 'litellm'`);
331
+ }
332
+ // Check for any providerId that's not in Cline's built-in catalog.
333
+ for (const n of names) {
334
+ if (n === 'openai-compatible') continue; // handled above
335
+ if (!OPENAI_COMPATIBLE_BUILTIN_IDS.has(n)) {
336
+ issues.push(`'${n}' providerId is NOT in Cline's built-in catalog (likely broken in kanban mode)`);
337
+ }
338
+ }
339
+ const lastUsed = settings.lastUsedProvider || '(none)';
340
+ if (issues.length > 0) {
341
+ // Throw so the ⚠ marker shows up; the check is in LENIENT_CHECKS
342
+ // so this won't cause `bizar validate` to exit non-zero.
343
+ throw new Error(`WARN: ${issues.join('; ')}. lastUsedProvider=${lastUsed} (${names.join(', ')})`);
344
+ }
345
+ return `lastUsedProvider=${lastUsed}; providers: ${names.join(', ')}`;
346
+ },
347
+
287
348
  '9router-reachable': async () => {
288
349
  const url = process.env.NINEROUTER_URL || 'http://localhost:20128';
289
350
  const ac = new AbortController();
@@ -301,6 +362,33 @@ const CHECKS = {
301
362
  }
302
363
  },
303
364
 
365
+ // v6.2.4 — Warn if the user's clineruntimeMaxConsecutiveMistakes is
366
+ // below the plugin's recommended minimum. Cline's CLI default is 3
367
+ // which aborts sessions on the 3rd tool mistake. The Bizar plugin
368
+ // recommends 10 (or `--retries N` with N ≥ 10).
369
+ 'mistake-limit-floor': async () => {
370
+ const MIN = 10;
371
+ const cfg = readJsonSafe(clineJsonPath());
372
+ if (!cfg?.plugin || !Array.isArray(cfg.plugin)) {
373
+ return 'no plugin entries in cline.json — skip mistake-limit check';
374
+ }
375
+ const bizarEntry = cfg.plugin.find((p) => Array.isArray(p) && p[0] && p[0].includes('plugins/bizar'));
376
+ if (!bizarEntry) {
377
+ return 'Bizar plugin not registered — skip mistake-limit check';
378
+ }
379
+ const opts = (Array.isArray(bizarEntry) && bizarEntry[1]) || {};
380
+ const raw = opts.clineruntimeMaxConsecutiveMistakes;
381
+ if (typeof raw !== 'number' || raw >= MIN) {
382
+ return `clineruntimeMaxConsecutiveMistakes=${raw ?? 'default 10'} (≥ ${MIN}) — OK`;
383
+ }
384
+ throw new Error(
385
+ `clineruntimeMaxConsecutiveMistakes=${raw} is BELOW the recommended minimum (${MIN}). ` +
386
+ `Cline's default of 3 aborts the session on the 3rd tool mistake. ` +
387
+ `Edit ~/.cline/cline.json plugin[1].clineruntimeMaxConsecutiveMistakes to ${MIN}, or run ` +
388
+ `\`bizar install\` to reset to defaults. (See _shared/CLINE_TOOLS.md for the top-5 mistakes.)`,
389
+ );
390
+ },
391
+
304
392
  'default-agent-set': async () => {
305
393
  const cfg = readJsonSafe(clineJsonPath());
306
394
  if (!cfg?.default_agent) {
@@ -371,12 +459,16 @@ const CHECK_ORDER = [
371
459
  'default-agent-set',
372
460
  'instructions-loaded',
373
461
  'provider-config',
462
+ 'cline-settings-provider',
374
463
  '9router-reachable',
464
+ 'mistake-limit-floor',
375
465
  ];
376
466
 
377
467
  const LENIENT_CHECKS = new Set([
378
468
  '9router-reachable',
379
469
  'provider-config', // v6.2.2+ — installer no longer touches provider config; user must configure
470
+ 'cline-settings-provider', // v6.2.3 — warns about legacy/fake providerIds; non-blocking
471
+ 'mistake-limit-floor', // v6.2.4 — warns about low mistake limit; non-blocking
380
472
  ]);
381
473
 
382
474
  export function showValidateHelp() {
@@ -304,4 +304,41 @@ describe('runValidate() — JSON output', () => {
304
304
  const { exitCode } = await captureRun({ json: true, only: 'this-does-not-exist' });
305
305
  assert.equal(exitCode, 2);
306
306
  });
307
+
308
+ test('v6.2.4 — mistake-limit-floor warns when clineruntimeMaxConsecutiveMistakes is < 10', async () => {
309
+ const cfg = JSON.parse(readFileSync(join(workDir, 'cline.json'), 'utf8'));
310
+ cfg.plugin = [['./plugins/bizar', { clineruntimeMaxConsecutiveMistakes: 3 }]];
311
+ writeFileSync(join(workDir, 'cline.json'), JSON.stringify(cfg, null, 2));
312
+ const { stdout, exitCode } = await captureRun({ json: true });
313
+ assert.equal(exitCode, 0, 'mistake-limit-floor is lenient so exit stays 0');
314
+ const parsed = JSON.parse(stdout);
315
+ const check = parsed.results.find((r) => r.name === 'mistake-limit-floor');
316
+ assert.ok(check, 'mistake-limit-floor check should be present');
317
+ assert.equal(check.ok, false, 'low mistake limit should warn');
318
+ assert.match(check.message, /BELOW the recommended minimum/);
319
+ assert.match(check.message, /clineruntimeMaxConsecutiveMistakes=3/);
320
+ });
321
+
322
+ test('v6.2.4 — mistake-limit-floor OK when clineruntimeMaxConsecutiveMistakes >= 10', async () => {
323
+ const cfg = JSON.parse(readFileSync(join(workDir, 'cline.json'), 'utf8'));
324
+ cfg.plugin = [['./plugins/bizar', { clineruntimeMaxConsecutiveMistakes: 15 }]];
325
+ writeFileSync(join(workDir, 'cline.json'), JSON.stringify(cfg, null, 2));
326
+ const { stdout, exitCode } = await captureRun({ json: true });
327
+ assert.equal(exitCode, 0);
328
+ const parsed = JSON.parse(stdout);
329
+ const check = parsed.results.find((r) => r.name === 'mistake-limit-floor');
330
+ assert.equal(check.ok, true);
331
+ assert.match(check.message, /15/);
332
+ });
333
+
334
+ test('v6.2.4 — mistake-limit-floor OK when no plugin entry (legacy cline.json)', async () => {
335
+ const cfg = JSON.parse(readFileSync(join(workDir, 'cline.json'), 'utf8'));
336
+ delete cfg.plugin;
337
+ writeFileSync(join(workDir, 'cline.json'), JSON.stringify(cfg, null, 2));
338
+ const { stdout } = await captureRun({ json: true });
339
+ const parsed = JSON.parse(stdout);
340
+ const check = parsed.results.find((r) => r.name === 'mistake-limit-floor');
341
+ assert.match(check.message, /no plugin entries/);
342
+ assert.equal(check.ok, true);
343
+ });
307
344
  });
@@ -5,7 +5,19 @@ description: Always-on rules for every Bizar agent. Loaded automatically by clin
5
5
 
6
6
  # Agent Baseline — Always-On Rules
7
7
 
8
- Every Bizar agent follows these rules at all times. They are translated from the upstream Claude Fable 5 system prompt, with every Claude-specific tool / function / directory mapped to the BizarHarness equivalent (cline tools, Semble, Skills CLI, Obsidian vault, agent-browser, dashboard artifact pipeline).
8
+ > **v6.2.4 Read `CLINE_TOOLS.md` first.** The Cline tools (`read_file`,
9
+ > `editor`, `apply_patch`, `ask_question`, `use_subagents`, `task`, …)
10
+ > have strict argument shapes. Passing the wrong shape — e.g. `options:
11
+ > null` on `ask_question` — silently fails and counts as a "mistake".
12
+ > After 3 mistakes (10 since v6.2.4) Cline aborts the session.
13
+ > See `_shared/CLINE_TOOLS.md` for the exact schemas.
14
+
15
+ Every Bizar agent follows these rules at all times. They are the
16
+ canonical agent baseline for the BizarHarness system — the Norse-pantheon
17
+ multi-agent system built on top of Cline. The rules below are tuned for
18
+ Bizar specifically: tool names reference Cline's built-in tools
19
+ (`read_file`, `editor`, `ask_question`, …) and Bizar's plugin tools
20
+ (`bizar_*`); storage paths reference `~/.cline/` and `~/.bizar_memory/`.
9
21
 
10
22
  ---
11
23
 
@@ -25,6 +37,33 @@ Every Bizar agent follows these rules at all times. They are translated from the
25
37
 
26
38
  ---
27
39
 
40
+ ## 1a. Tool Mistakes — Don't Kill the Session
41
+
42
+ Cline's runtime counts consecutive tool failures (mistakes). When the
43
+ count hits the limit (3 in v6.2.0–v6.2.3; **10 since v6.2.4**), it aborts
44
+ the session with `max consecutive mistakes reached`. The most common
45
+ cause is calling a tool with the wrong argument shape.
46
+
47
+ **Top 5 mistakes that abort sessions — read this and avoid them:**
48
+
49
+ 1. **`ask_question` with `options: null` or `options: undefined`.** The tool silently fails and counts as a mistake. Always pass an array of 2–5 strings.
50
+ 2. **`editor` with non-matching `old_text`.** Whitespace must match exactly. Always `read_file` first and copy the bytes.
51
+ 3. **`editor` with `old_text` that matches multiple places.** Make `old_text` more specific (include more surrounding context) so it matches exactly once.
52
+ 4. **`execute_command` with shell redirects (`>`, `>>`).** Bizar blocks these by default. Use `editor` or `apply_patch` to write files.
53
+ 5. **`use_subagents` with a single prompt.** Subagents are parallel research. Spawn 3–5 at once, not one at a time.
54
+
55
+ **Recovery: if you hit the mistake limit:**
56
+
57
+ - Stop retrying the same broken call — each retry wastes a mistake.
58
+ - Read `_shared/CLINE_TOOLS.md` for the exact schema.
59
+ - Spawn a fresh session via `bizar_spawn_background` if the runtime is stuck.
60
+ - Ask the user to abort and restart with `bizar doctor` + `bizar validate`.
61
+
62
+ The full reference is at `_shared/CLINE_TOOLS.md` (linked from every agent
63
+ file's `description` frontmatter).
64
+
65
+ ---
66
+
28
67
  ## 2. Codebase Search — Use Semble First
29
68
 
30
69
  Semble is the local code search tool — faster and more token-efficient than reading files directly.