@polderlabs/bizar 6.2.2 → 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.
@@ -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();
@@ -371,12 +432,14 @@ const CHECK_ORDER = [
371
432
  'default-agent-set',
372
433
  'instructions-loaded',
373
434
  'provider-config',
435
+ 'cline-settings-provider',
374
436
  '9router-reachable',
375
437
  ];
376
438
 
377
439
  const LENIENT_CHECKS = new Set([
378
440
  '9router-reachable',
379
441
  'provider-config', // v6.2.2+ — installer no longer touches provider config; user must configure
442
+ 'cline-settings-provider', // v6.2.3 — warns about legacy/fake providerIds; non-blocking
380
443
  ]);
381
444
 
382
445
  export function showValidateHelp() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polderlabs/bizar",
3
- "version": "6.2.2",
3
+ "version": "6.2.3",
4
4
  "description": "Norse-pantheon multi-agent system for cline — 13 agents across 4 cost tiers with cost-aware routing, plans, and a configurable agent harness. v4 ships as a single npm package bundling the dashboard server, cline plugin, and typed SDK.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,7 +24,7 @@
24
24
  "test:sdk": "node_modules/.bin/vitest run --root packages/sdk",
25
25
  "test:web": "cd bizar-dash && npx vitest run",
26
26
  "test:sdk:watch": "node_modules/.bin/vitest --root packages/sdk",
