@polderlabs/bizar 6.0.2 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,279 @@
1
+ /**
2
+ * cli/commands/validate.test.mjs
3
+ *
4
+ * v6.2.0 — Unit tests for the new `bizar validate` subcommand.
5
+ *
6
+ * We exercise `runValidate()` directly. CLINE_DIR is mocked via
7
+ * `process.env.CLINE_DIR`; a minimal-but-complete Bizar install is
8
+ * staged inside a tmpdir. We then progressively delete files to
9
+ * verify each check fails with a sensible message.
10
+ */
11
+ import { test, describe, beforeEach, afterEach } from 'node:test';
12
+ import assert from 'node:assert/strict';
13
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from 'node:fs';
14
+ import { tmpdir } from 'node:os';
15
+ import { join } from 'node:path';
16
+ import { Writable } from 'node:stream';
17
+
18
+ const { runValidate } = await import('./validate.mjs');
19
+
20
+ const ORIG_CLINE_DIR = process.env.CLINE_DIR;
21
+ const ORIG_HOME = process.env.HOME;
22
+
23
+ let workDir;
24
+
25
+ function freshWorkDir() {
26
+ const root = mkdtempSync(join(tmpdir(), 'bizar-validate-'));
27
+ process.env.CLINE_DIR = root;
28
+ return root;
29
+ }
30
+
31
+ function makeFakeClineInstall(root) {
32
+ writeFileSync(
33
+ join(root, 'cline.json'),
34
+ JSON.stringify({
35
+ $schema: 'https://docs.cline.bot/config.json',
36
+ plugin: [[
37
+ './plugins/bizar',
38
+ { loopThresholdWarn: 5, loopThresholdEscalate: 8, loopThresholdBlock: 12, loopWindowSize: 10 },
39
+ ]],
40
+ default_agent: 'odin',
41
+ permission: 'allow',
42
+ snapshot: false,
43
+ instructions: ['.cline/instructions/bizar-tools.md'],
44
+ provider: {
45
+ '9router': {
46
+ baseUrl: 'http://localhost:20128/v1',
47
+ apiKey: '${env:NINEROUTER_KEY}',
48
+ models: { 'minimax/MiniMax-M3': { reasoning: true } },
49
+ },
50
+ minimax: { models: {} },
51
+ },
52
+ }, null, 2),
53
+ );
54
+ const agentsDir = join(root, 'agents');
55
+ mkdirSync(agentsDir, { recursive: true });
56
+ for (const f of [
57
+ 'odin.md', 'vor.md', 'frigg.md', 'quick.md',
58
+ 'mimir.md', 'heimdall.md', 'hermod.md', 'thor.md', 'baldr.md',
59
+ 'tyr.md', 'vidarr.md', 'forseti.md',
60
+ 'semble-search.md', 'agent-browser.md',
61
+ ]) {
62
+ writeFileSync(join(agentsDir, f), '# fake agent');
63
+ }
64
+ writeFileSync(join(agentsDir, 'odin.yaml'), 'name: odin\ndescription: odin\n');
65
+ const cmdsDir = join(root, 'commands');
66
+ mkdirSync(cmdsDir, { recursive: true });
67
+ for (const f of [
68
+ 'audit.md', 'bizar.md', 'explain.md', 'init.md', 'learn.md',
69
+ 'plan.md', 'plow-through.md', 'pr-review.md', 'tailscale-serve.md',
70
+ 'visual-plan.md',
71
+ 'team.md', 'test.md', 'validate.md',
72
+ ]) {
73
+ writeFileSync(join(cmdsDir, f), '# fake command');
74
+ }
75
+ const skillsDir = join(root, 'skills');
76
+ mkdirSync(skillsDir, { recursive: true });
77
+ for (const skill of ['9router', 'bizar', 'obsidian']) {
78
+ mkdirSync(join(skillsDir, skill), { recursive: true });
79
+ writeFileSync(join(skillsDir, skill, 'SKILL.md'), '# fake skill');
80
+ }
81
+ const rulesDir = join(root, 'rules');
82
+ mkdirSync(rulesDir, { recursive: true });
83
+ for (const r of ['general.md', 'git.md', 'javascript.md', 'python.md', 'testing.md', 'thinking.md', 'uncertainty.md']) {
84
+ writeFileSync(join(rulesDir, r), '# fake rule');
85
+ }
86
+ const hooksDir = join(root, 'hooks');
87
+ mkdirSync(hooksDir, { recursive: true });
88
+ for (const h of ['pre-tool-use.md', 'post-tool-use.md', 'README.md']) {
89
+ writeFileSync(join(hooksDir, h), '# fake hook');
90
+ }
91
+ const pluginDir = join(root, 'plugins', 'bizar');
92
+ mkdirSync(pluginDir, { recursive: true });
93
+ writeFileSync(join(pluginDir, 'index.ts'), '// fake plugin entry — >100 bytes to pass size check\n'.repeat(10));
94
+ mkdirSync(join(pluginDir, 'src'), { recursive: true });
95
+ writeFileSync(
96
+ join(pluginDir, 'src', 'clineruntime.ts'),
97
+ 'export class ClineRuntime { config = { enableAgentTeams: true }; }\n',
98
+ );
99
+ // node_modules with the required runtime deps
100
+ const nmDir = join(pluginDir, 'node_modules');
101
+ mkdirSync(nmDir, { recursive: true });
102
+ mkdirSync(join(nmDir, 'zod'), { recursive: true });
103
+ writeFileSync(join(nmDir, 'zod', 'package.json'), '{}');
104
+ mkdirSync(join(nmDir, '@cline'), { recursive: true });
105
+ for (const dep of ['@cline/sdk', '@cline/core', '@cline/shared']) {
106
+ mkdirSync(join(nmDir, dep), { recursive: true });
107
+ writeFileSync(join(nmDir, dep, 'package.json'), '{}');
108
+ }
109
+ }
110
+
111
+ beforeEach(() => {
112
+ workDir = freshWorkDir();
113
+ makeFakeClineInstall(workDir);
114
+ });
115
+
116
+ afterEach(() => {
117
+ if (workDir && existsSync(workDir)) rmSync(workDir, { recursive: true, force: true });
118
+ if (ORIG_CLINE_DIR === undefined) delete process.env.CLINE_DIR;
119
+ else process.env.CLINE_DIR = ORIG_CLINE_DIR;
120
+ if (ORIG_HOME === undefined) delete process.env.HOME;
121
+ else process.env.HOME = ORIG_HOME;
122
+ });
123
+
124
+ /**
125
+ * Capture stdout + exit code for a single runValidate() call.
126
+ * The test runner writes its own progress to stdout, so we swap
127
+ * process.stdout for a sink Writable during the call.
128
+ */
129
+ async function captureRun(opts) {
130
+ const captured = [];
131
+ const sink = new Writable({
132
+ write(chunk, _enc, cb) {
133
+ captured.push(Buffer.from(chunk));
134
+ cb();
135
+ },
136
+ });
137
+ const origStdoutDescriptor = Object.getOwnPropertyDescriptor(process, 'stdout');
138
+ const origExit = process.exit;
139
+ let exitCode = null;
140
+ Object.defineProperty(process, 'stdout', { value: sink, configurable: true, writable: true });
141
+ process.exit = (code) => { exitCode = code; };
142
+ try {
143
+ await runValidate(opts);
144
+ } finally {
145
+ if (origStdoutDescriptor) Object.defineProperty(process, 'stdout', origStdoutDescriptor);
146
+ process.exit = origExit;
147
+ }
148
+ return { stdout: Buffer.concat(captured).toString('utf8'), exitCode };
149
+ }
150
+
151
+ describe('runValidate() — JSON output', () => {
152
+ test('returns structured JSON with passed/failed/results', async () => {
153
+ const { stdout, exitCode } = await captureRun({ json: true });
154
+ assert.equal(exitCode, 0, 'should exit 0 on success');
155
+ const parsed = JSON.parse(stdout);
156
+ assert.equal(typeof parsed.passed, 'number');
157
+ assert.equal(typeof parsed.failed, 'number');
158
+ assert.ok(Array.isArray(parsed.results));
159
+ assert.ok(parsed.results.length >= 18, 'expected at least 18 checks');
160
+ for (const r of parsed.results) {
161
+ assert.ok(typeof r.name === 'string');
162
+ assert.ok(typeof r.ok === 'boolean');
163
+ assert.ok(typeof r.message === 'string');
164
+ }
165
+ });
166
+
167
+ test('fails on missing team command', async () => {
168
+ rmSync(join(workDir, 'commands', 'team.md'));
169
+ const { exitCode } = await captureRun({ json: true });
170
+ assert.equal(exitCode, 1, 'should exit 1 on team-command-present failure');
171
+ });
172
+
173
+ test('fails on missing test command', async () => {
174
+ rmSync(join(workDir, 'commands', 'test.md'));
175
+ const { exitCode } = await captureRun({ json: true });
176
+ assert.equal(exitCode, 1);
177
+ });
178
+
179
+ test('fails on missing validate command', async () => {
180
+ rmSync(join(workDir, 'commands', 'validate.md'));
181
+ const { exitCode } = await captureRun({ json: true });
182
+ assert.equal(exitCode, 1);
183
+ });
184
+
185
+ test('fails on missing agent file', async () => {
186
+ rmSync(join(workDir, 'agents', 'mimir.md'));
187
+ const { exitCode } = await captureRun({ json: true });
188
+ assert.equal(exitCode, 1);
189
+ });
190
+
191
+ test('fails when cline.json missing', async () => {
192
+ rmSync(join(workDir, 'cline.json'));
193
+ const { exitCode } = await captureRun({ json: true });
194
+ assert.equal(exitCode, 1);
195
+ });
196
+
197
+ test('fails when enableAgentTeams is false in clineruntime.ts', async () => {
198
+ writeFileSync(
199
+ join(workDir, 'plugins', 'bizar', 'src', 'clineruntime.ts'),
200
+ 'export class ClineRuntime { config = { enableAgentTeams: false }; }\n',
201
+ );
202
+ const { exitCode } = await captureRun({ json: true });
203
+ assert.equal(exitCode, 1, 'should fail when enableAgentTeams is false');
204
+ });
205
+
206
+ test('fails when provider config is missing', async () => {
207
+ const cfg = JSON.parse(readFileSync(join(workDir, 'cline.json'), 'utf8'));
208
+ delete cfg.provider;
209
+ writeFileSync(join(workDir, 'cline.json'), JSON.stringify(cfg, null, 2));
210
+ const { exitCode } = await captureRun({ json: true });
211
+ assert.equal(exitCode, 1);
212
+ });
213
+
214
+ test('passes when only 9router is configured (no minimax)', async () => {
215
+ const cfg = JSON.parse(readFileSync(join(workDir, 'cline.json'), 'utf8'));
216
+ delete cfg.provider.minimax;
217
+ writeFileSync(join(workDir, 'cline.json'), JSON.stringify(cfg, null, 2));
218
+ const { exitCode } = await captureRun({ json: true });
219
+ assert.equal(exitCode, 0);
220
+ });
221
+
222
+ test('passes when only minimax is configured (no 9router)', async () => {
223
+ const cfg = JSON.parse(readFileSync(join(workDir, 'cline.json'), 'utf8'));
224
+ delete cfg.provider['9router'];
225
+ writeFileSync(join(workDir, 'cline.json'), JSON.stringify(cfg, null, 2));
226
+ const { exitCode } = await captureRun({ json: true });
227
+ assert.equal(exitCode, 0);
228
+ });
229
+
230
+ test('default mode is lenient on 9router-unreachable', async () => {
231
+ const { stdout, exitCode } = await captureRun({ json: true });
232
+ const parsed = JSON.parse(stdout);
233
+ const nineR = parsed.results.find((r) => r.name === '9router-reachable');
234
+ assert.ok(nineR, '9router-reachable check should be present');
235
+ if (!nineR.ok) {
236
+ // 9router is not running. Default mode must be lenient.
237
+ assert.equal(exitCode, 0, 'default mode should be lenient when 9router is down');
238
+ } else {
239
+ // 9router is running (some test envs have it). Just confirm the
240
+ // check is in the list and exit code is sane.
241
+ assert.equal(exitCode, 0, 'expected exit 0 when all critical checks pass');
242
+ }
243
+ });
244
+
245
+ test('--strict fails when 9router is unreachable', async () => {
246
+ // We can't easily kill 9router in a test, but we can prove that
247
+ // --strict mode would fail if 9router were down: set NINEROUTER_URL
248
+ // to a port nothing is listening on.
249
+ const origUrl = process.env.NINEROUTER_URL;
250
+ process.env.NINEROUTER_URL = 'http://127.0.0.1:1'; // nothing here
251
+ try {
252
+ const { exitCode } = await captureRun({ json: true, strict: true });
253
+ assert.equal(exitCode, 1, 'strict mode should fail when 9router is unreachable');
254
+ } finally {
255
+ if (origUrl === undefined) delete process.env.NINEROUTER_URL;
256
+ else process.env.NINEROUTER_URL = origUrl;
257
+ }
258
+ });
259
+
260
+ test('--only filters to a single check', async () => {
261
+ const { stdout, exitCode } = await captureRun({ json: true, only: 'team-command-present' });
262
+ assert.equal(exitCode, 0);
263
+ const parsed = JSON.parse(stdout);
264
+ assert.equal(parsed.results.length, 1);
265
+ assert.equal(parsed.results[0].name, 'team-command-present');
266
+ assert.equal(parsed.results[0].ok, true);
267
+ });
268
+
269
+ test('--only on a missing file fails with exit 1', async () => {
270
+ rmSync(join(workDir, 'commands', 'team.md'));
271
+ const { exitCode } = await captureRun({ json: true, only: 'team-command-present' });
272
+ assert.equal(exitCode, 1);
273
+ });
274
+
275
+ test('--only with an unknown name exits 2', async () => {
276
+ const { exitCode } = await captureRun({ json: true, only: 'this-does-not-exist' });
277
+ assert.equal(exitCode, 2);
278
+ });
279
+ });
package/cli/install.mjs CHANGED
@@ -8,9 +8,10 @@ import { fileURLToPath } from 'node:url';
8
8
  // `__dirname` is a CommonJS global. ESM modules don't have it. We define it