27
- "test": "npm run typecheck && npm run test:sdk && node --test cli/install.test.mjs cli/provision.test.mjs cli/commands/validate.test.mjs cli/commands/setup-provider.test.mjs && bun test plugins/bizar/tests/loop.test.ts plugins/bizar/tests/block.test.ts plugins/bizar/tests/stall-think.test.ts plugins/bizar/tests/tools/bg-get-comments.test.ts plugins/bizar/tests/tools/bg-spawn-delegation.test.ts plugins/bizar/tests/tools/cline-runner.test.ts plugins/bizar/tests/settings.test.ts plugins/bizar/tests/commands.test.ts plugins/bizar/tests/commands-impl.test.ts plugins/bizar/tests/tools/plan-action.test.ts plugins/bizar/tests/tools/wait-for-feedback.test.ts plugins/bizar/tests/tools/read-glyph-feedback.test.ts plugins/bizar/tests/reasoning-clean.test.ts plugins/bizar/tests/key-rotation.test.ts && node bizar-dash/tests/smoke-v2.mjs && node --test bizar-dash/tests/path-safe.test.mjs bizar-dash/tests/tmux-wrap.test.mjs bizar-dash/tests/cline-sessions-detail.test.mjs bizar-dash/tests/cline-runner.test.mjs bizar-dash/tests/mod-instructions.node.test.mjs bizar-dash/tests/mod-upgrade.node.test.mjs bizar-dash/tests/graphify-mod-spawn.node.test.mjs bizar-dash/tests/no-agent-browser.node.test.mjs bizar-dash/tests/providers-store-backup-keys.node.test.mjs bizar-dash/tests/dashboard-ports.test.mjs bizar-dash/tests/submit-feedback.test.mjs bizar-dash/tests/yaml.test.mjs bizar-dash/tests/memory-store.test.mjs bizar-dash/tests/memory-schema.test.mjs bizar-dash/tests/memory-secrets.test.mjs bizar-dash/tests/memory-git.test.mjs bizar-dash/tests/memory-sync.test.mjs bizar-dash/tests/obsidian-back-compat.test.mjs bizar-dash/tests/memory-lightrag.test.mjs bizar-dash/tests/memory-config.test.mjs bizar-dash/tests/memory-cli.test.mjs bizar-dash/tests/memory-cli-readlistdelete.test.mjs bizar-dash/tests/memory-cli-setup.test.mjs bizar-dash/tests/memory-conflicts.test.mjs bizar-dash/tests/memory-namespace.test.mjs bizar-dash/tests/memory-path-safety.test.mjs bizar-dash/tests/memory-protocol-drift.test.mjs bizar-dash/tests/memory-roundtrip.test.mjs bizar-dash/tests/cli-bugfixes.test.mjs bizar-dash/tests/server-bugfixes.test.mjs bizar-dash/tests/frontend-bugfixes.test.mjs",
27
+ "test": "npm run typecheck && npm run test:sdk && node --test cli/install.test.mjs cli/provision.test.mjs cli/commands/validate.test.mjs cli/commands/setup-provider.test.mjs cli/commands/rca.test.mjs && bun test plugins/bizar/tests/loop.test.ts plugins/bizar/tests/block.test.ts plugins/bizar/tests/stall-think.test.ts plugins/bizar/tests/tools/bg-get-comments.test.ts plugins/bizar/tests/tools/bg-spawn-delegation.test.ts plugins/bizar/tests/tools/cline-runner.test.ts plugins/bizar/tests/settings.test.ts plugins/bizar/tests/commands.test.ts plugins/bizar/tests/commands-impl.test.ts plugins/bizar/tests/tools/plan-action.test.ts plugins/bizar/tests/tools/wait-for-feedback.test.ts plugins/bizar/tests/tools/read-glyph-feedback.test.ts plugins/bizar/tests/reasoning-clean.test.ts plugins/bizar/tests/key-rotation.test.ts && node bizar-dash/tests/smoke-v2.mjs && node --test bizar-dash/tests/path-safe.test.mjs bizar-dash/tests/tmux-wrap.test.mjs bizar-dash/tests/cline-sessions-detail.test.mjs bizar-dash/tests/cline-runner.test.mjs bizar-dash/tests/mod-instructions.node.test.mjs bizar-dash/tests/mod-upgrade.node.test.mjs bizar-dash/tests/graphify-mod-spawn.node.test.mjs bizar-dash/tests/no-agent-browser.node.test.mjs bizar-dash/tests/providers-store-backup-keys.node.test.mjs bizar-dash/tests/dashboard-ports.test.mjs bizar-dash/tests/submit-feedback.test.mjs bizar-dash/tests/yaml.test.mjs bizar-dash/tests/memory-store.test.mjs bizar-dash/tests/memory-schema.test.mjs bizar-dash/tests/memory-secrets.test.mjs bizar-dash/tests/memory-git.test.mjs bizar-dash/tests/memory-sync.test.mjs bizar-dash/tests/obsidian-back-compat.test.mjs bizar-dash/tests/memory-lightrag.test.mjs bizar-dash/tests/memory-config.test.mjs bizar-dash/tests/memory-cli.test.mjs bizar-dash/tests/memory-cli-readlistdelete.test.mjs bizar-dash/tests/memory-cli-setup.test.mjs bizar-dash/tests/memory-conflicts.test.mjs bizar-dash/tests/memory-namespace.test.mjs bizar-dash/tests/memory-path-safety.test.mjs bizar-dash/tests/memory-protocol-drift.test.mjs bizar-dash/tests/memory-roundtrip.test.mjs bizar-dash/tests/cli-bugfixes.test.mjs bizar-dash/tests/server-bugfixes.test.mjs bizar-dash/tests/frontend-bugfixes.test.mjs",
28
28
  "build": "npm run build:sdk && npm run build:dash",
29
29
  "prepublishOnly": "npm run build"
30
30
  },
@@ -1,14 +1,19 @@
1
1
  {
2
2
  "name": "@polderlabs/bizar-plugin",
3
- "version": "6.2.2",
3
+ "version": "6.2.3",
4
4
  "description": "Bizar Norse-pantheon multi-agent plugin for Cline — 14 agents across 4 cost tiers with cost-aware routing and plans.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
7
7
  "cline": {
8
8
  "plugins": [
9
9
  {
10
- "paths": ["./index.ts"],
11
- "capabilities": ["tools", "hooks"]
10
+ "paths": [
11
+ "./index.ts"
12
+ ],
13
+ "capabilities": [
14
+ "tools",
15
+ "hooks"
16
+ ]
12
17
  }
13
18
  ]
14
19
  },
@@ -21,8 +26,14 @@
21
26
  "@cline/shared": "*"
22
27
  },
23
28
  "peerDependenciesMeta": {
24
- "@cline/sdk": { "optional": true },
25
- "@cline/core": { "optional": true },
26
- "@cline/shared": { "optional": true }
29
+ "@cline/sdk": {
30
+ "optional": true
31
+ },
32
+ "@cline/core": {
33
+ "optional": true
34
+ },
35
+ "@cline/shared": {
36
+ "optional": true
37
+ }
27
38
  }
28
39
  }
@@ -160,7 +160,7 @@ export class ClineRuntime {
160
160
  workspaceRoot: opts.workspaceRoot,
161
161
  cwd: opts.workspaceRoot,
162
162
  enableTools: true,
163
- enableSpawnAgent: false,
163
+ enableSpawnAgent: true,
164
164
  enableAgentTeams: true,
165
165
  ...(execution ? { execution } : {}),
166
166
  ...(recovery ? { onConsecutiveMistakeLimitReached: recovery } : {}),
@@ -180,7 +180,7 @@ describe("ClineRuntime.startSession — agent teams plumbing (v6.2.0)", () => {
180
180
  expect(cfg.enableAgentTeams).toBe(true);
181
181
  });
182
182
 
183
- test("enableTools is true, enableSpawnAgent is false", async () => {
183
+ test("enableTools is true, enableSpawnAgent is true (subagents + task tool)", async () => {
184
184
  const runtime = new ClineRuntime({ logger: stubLogger() });
185
185
  (runtime as unknown as { core: unknown }).core = makeFakeCore();
186
186
  await runtime.startSession({
@@ -191,7 +191,8 @@ describe("ClineRuntime.startSession — agent teams plumbing (v6.2.0)", () => {
191
191
  });
192
192
  const cfg = capturedConfig[0]!.config;
193
193
  expect(cfg.enableTools).toBe(true);
194
- expect(cfg.enableSpawnAgent).toBe(false);
194
+ expect(cfg.enableSpawnAgent).toBe(true,
195
+ "enableSpawnAgent must be true so Odin can use the `task` tool and `use_subagents`");
195
196
  });
196
197
 
197
198
  test("all three boolean flags survive even when caller passes no opts", async () => {
@@ -206,7 +207,27 @@ describe("ClineRuntime.startSession — agent teams plumbing (v6.2.0)", () => {
206
207
  const cfg = capturedConfig[0]!.config;
207
208
  expect(cfg.enableAgentTeams).toBe(true);
208
209
  expect(cfg.enableTools).toBe(true);
209
- expect(cfg.enableSpawnAgent).toBe(false);
210
+ expect(cfg.enableSpawnAgent).toBe(true,
211
+ "v6.2.3 — subagents + task tool must be available even without caller opts");
212
+ });
213
+ });
214
+
215
+ describe("ClineRuntime.startSession — subagents plumbing (v6.2.3)", () => {
216
+ test("enableSpawnAgent regression: must NEVER be false (v6.2.3 fix)", async () => {
217
+ // v6.2.3 flipped enableSpawnAgent from false → true. Before this
218
+ // change, Odin could not use the `task` tool or `use_subagents`,
219
+ // which silently broke subagent delegation. This test pins the fix.
220
+ const runtime = new ClineRuntime({ logger: stubLogger() });
221
+ (runtime as unknown as { core: unknown }).core = makeFakeCore();
222
+ await runtime.startSession({
223
+ providerId: "anthropic",
224
+ modelId: "claude-sonnet-4-6",
225
+ workspaceRoot: "/tmp",
226
+ prompt: "go",
227
+ });
228
+ const cfg = capturedConfig[0]!.config;
229
+ expect(cfg.enableSpawnAgent).not.toBe(false);
230
+ expect(cfg.enableSpawnAgent).toBe(true);
210
231
  });
211
232
  });
212
233