9
9
  // at module scope so all functions in this file can use it without each
10
10
  // having to recreate the polyfill. (v4.2.3 — previously only `runInstaller`
11
- // had it, which caused `promptAndInstallOptional` to crash with
12
- // `ERR_AMBIGUOUS_MODULE_SYNTAX` when the bootstrap path fired on a fresh
13
- // install.)
11
+ // had it, which caused the legacy `promptAndInstallOptional` bootstrap
12
+ // to crash with `ERR_AMBIGUOUS_MODULE_SYNTAX`. v6.1.0 that helper
13
+ // has been removed; `__dirname` is still used by `runPostInstall` for
14
+ // the same reason.)
14
15
  const __dirname = dirname(fileURLToPath(import.meta.url));
15
16
 
16
17
  import { showBanner, showPantheon, sectionHeading } from './banner.mjs';
@@ -302,39 +303,6 @@ async function isPackageInstalled(name) {
302
303
  }
303
304
  }
304
305
 
305
- async function promptAndInstallOptional() {
306
- // v4.4.5 — Plugin and dashboard are both shipped inside this package.
307
- // No separate npm install step is required for either. The plugin
308
- // copy happens via installPluginFromGlobal() in runInstaller(); the
309
- // dashboard is loaded directly from this package's bizar-dash/src/.
310
- // We just verify they're present here and warn loudly if not.
311
- const pluginPath = join(__dirname, '..', 'plugins', 'bizar');
312
- if (!existsSync(pluginPath)) {
313
- console.error('');
314
- console.error(' ✗ Plugin source not found at plugins/bizar/.');
315
- console.error(' This package is missing the plugin source. Reinstall:');
316
- console.error(' npm install -g @polderlabs/bizar --force');
317
- } else {
318
- console.log(' ✓ Plugin source present (plugins/bizar/)');
319
- }
320
-
321
- const dashDir = join(__dirname, '..', 'bizar-dash');
322
- // v6.0.2 — Dashboard shipped as a resource directory since v4.0.0.
323
- // It no longer carries its own `package.json` (it became a separate
324
- // `@polderlabs/bizar-dash` npm package, then was collapsed back in
325
- // for v6.x). Probe for the built artifact + server source instead.
326
- const dashDistHtml = join(dashDir, 'dist', 'index.html');
327
- const dashServerSrc = join(dashDir, 'src', 'server', 'api.mjs');
328
- if (!existsSync(dashDistHtml) && !existsSync(dashServerSrc)) {
329
- console.error('');
330
- console.error(' ✗ Dashboard source not found at bizar-dash/.');
331
- console.error(' This package is missing the dashboard source. Reinstall:');
332
- console.error(' npm install -g @polderlabs/bizar --force');
333
- } else {
334
- console.log(' ✓ Dashboard source present (bizar-dash/)');
335
- }
336
- }
337
-
338
306
  async function promptGraphifyInstall() {
339
307
  const { spawnSync, execSync } = await import('node:child_process');
340
308
 
@@ -511,10 +479,19 @@ async function promptGraphifyInstall() {
511
479
  }
512
480
 
513
481
  export async function runPostInstall() {
514
- // Skip interactive prompts in CI / non-TTY environments
515
- if (!process.env.BIZAR_SKIP_OPTIONAL_INSTALLS) {
516
- await promptAndInstallOptional();
517
- }
482
+ // v6.1.0 Bizar is Cline-only. The legacy opencode-era
483
+ // `promptAndInstallOptional()` (plugin/dashboard presence probes)
484
+ // has been removed; `cli/provision.mjs:runProvision` covers the same
485
+ // surface via `runProvision({ mode: 'install' })`, which is the path
486
+ // used by `bizar install` and `bizar update`. This `runPostInstall`
487
+ // is now a thin Cline-only bootstrap that:
488
+ // - copies `config/cline.json` template on first install
489
+ // - runs `installCommandsBizar()` for the legacy `commands-bizar/` dir
490
+ // - installs agents into `~/.cline/agents/`
491
+ // - probes for headroom / semble / skills-cli
492
+ // - installs Headroom via pip or npm
493
+ // The old OpenCode-era probes (`Plugin source present`,
494
+ // `Dashboard source present`) moved to `cli/doctor.mjs` as live checks.
518
495
  const { mkdirSync, copyFileSync, existsSync } = await import('node:fs');
519
496
  const { execSync } = await import('node:child_process');
520
497
 
@@ -6,8 +6,10 @@
6
6
  * Verifies the installation is functional by checking:
7
7
  * 1. Memory vault exists and is git-initialized
8
8
  * 2. `bizar doctor` exits 0
9
- * 3. `bizar bg list` returns (even if empty)
10
- * 4. lightrag-server installed
9
+ * 3. Dashboard HTTP responds 200
10
+ * 4. WebSocket connects to dashboard
11
+ * 5. `bizar bg list` returns (even if empty)
12
+ * 6. lightrag-server installed
11
13
  *
12
14
  * Each check has a 30s timeout. Exits 0 if all pass, 1 otherwise.
13
15
  *
@@ -27,6 +29,7 @@ const DEFAULT_TIMEOUT_MS = 30_000;
27
29
  const HOME = homedir();
28
30
  const DEFAULT_BIZAR_HOME = join(HOME, '.config', 'bizar');
29
31
  const DEFAULT_MEMORY_VAULT = join(HOME, '.bizar_memory');
32
+ const DEFAULT_DASHBOARD_PORT = 4097;
30
33
 
31
34
  /**
32
35
  * @param {object} opts
@@ -37,6 +40,7 @@ const DEFAULT_MEMORY_VAULT = join(HOME, '.bizar_memory');
37
40
  export async function runSmokeTest({ bizarHome, repoPath, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
38
41
  const bh = bizarHome || DEFAULT_BIZAR_HOME;
39
42
  const mv = process.env.BIZAR_MEMORY_VAULT || DEFAULT_MEMORY_VAULT;
43
+ const port = parseInt(process.env.BIZAR_DASHBOARD_PORT || String(DEFAULT_DASHBOARD_PORT), 10) || DEFAULT_DASHBOARD_PORT;
40
44
  const checks = [];
41
45
 
42
46
  function check(name, fn) {
@@ -77,7 +81,96 @@ export async function runSmokeTest({ bizarHome, repoPath, timeoutMs = DEFAULT_TI
77
81
  }
78
82
  });
79
83
 
80
- // ── 3. `bizar bg list` returns ────────────────────────────────────────────
84
+ // ── 3. Dashboard HTTP responds 200 (with retry) ─────────────────────────────────
85
+ check('dashboard HTTP', async () => {
86
+ const maxAttempts = 5;
87
+ const delayMs = 2000;
88
+ let lastErr = null;
89
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
90
+ try {
91
+ const controller = new AbortController();
92
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
93
+ try {
94
+ const res = await fetch(`http://127.0.0.1:${port}/`, {
95
+ signal: controller.signal,
96
+ method: 'GET',
97
+ });
98
+ clearTimeout(timer);
99
+ if (res.ok) {
100
+ if (attempt > 1) {
101
+ return { ok: true, message: `HTTP ${res.status} on port ${port} (tried ${attempt}x)` };
102
+ }
103
+ return { ok: true, message: `HTTP ${res.status} on port ${port}` };
104
+ }
105
+ lastErr = `HTTP ${res.status} on port ${port} (expected 200)`;
106
+ } catch (err) {
107
+ clearTimeout(timer);
108
+ lastErr = err.name === 'AbortError'
109
+ ? `dashboard not responding on port ${port} (timeout)`
110
+ : `dashboard unreachable on port ${port}: ${err.message}`;
111
+ }
112
+ } catch (err) {
113
+ lastErr = `fetch not available: ${err.message}`;
114
+ }
115
+ if (attempt < maxAttempts) {
116
+ await new Promise((r) => setTimeout(r, delayMs));
117
+ }
118
+ }
119
+ return { ok: false, message: lastErr || 'dashboard HTTP failed' };
120
+ });
121
+
122
+ // ── 4. WebSocket connects (with retry) ────────────────────────────────────────
123
+ check('dashboard WebSocket', async () => {
124
+ const maxAttempts = 5;
125
+ const delayMs = 2000;
126
+ let lastErr = null;
127
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
128
+ try {
129
+ const { WebSocket } = await import('ws' in globalThis
130
+ ? { ws: globalThis.ws, WebSocket: globalThis.WebSocket }
131
+ : await import(`ws`).then(m => ({ ws: m, WebSocket: m.WebSocket || m.default }))
132
+ );
133
+ const url = `ws://127.0.0.1:${port}/ws`;
134
+ const controller = new AbortController();
135
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
136
+ try {
137
+ const ws = new WebSocket(url);
138
+ await new Promise((resolve, reject) => {
139
+ ws.on('open', resolve);
140
+ ws.on('error', reject);
141
+ ws.on('close', () => reject(new Error('closed before open')));
142
+ controller.signal.addEventListener('abort', () => {
143
+ ws.close();
144
+ reject(new Error('timeout'));
145
+ });
146
+ });
147
+ clearTimeout(timer);
148
+ ws.close();
149
+ if (attempt > 1) {
150
+ return { ok: true, message: `WebSocket connected at ${url} (tried ${attempt}x)` };
151
+ }
152
+ return { ok: true, message: `WebSocket connected at ${url}` };
153
+ } catch (err) {
154
+ clearTimeout(timer);
155
+ lastErr = err.message === 'timeout'
156
+ ? `WebSocket timeout on ${url}`
157
+ : `WebSocket failed on ${url}: ${err.message}`;
158
+ }
159
+ } catch (err) {
160
+ // ws module not available in this environment — skip
161
+ if (err.code === 'MODULE_NOT_FOUND' || err.message?.includes('ws')) {
162
+ return { ok: true, message: 'ws module not available — skipping WebSocket check' };
163
+ }
164
+ lastErr = `WebSocket check failed: ${err.message}`;
165
+ }
166
+ if (attempt < maxAttempts) {
167
+ await new Promise((r) => setTimeout(r, delayMs));
168
+ }
169
+ }
170
+ return { ok: false, message: lastErr || 'WebSocket failed' };
171
+ });
172
+
173
+ // ── 5. `bizar bg list` returns ────────────────────────────────────────────
81
174
  check('background agents', () => {
82
175
  try {
83
176
  execSync('bizar bg list', {
@@ -5,11 +5,9 @@
5
5
  *
6
6
  * Tests:
7
7
  * 1. lightrag-server check: file existence is verified
8
- * 2. lightrag: warns when not found in PATH or ~/.local/bin/
9
- *
10
- * Note: dashboard HTTP/WS smoke checks were removed in v6.0.0-beta.2
11
- * (the dashboard is optional and the checks were failing noisily on
12
- * systems that don't run it). See PROGRESS.md.
8
+ * 2. HTTP retry: retries on failure, succeeds when server eventually responds
9
+ * 3. WS retry: retries on failure, succeeds when server eventually responds
10
+ * 4. lightrag: warns when not found in PATH or ~/.local/bin/
13
11
  */
14
12
  import { test, describe, beforeEach, afterEach } from 'node:test';
15
13
  import assert from 'node:assert/strict';
package/cli/provision.mjs CHANGED
@@ -860,8 +860,46 @@ export async function patchClineJson({ dryRun, force }) {
860
860
  (p) => Array.isArray(p) && typeof p[0] === 'string' && p[0].includes('plugins/bizar'),
861
861
  );
862
862
 
863
- // Auto-add provider.minimax block if missing (v5.x — must happen
864
- // even when the plugin entry already exists, so always evaluate).
863
+ // v6.2.0 — Multi-patch installer. On every install/update, ensure the
864
+ // full Bizar config surface is present in cline.json:
865
+ //
866
+ // 1. provider.9router (preferred gateway since v6.0.1) — add if
867
+ // missing so the install is "complete" out of the box.
868
+ // 2. provider.minimax (legacy fallback) — kept for back-compat.
869
+ // 3. default_agent — set to "odin" if missing.
870
+ // 4. $schema — add if missing (helps editors validate).
871
+ // 5. instructions — point at .cline/instructions/bizar-tools.md if
872
+ // missing, so Cline loads the tool reference on every session.
873
+ // 6. plugin entry — add if missing (the critical one; without this
874
+ // the Bizar plugin never loads).
875
+ // 7. permissions — set to "allow" if missing.
876
+ // 8. snapshot — set to false if missing.
877
+ //
878
+ // All patches are additive and idempotent. We never overwrite a value
879
+ // the user has set; the `force` flag bypasses that protection for the
880
+ // plugin entry only.
881
+ let addedProvider = false;
882
+ let addedDefaultAgent = false;
883
+ let addedSchema = false;
884
+ let addedInstructions = false;
885
+ let addedPermissions = false;
886
+ let addedSnapshot = false;
887
+ let added9router = false;
888
+
889
+ if (!cfg.provider) cfg.provider = {};
890
+
891
+ // 1. provider.9router — preferred gateway.
892
+ if (!cfg.provider['9router'] && existsSync(join(REPO_ROOT, 'config', 'cline.json'))) {
893
+ try {
894
+ const tpl = JSON.parse(readFileSync(join(REPO_ROOT, 'config', 'cline.json'), 'utf8'));
895
+ if (tpl.provider && tpl.provider['9router']) {
896
+ cfg.provider['9router'] = tpl.provider['9router'];
897
+ added9router = true;
898
+ }
899
+ } catch { /* ignore template parse errors */ }
900
+ }
901
+
902
+ // 2. provider.minimax — legacy fallback.
865
903
  const DEFAULT_MINIMAX_BLOCK = {
866
904
  options: {
867
905
  baseURL: 'https://api.minimax.io/v1',
@@ -874,21 +912,59 @@ export async function patchClineJson({ dryRun, force }) {
874
912
  'MiniMax-M3-Reasoning': { name: 'MiniMax M3 Reasoning', interleaved: { field: 'reasoning_details' }, reasoning: true },
875
913
  },
876
914
  };
877
- let addedProvider = false;
878
- if (!cfg.provider) {
879
- cfg.provider = {};
880
- }
881
915
  if (!cfg.provider.minimax) {
882
916
  cfg.provider.minimax = DEFAULT_MINIMAX_BLOCK;
883
917
  addedProvider = true;
884
918
  }
885
919
 
886
- if (hasEntry && !force && !addedProvider) {
920
+ // 3. default_agent
921
+ if (!cfg.default_agent) {
922
+ cfg.default_agent = 'odin';
923
+ addedDefaultAgent = true;
924
+ }
925
+
926
+ // 4. $schema
927
+ if (!cfg.$schema) {
928
+ cfg.$schema = 'https://docs.cline.bot/config.json';
929
+ addedSchema = true;
930
+ }
931
+
932
+ // 5. instructions — point at the bundled Bizar tools reference.
933
+ if (!cfg.instructions || (Array.isArray(cfg.instructions) && cfg.instructions.length === 0)) {
934
+ cfg.instructions = ['.cline/instructions/bizar-tools.md'];
935
+ addedInstructions = true;
936
+ }
937
+
938
+ // 6. permission
939
+ if (!cfg.permission) {
940
+ cfg.permission = 'allow';
941
+ addedPermissions = true;
942
+ }
943
+
944
+ // 7. snapshot
945
+ if (typeof cfg.snapshot !== 'boolean') {
946
+ cfg.snapshot = false;
947
+ addedSnapshot = true;
948
+ }
949
+
950
+ const anyAdded = addedProvider || added9router || addedDefaultAgent || addedSchema
951
+ || addedInstructions || addedPermissions || addedSnapshot;
952
+
953
+ if (hasEntry && !force && !anyAdded) {
887
954
  return { ok: true, message: 'cline.json already has Bizar plugin entry' };
888
955
  }
889
956
 
890
957
  if (dryRun) {
891
- return { ok: true, message: `[dry-run] would patch cline.json with plugin entry${addedProvider ? ' + provider.minimax' : ''}` };
958
+ const added = [];
959
+ if (!hasEntry) added.push('plugin entry');
960
+ if (added9router) added.push('provider.9router');
961
+ if (addedProvider) added.push('provider.minimax');
962
+ if (addedDefaultAgent) added.push('default_agent');
963
+ if (addedSchema) added.push('$schema');
964
+ if (addedInstructions) added.push('instructions');
965
+ if (addedPermissions) added.push('permission');
966
+ if (addedSnapshot) added.push('snapshot');
967
+ return { ok: true, message: `[dry-run] would patch cline.json with: ${added.join(', ')}` };
892
968
  }
893
969
 
894
970
  if (!hasEntry) {
@@ -904,16 +980,26 @@ export async function patchClineJson({ dryRun, force }) {
904
980
  loopThresholdEscalate: 8,
905
981
  loopThresholdBlock: 12,
906
982
  loopWindowSize: 10,
983
+ clineruntimeMaxConsecutiveMistakes: 6,
907
984
  }]);
908
985
  cfg.plugin = plugins;
909
986
  }
910
987
 
911
988
  writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n');
989
+ const added = [];
990
+ if (!hasEntry) added.push('plugin entry');
991
+ if (added9router) added.push('provider.9router');
992
+ if (addedProvider) added.push('provider.minimax');
993
+ if (addedDefaultAgent) added.push('default_agent');
994
+ if (addedSchema) added.push('$schema');
995
+ if (addedInstructions) added.push('instructions');
996
+ if (addedPermissions) added.push('permission');
997
+ if (addedSnapshot) added.push('snapshot');
912
998
  return {
913
999
  ok: true,
914
- message: addedProvider
915
- ? 'cline.json patched with provider.minimax (plugin entry was already present)'
916
- : 'cline.json patched with Bizar plugin entry + provider.minimax',
1000
+ message: added.length > 0
1001
+ ? `cline.json patched: ${added.join(', ')}`
1002
+ : 'cline.json already complete',
917
1003
  };
918
1004
  }
919
1005