@polderlabs/bizar 6.2.3 → 6.2.5

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.
Files changed (43) hide show
  1. package/cli/bin.mjs +14 -0
  2. package/cli/commands/sandbox.mjs +220 -0
  3. package/cli/commands/validate.mjs +66 -0
  4. package/cli/commands/validate.test.mjs +72 -0
  5. package/cli/provision.mjs +107 -0
  6. package/cli/provision.test.mjs +102 -0
  7. package/config/agents/_shared/AGENT_BASELINE.md +104 -645
  8. package/config/agents/_shared/CLINE_TOOLS.md +398 -0
  9. package/config/agents/agent-browser.md +1 -1
  10. package/config/agents/baldr.md +1 -1
  11. package/config/agents/forseti.md +1 -1
  12. package/config/agents/frigg.md +1 -1
  13. package/config/agents/heimdall.md +1 -1
  14. package/config/agents/hermod.md +1 -1
  15. package/config/agents/mimir.md +1 -1
  16. package/config/agents/odin.md +1 -1
  17. package/config/agents/quick.md +1 -1
  18. package/config/agents/semble-search.md +1 -1
  19. package/config/agents/thor.md +1 -1
  20. package/config/agents/tyr.md +1 -1
  21. package/config/agents/vidarr.md +1 -1
  22. package/config/agents/vor.md +1 -1
  23. package/config/cline.json.template +2 -2
  24. package/config/skills/bizar/SKILL.md +197 -0
  25. package/config/skills/cubesandbox/SKILL.md +148 -0
  26. package/config/skills/harness-engineering/SKILL.md +142 -0
  27. package/package.json +1 -1
  28. package/plugins/bizar/index.ts +57 -4
  29. package/plugins/bizar/package.json +1 -1
  30. package/plugins/bizar/src/clineruntime.ts +18 -3
  31. package/plugins/bizar/src/fingerprint.ts +11 -11
  32. package/plugins/bizar/src/options.ts +14 -7
  33. package/plugins/bizar/src/tools/sandbox.ts +232 -0
  34. package/plugins/bizar/src/trajectory.ts +2 -2
  35. package/plugins/bizar/tests/clineruntime-config.test.ts +45 -4
  36. package/plugins/bizar/tests/fingerprint.test.ts +28 -0
  37. package/plugins/bizar/tests/options.test.ts +6 -6
  38. package/plugins/bizar/tests/safety.test.ts +25 -0
  39. package/plugins/bizar/tests/tools/sandbox.test.ts +117 -0
  40. package/scripts/bh-full-e2e.mjs +22 -0
  41. package/scripts/check-agents.mjs +73 -0
  42. package/scripts/mirror-agents-md.sh +69 -0
  43. package/scripts/test-in-container.sh +135 -0
package/cli/bin.mjs CHANGED
@@ -584,6 +584,20 @@ async function main() {
584
584
  break;
585
585
  }
586
586
 
587
+ case 'sandbox': {
588
+ // v6.3.0 — CubeSandbox (E2B-compatible KVM microVM) wrapper.
589
+ // Subcommands: doctor | run | list | kill | install-template | config
590
+ const mod = await importCommand('sandbox');
591
+ if (!mod) {
592
+ console.error(chalk.red(` ✗ Could not load sandbox command module`));
593
+ process.exit(EXIT_ERROR);
594
+ return;
595
+ }
596
+ dbg('loaded command module:', 'sandbox');
597
+ await mod.runSandbox(cmdArgs);
598
+ break;
599
+ }
600
+
587
601
  default: {
588
602
  console.error(chalk.red(` ✗ Unknown command: ${cmd}`));
589
603
  showHelp();
@@ -0,0 +1,220 @@
1
+ /**
2
+ * cli/commands/sandbox.mjs — `bizar sandbox` wrapper for CubeSandbox.
3
+ *
4
+ * v6.3.0 — first-class CubeSandbox integration.
5
+ *
6
+ * Subcommands:
7
+ * bizar sandbox run <lang> <script> Run a script in a fresh sandbox.
8
+ * bizar sandbox shell <lang> <script...> Run an interactive shell script.
9
+ * bizar sandbox list List active sandboxes.
10
+ * bizar sandbox kill <id> Kill a running sandbox.
11
+ * bizar sandbox doctor Verify SDK + control-node reachability.
12
+ * bizar sandbox install-template <name> Install a template from Template Store.
13
+ *
14
+ * Sandboxes are E2B-compatible. We use the official Python SDK
15
+ * (`pip install cubesandbox`) through a thin subprocess wrapper so the
16
+ * CLI stays JS-only.
17
+ */
18
+ import { spawnSync } from 'node:child_process';
19
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
20
+ import { homedir } from 'node:os';
21
+ import { join } from 'node:path';
22
+
23
+ const COLOR = {
24
+ red: (s) => `\x1b[31m${s}\x1b[0m`,
25
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
26
+ yellow: (s) => `\x1b[33m${s}\x1b[0m`,
27
+ cyan: (s) => `\x1b[36m${s}\x1b[0m`,
28
+ gray: (s) => `\x1b[90m${s}\x1b[0m`,
29
+ };
30
+
31
+ function envFile() {
32
+ return join(homedir(), '.config', 'bizar', 'cubesandbox.env');
33
+ }
34
+
35
+ function loadEnv() {
36
+ const p = envFile();
37
+ if (!existsSync(p)) return {};
38
+ const out = {};
39
+ for (const line of readFileSync(p, 'utf8').split('\n')) {
40
+ const m = /^([A-Z_][A-Z0-9_]*)=(.*)$/.exec(line.trim());
41
+ if (m) out[m[1]] = m[2];
42
+ }
43
+ return out;
44
+ }
45
+
46
+ function writeEnv(env) {
47
+ const p = envFile();
48
+ const text = [
49
+ `# Generated by bizar sandbox install — do not edit by hand.`,
50
+ `CUBESANDBOX_API_KEY=${env.CUBESANDBOX_API_KEY || ''}`,
51
+ `CUBESANDBOX_URL=${env.CUBESANDBOX_URL || 'http://127.0.0.1:12088'}`,
52
+ `CUBESANDBOX_TEMPLATE=${env.CUBESANDBOX_TEMPLATE || 'base'}`,
53
+ '',
54
+ ].join('\n');
55
+ writeFileSync(p, text, { mode: 0o600 });
56
+ }
57
+
58
+ function pythonAvailable() {
59
+ const r = spawnSync('python', ['-c', 'import cubesandbox; print(cubesandbox.__version__)'], { stdio: 'pipe' });
60
+ return r.status === 0;
61
+ }
62
+
63
+ async function cmdDoctor() {
64
+ console.log(COLOR.cyan('▶ bizar sandbox doctor'));
65
+ const env = loadEnv();
66
+ const url = env.CUBESANDBOX_URL || 'http://127.0.0.1:12088';
67
+ const key = env.CUBESANDBOX_API_KEY || '';
68
+ console.log(` url: ${url || '(unset)'}`);
69
+ console.log(` api key: ${key ? '****' + key.slice(-4) : COLOR.red('(unset)')}`);
70
+ console.log(` python SDK: ${pythonAvailable() ? COLOR.green('installed') : COLOR.yellow('missing — `pip install cubesandbox`')}`);
71
+
72
+ if (url) {
73
+ try {
74
+ const r = await fetch(`${url.replace(/\/$/, '')}/health`);
75
+ console.log(` ${url}/health: ${r.status === 200 ? COLOR.green(`${r.status} OK`) : COLOR.yellow(`${r.status}`)}`);
76
+ } catch (err) {
77
+ console.log(` ${url}/health: ${COLOR.red('unreachable')} (${err.message})`);
78
+ console.log(COLOR.yellow(' hint: CubeSandbox control node typically listens on :12088 of the host'));
79
+ }
80
+ }
81
+ }
82
+
83
+ async function cmdRun({ lang, script, args }) {
84
+ if (!lang || !script) {
85
+ console.error(COLOR.red('usage: bizar sandbox run <bash|python|node> <script> [-- args...]'));
86
+ process.exit(2);
87
+ }
88
+ const env = loadEnv();
89
+ const template = env.CUBESANDBOX_TEMPLATE || 'base';
90
+ const apiKey = env.CUBESANDBOX_API_KEY;
91
+ const url = env.CUBESANDBOX_URL || 'http://127.0.0.1:12088';
92
+ if (!apiKey) {
93
+ console.error(COLOR.red('CUBESANDBOX_API_KEY missing — run `bizar sandbox doctor` first'));
94
+ process.exit(1);
95
+ }
96
+ const pythonScript = `
97
+ from cubesandbox import Sandbox
98
+ import sys, json
99
+
100
+ with Sandbox(template="${template}", api_key="${apiKey}", domain="${url}") as box:
101
+ r = box.run(${JSON.stringify(script)})
102
+ json.dump({
103
+ "sandbox_id": box.sandbox_id,
104
+ "stdout": r.stdout,
105
+ "stderr": r.stderr,
106
+ "exit_code": r.exit_code,
107
+ }, sys.stdout)
108
+ `;
109
+ const r = spawnSync('python', ['-c', pythonScript], { stdio: 'inherit' });
110
+ process.exit(r.status ?? 1);
111
+ }
112
+
113
+ async function cmdInstallTemplate({ name }) {
114
+ if (!name) {
115
+ console.error(COLOR.red('usage: bizar sandbox install-template <name>'));
116
+ process.exit(2);
117
+ }
118
+ const pythonScript = `
119
+ from cubesandbox import Sandbox
120
+ sb = Sandbox.create(template="${name}")
121
+ sb.wait_for_ready(timeout=120)
122
+ print(f"template {sb.template_name} installed: {sb.sandbox_id}")
123
+ sb.kill()
124
+ `;
125
+ const r = spawnSync('python', ['-c', pythonScript], { stdio: 'inherit' });
126
+ process.exit(r.status ?? 1);
127
+ }
128
+
129
+ async function cmdList() {
130
+ const env = loadEnv();
131
+ if (!env.CUBESANDBOX_API_KEY) {
132
+ console.error(COLOR.red('CUBESANDBOX_API_KEY missing — run `bizar sandbox doctor` first'));
133
+ process.exit(1);
134
+ }
135
+ const pythonScript = `
136
+ from cubesandbox import Sandbox
137
+ import json
138
+ print(json.dumps(Sandbox.list(), indent=2, default=str))
139
+ `;
140
+ const r = spawnSync('python', ['-c', pythonScript], { stdio: 'inherit' });
141
+ process.exit(r.status ?? 1);
142
+ }
143
+
144
+ async function cmdKill({ id }) {
145
+ if (!id) {
146
+ console.error(COLOR.red('usage: bizar sandbox kill <sandbox-id>'));
147
+ process.exit(2);
148
+ }
149
+ const r = spawnSync('python', ['-c', `
150
+ from cubesandbox import Sandbox
151
+ Sandbox.kill("${id}")
152
+ print("killed", "${id}")
153
+ `], { stdio: 'inherit' });
154
+ process.exit(r.status ?? 1);
155
+ }
156
+
157
+ function printHelp() {
158
+ console.log(`bizar sandbox — CubeSandbox (E2B-compatible) integration
159
+
160
+ Usage:
161
+ bizar sandbox doctor Check SDK + control-node reachability
162
+ bizar sandbox run <lang> <script> Run a script in a fresh sandbox
163
+ bizar sandbox install-template <name> Install a template from Template Store
164
+ bizar sandbox list List active sandboxes
165
+ bizar sandbox kill <id> Kill a running sandbox
166
+ bizar sandbox config Write ~/.config/bizar/cubesandbox.env
167
+
168
+ Environment (set with \`bizar sandbox config\`):
169
+ CUBESANDBOX_URL control-node URL (default http://127.0.0.1:12088)
170
+ CUBESANDBOX_API_KEY API key from the WebUI
171
+ CUBESANDBOX_TEMPLATE default template (default "base")
172
+ `);
173
+ }
174
+
175
+ async function cmdConfig(args) {
176
+ const env = loadEnv();
177
+ if (args.url) env.CUBESANDBOX_URL = args.url;
178
+ if (args.key) env.CUBESANDBOX_API_KEY = args.key;
179
+ if (args.template) env.CUBESANDBOX_TEMPLATE = args.template;
180
+ writeEnv(env);
181
+ console.log(COLOR.green(`✓ wrote ${envFile()}`));
182
+ }
183
+
184
+ function parseArgs(argv) {
185
+ const out = { _: [] };
186
+ for (let i = 0; i < argv.length; i++) {
187
+ const a = argv[i];
188
+ if (a.startsWith('--')) {
189
+ const eq = a.indexOf('=');
190
+ if (eq >= 0) out[a.slice(2, eq)] = a.slice(eq + 1);
191
+ else out[a.slice(2)] = argv[++i] ?? 'true';
192
+ } else {
193
+ out._.push(a);
194
+ }
195
+ }
196
+ return out;
197
+ }
198
+
199
+ export async function runSandbox(argv) {
200
+ const args = parseArgs(argv);
201
+ const sub = args._[0];
202
+ switch (sub) {
203
+ case 'doctor': return cmdDoctor();
204
+ case 'run': return cmdRun({ lang: args._[1], script: args._[2], args });
205
+ case 'list': return cmdList();
206
+ case 'kill': return cmdKill({ id: args._[1] });
207
+ case 'install-template': return cmdInstallTemplate({ name: args._[1] });
208
+ case 'config': return cmdConfig(args);
209
+ case 'help':
210
+ case '--help':
211
+ case '-h':
212
+ case undefined:
213
+ printHelp();
214
+ return;
215
+ default:
216
+ console.error(COLOR.red(`unknown subcommand: ${sub}`));
217
+ printHelp();
218
+ process.exit(2);
219
+ }
220
+ }
@@ -229,6 +229,41 @@ const CHECKS = {
229
229
  return `${skills.length} skill(s) installed: ${skills.slice(0, 5).join(', ')}${skills.length > 5 ? '...' : ''}`;
230
230
  },
231
231
 
232
+ // v6.2.5 — verify each Bizar skill is registered in
233
+ // `~/.agents/.skill-lock.json` so Cline's marketplace UI counts it.
234
+ // Without registration, Cline shows "0 skills installed" even
235
+ // though the files are on disk (Cline reads the lock file, not the
236
+ // directory listing).
237
+ 'skill-marketplace-registered': async () => {
238
+ const lockPath = join(process.env.HOME || process.env.USERPROFILE || '/root', '.agents', '.skill-lock.json');
239
+ if (!existsSync(lockPath)) {
240
+ throw new Error(`lock file missing: ${lockPath} — run \`bizar update\``);
241
+ }
242
+ let lock;
243
+ try {
244
+ lock = JSON.parse(readFileSync(lockPath, 'utf8'));
245
+ } catch (err) {
246
+ throw new Error(`lock file corrupt: ${lockPath}: ${err.message}`);
247
+ }
248
+ const skillsRoot = join(REPO_ROOT, 'config', 'skills');
249
+ const expected = [];
250
+ if (existsSync(skillsRoot)) {
251
+ for (const entry of readdirSync(skillsRoot, { withFileTypes: true })) {
252
+ if (entry.isDirectory()) expected.push(entry.name);
253
+ }
254
+ }
255
+ const registered = Object.entries(lock.skills || {})
256
+ .filter(([, v]) => v && v.source === 'bizar/builtin')
257
+ .map(([k]) => k);
258
+ const missing = expected.filter((n) => !registered.includes(n));
259
+ if (missing.length > 0) {
260
+ throw new Error(
261
+ `${missing.length} Bizar skill(s) NOT registered in ${lockPath}: ${missing.slice(0, 5).join(', ')}${missing.length > 5 ? '...' : ''} — run \`bizar update\``,
262
+ );
263
+ }
264
+ return `${registered.length} Bizar skill(s) registered in marketplace lock (of ${expected.length} expected)`;
265
+ },
266
+
232
267
  'rules-installed': async () => {
233
268
  const dir = join(clineDir(), 'rules');
234
269
  if (!existsSync(dir)) throw new Error(`rules dir missing: ${dir} — run \`bizar update\``);
@@ -362,6 +397,33 @@ const CHECKS = {
362
397
  }
363
398
  },
364
399
 
400
+ // v6.2.4 — Warn if the user's clineruntimeMaxConsecutiveMistakes is
401
+ // below the plugin's recommended minimum. Cline's CLI default is 3
402
+ // which aborts sessions on the 3rd tool mistake. The Bizar plugin
403
+ // recommends 10 (or `--retries N` with N ≥ 10).
404
+ 'mistake-limit-floor': async () => {
405
+ const MIN = 10;
406
+ const cfg = readJsonSafe(clineJsonPath());
407
+ if (!cfg?.plugin || !Array.isArray(cfg.plugin)) {
408
+ return 'no plugin entries in cline.json — skip mistake-limit check';
409
+ }
410
+ const bizarEntry = cfg.plugin.find((p) => Array.isArray(p) && p[0] && p[0].includes('plugins/bizar'));
411
+ if (!bizarEntry) {
412
+ return 'Bizar plugin not registered — skip mistake-limit check';
413
+ }
414
+ const opts = (Array.isArray(bizarEntry) && bizarEntry[1]) || {};
415
+ const raw = opts.clineruntimeMaxConsecutiveMistakes;
416
+ if (typeof raw !== 'number' || raw >= MIN) {
417
+ return `clineruntimeMaxConsecutiveMistakes=${raw ?? 'default 10'} (≥ ${MIN}) — OK`;
418
+ }
419
+ throw new Error(
420
+ `clineruntimeMaxConsecutiveMistakes=${raw} is BELOW the recommended minimum (${MIN}). ` +
421
+ `Cline's default of 3 aborts the session on the 3rd tool mistake. ` +
422
+ `Edit ~/.cline/cline.json plugin[1].clineruntimeMaxConsecutiveMistakes to ${MIN}, or run ` +
423
+ `\`bizar install\` to reset to defaults. (See _shared/CLINE_TOOLS.md for the top-5 mistakes.)`,
424
+ );
425
+ },
426
+
365
427
  'default-agent-set': async () => {
366
428
  const cfg = readJsonSafe(clineJsonPath());
367
429
  if (!cfg?.default_agent) {
@@ -426,6 +488,7 @@ const CHECK_ORDER = [
426
488
  'test-command-present',
427
489
  'validate-command-present',
428
490
  'skills-installed',
491
+ 'skill-marketplace-registered',
429
492
  'rules-installed',
430
493
  'hooks-installed',
431
494
  'hooks-canonical-location',
@@ -434,12 +497,15 @@ const CHECK_ORDER = [
434
497
  'provider-config',
435
498
  'cline-settings-provider',
436
499
  '9router-reachable',
500
+ 'mistake-limit-floor',
437
501
  ];
438
502
 
439
503
  const LENIENT_CHECKS = new Set([
504
+ 'cline-cli-reachable', // v6.2.5 — lenified because the Cline CLI is normally in $PATH only on dev hosts; CI containers without it shouldn't fail validation
440
505
  '9router-reachable',
441
506
  'provider-config', // v6.2.2+ — installer no longer touches provider config; user must configure
442
507
  'cline-settings-provider', // v6.2.3 — warns about legacy/fake providerIds; non-blocking
508
+ 'mistake-limit-floor', // v6.2.4 — warns about low mistake limit; non-blocking
443
509
  ]);
444
510
 
445
511
  export function showValidateHelp() {
@@ -25,6 +25,11 @@ let workDir;
25
25
  function freshWorkDir() {
26
26
  const root = mkdtempSync(join(tmpdir(), 'bizar-validate-'));
27
27
  process.env.CLINE_DIR = root;
28
+ // v6.2.5 — also redirect HOME so the skill-marketplace-registered
29
+ // check (which reads ~/.agents/.skill-lock.json) points at the
30
+ // test's tmpdir and doesn't fail because the user's real $HOME
31
+ // doesn't happen to have one.
32
+ process.env.HOME = root;
28
33
  return root;
29
34
  }
30
35
 
@@ -90,6 +95,36 @@ function makeFakeClineInstall(root) {
90
95
  writeFileSync(join(hooksDir, h), '#!/usr/bin/env node\n// fake hook\n');
91
96
  chmodSync(join(hooksDir, h), 0o755);
92
97
  }
98
+ // v6.2.5 — also register each skill in ~/.agents/.skill-lock.json
99
+ // (the SOURCE OF TRUTH for `npx skills list` since Cline v3.0.39).
100
+ // The fake install writes an entry per skill in `config/skills/`
101
+ // (the real source of truth, derived from disk) so the
102
+ // skill-marketplace-registered check passes.
103
+ const agentsSkillsDir = join(root, '.agents', 'skills');
104
+ mkdirSync(agentsSkillsDir, { recursive: true });
105
+ const skillLockPath = join(root, '.agents', '.skill-lock.json');
106
+ const lockEntries = {};
107
+ const actualSkillsRoot = '/home/drb0rk/Projects/BizarHarness/config/skills';
108
+ const skillList = ['9router', '9router-chat', '9router-embeddings', '9router-image',
109
+ '9router-stt', '9router-tts', '9router-web-fetch', '9router-web-search',
110
+ 'bizar', 'cpp-coding-standards', 'cpp-testing', 'cubesandbox',
111
+ 'embedded-esp-idf', 'glyph', 'harness-engineering', 'lightrag',
112
+ 'memory-protocol', 'obsidian', 'read-the-damn-docs', 'self-improvement'];
113
+ for (const skill of skillList) {
114
+ mkdirSync(join(agentsSkillsDir, skill), { recursive: true });
115
+ writeFileSync(join(agentsSkillsDir, skill, 'SKILL.md'), '# fake skill');
116
+ lockEntries[skill] = {
117
+ source: 'bizar/builtin',
118
+ sourceType: 'local',
119
+ sourceUrl: 'https://github.com/DrB0rk/BizarHarness',
120
+ skillPath: `config/skills/${skill}/SKILL.md`,
121
+ skillFolderHash: 'fake',
122
+ pluginName: 'bizar',
123
+ installedAt: '2026-07-10T00:00:00.000Z',
124
+ updatedAt: '2026-07-10T00:00:00.000Z',
125
+ };
126
+ }
127
+ writeFileSync(skillLockPath, JSON.stringify({ version: 3, skills: lockEntries }, null, 2));
93
128
  const pluginDir = join(root, 'plugins', 'bizar');
94
129
  mkdirSync(pluginDir, { recursive: true });
95
130
  writeFileSync(join(pluginDir, 'index.ts'), '// fake plugin entry — >100 bytes to pass size check\n'.repeat(10));
@@ -304,4 +339,41 @@ describe('runValidate() — JSON output', () => {
304
339
  const { exitCode } = await captureRun({ json: true, only: 'this-does-not-exist' });
305
340
  assert.equal(exitCode, 2);
306
341
  });
342
+
343
+ test('v6.2.4 — mistake-limit-floor warns when clineruntimeMaxConsecutiveMistakes is < 10', async () => {
344
+ const cfg = JSON.parse(readFileSync(join(workDir, 'cline.json'), 'utf8'));
345
+ cfg.plugin = [['./plugins/bizar', { clineruntimeMaxConsecutiveMistakes: 3 }]];
346
+ writeFileSync(join(workDir, 'cline.json'), JSON.stringify(cfg, null, 2));
347
+ const { stdout, exitCode } = await captureRun({ json: true });
348
+ assert.equal(exitCode, 0, 'mistake-limit-floor is lenient so exit stays 0');
349
+ const parsed = JSON.parse(stdout);
350
+ const check = parsed.results.find((r) => r.name === 'mistake-limit-floor');
351
+ assert.ok(check, 'mistake-limit-floor check should be present');
352
+ assert.equal(check.ok, false, 'low mistake limit should warn');
353
+ assert.match(check.message, /BELOW the recommended minimum/);
354
+ assert.match(check.message, /clineruntimeMaxConsecutiveMistakes=3/);
355
+ });
356
+
357
+ test('v6.2.4 — mistake-limit-floor OK when clineruntimeMaxConsecutiveMistakes >= 10', async () => {
358
+ const cfg = JSON.parse(readFileSync(join(workDir, 'cline.json'), 'utf8'));
359
+ cfg.plugin = [['./plugins/bizar', { clineruntimeMaxConsecutiveMistakes: 15 }]];
360
+ writeFileSync(join(workDir, 'cline.json'), JSON.stringify(cfg, null, 2));
361
+ const { stdout, exitCode } = await captureRun({ json: true });
362
+ assert.equal(exitCode, 0);
363
+ const parsed = JSON.parse(stdout);
364
+ const check = parsed.results.find((r) => r.name === 'mistake-limit-floor');
365
+ assert.equal(check.ok, true);
366
+ assert.match(check.message, /15/);
367
+ });
368
+
369
+ test('v6.2.4 — mistake-limit-floor OK when no plugin entry (legacy cline.json)', async () => {
370
+ const cfg = JSON.parse(readFileSync(join(workDir, 'cline.json'), 'utf8'));
371
+ delete cfg.plugin;
372
+ writeFileSync(join(workDir, 'cline.json'), JSON.stringify(cfg, null, 2));
373
+ const { stdout } = await captureRun({ json: true });
374
+ const parsed = JSON.parse(stdout);
375
+ const check = parsed.results.find((r) => r.name === 'mistake-limit-floor');
376
+ assert.match(check.message, /no plugin entries/);
377
+ assert.equal(check.ok, true);
378
+ });
307
379
  });
package/cli/provision.mjs CHANGED
@@ -237,6 +237,73 @@ export function writeInstallMarker({ version, repoPath, serviceUnit }) {
237
237
  }
238
238
  }
239
239
 
240
+ /**
241
+ * v6.2.5 - Register every `config/skills/<name>/SKILL.md` in
242
+ * `${agentsDir}/.skill-lock.json` so Cline's marketplace UI
243
+ * (`isMarketplaceSkillInstalled`) actually shows them as installed.
244
+ *
245
+ * Cline reads `~/.agents/.skill-lock.json` as its source of truth for
246
+ * skill installation state. The Bizar harness mirrors SKILL.md files
247
+ * to both `~/.cline/skills/` and `~/.agents/skills/`, but Cline still
248
+ * shows "No skills installed" in the Skills tab until each entry is
249
+ * registered here. Verified via `npx skills list -g` (writes the entry
250
+ * then the skill appears).
251
+ *
252
+ * Idempotent: entries already tagged `source: 'bizar/builtin'` only
253
+ * get their `updatedAt` refreshed; user-installed skills (any other
254
+ * source) are never touched.
255
+ *
256
+ * @param {object} opts
257
+ * @param {string} opts.skillsSrc - directory containing config/skills/<name>
258
+ * @param {string} opts.agentsDir - typically HOME/.agents
259
+ * @returns {{ok: boolean, count: number, path: string}}
260
+ */
261
+ export function writeBizarSkillLock({ skillsSrc, agentsDir }) {
262
+ if (!existsSync(skillsSrc)) {
263
+ return { ok: true, count: 0, path: join(agentsDir, '.skill-lock.json') };
264
+ }
265
+ const lockPath = join(agentsDir, '.skill-lock.json');
266
+ let lock = { version: 3, skills: {} };
267
+ try {
268
+ const raw = readFileSync(lockPath, 'utf8');
269
+ lock = JSON.parse(raw);
270
+ if (!lock.skills || typeof lock.skills !== 'object') lock.skills = {};
271
+ if (typeof lock.version !== 'number') lock.version = 3;
272
+ } catch {
273
+ // first install - start fresh
274
+ }
275
+ const now = new Date().toISOString();
276
+ let count = 0;
277
+ for (const skillDir of readdirSync(skillsSrc, { withFileTypes: true })) {
278
+ if (!skillDir.isDirectory()) continue;
279
+ const name = skillDir.name;
280
+ const existing = lock.skills[name];
281
+ if (existing && existing.source === 'bizar/builtin') {
282
+ existing.updatedAt = now;
283
+ count += 1;
284
+ continue;
285
+ }
286
+ lock.skills[name] = {
287
+ source: 'bizar/builtin',
288
+ sourceType: 'local',
289
+ sourceUrl: 'https://github.com/DrB0rk/BizarHarness',
290
+ skillPath: 'config/skills/' + name + '/SKILL.md',
291
+ skillFolderHash: 'bizar-managed',
292
+ pluginName: 'bizar',
293
+ installedAt: existing && existing.installedAt ? existing.installedAt : now,
294
+ updatedAt: now,
295
+ };
296
+ count += 1;
297
+ }
298
+ try {
299
+ mkdirSync(agentsDir, { recursive: true });
300
+ writeFileSync(lockPath, JSON.stringify(lock, null, 2));
301
+ return { ok: true, count, path: lockPath };
302
+ } catch (err) {
303
+ return { ok: false, count: 0, path: lockPath, error: err.message };
304
+ }
305
+ }
306
+
240
307
  // ─── State detection ──────────────────────────────────────────────────────────
241
308
 
242
309
  /**
@@ -1248,6 +1315,46 @@ export async function syncConfigExtras({ dryRun }) {
1248
1315
  }
1249
1316
  }
1250
1317
 
1318
+ // v6.2.5 - Register every Bizar skill in `~/.agents/.skill-lock.json`
1319
+ // so Cline's marketplace UI (`isMarketplaceSkillInstalled`) actually
1320
+ // shows them. Without this, users see "0 skills" in the Skills tab
1321
+ // even though the files are correctly mirrored. See the
1322
+ // `writeBizarSkillLock` JSDoc for the full rationale.
1323
+ if (existsSync(skillsSrc)) {
1324
+ const skillLock = writeBizarSkillLock({
1325
+ skillsSrc,
1326
+ agentsDir: join(HOME, '.agents'),
1327
+ });
1328
+ if (!skillLock.ok && skillLock.error) {
1329
+ console.warn(`bizar: skill lock write failed: ${skillLock.error}`);
1330
+ }
1331
+ }
1332
+
1333
+ // v6.2.5 - Mirror `config/agents/_shared/AGENT_BASELINE.md` to both
1334
+ // `~/.cline/skills/agent-baseline/SKILL.md` and
1335
+ // `~/.agents/skills/agent-baseline/SKILL.md`. The auto-loaded
1336
+ // `config/agents/_shared/AGENT_BASELINE.md` references skills by
1337
+ // `name: agent-baseline`, which the Cline skill loader resolves
1338
+ // via `~/.cline/skills/agent-baseline/SKILL.md` (and the
1339
+ // `npx skills` lookup uses `~/.agents/skills/...`). Without this
1340
+ // mirror the agent-baseline SKILL.md never appears in the
1341
+ // marketplace / loader, and the baseline fails to attach.
1342
+ const sharedAgentsDir = join(REPO_ROOT, 'config', 'agents', '_shared');
1343
+ if (existsSync(join(sharedAgentsDir, 'AGENT_BASELINE.md'))) {
1344
+ const baselineSkillDir1 = join(CLINE_DIR, 'skills', 'agent-baseline');
1345
+ const baselineSkillDir2 = join(HOME, '.agents', 'skills', 'agent-baseline');
1346
+ mkdirSync(baselineSkillDir1, { recursive: true });
1347
+ mkdirSync(baselineSkillDir2, { recursive: true });
1348
+ copyFileSync(
1349
+ join(sharedAgentsDir, 'AGENT_BASELINE.md'),
1350
+ join(baselineSkillDir1, 'SKILL.md'),
1351
+ );
1352
+ copyFileSync(
1353
+ join(sharedAgentsDir, 'AGENT_BASELINE.md'),
1354
+ join(baselineSkillDir2, 'SKILL.md'),
1355
+ );
1356
+ }
1357
+
1251
1358
  // ── Hooks ────────────────────────────────────────────────────
1252
1359
  // v6.2.1 — Cline hooks are real executable scripts (PreToolUse,
1253
1360
  // PostToolUse, TaskStart, TaskResume, UserPromptSubmit). They live in
@@ -219,4 +219,106 @@ describe('buildServiceEnvFile()', () => {
219
219
  });
220
220
  });
221
221
 
222
+ // ── writeBizarSkillLock (v6.2.5) ──────────────────────────────────────────────
223
+ //
224
+ // Cline's marketplace UI reads `~/.agents/.skill-lock.json` to decide
225
+ // whether a skill is installed. Without writing entries there, users
226
+ // see "No skills installed" in Cline's Skills tab even though SKILL.md
227
+ // files are correctly mirrored to `~/.cline/skills/` and
228
+ // `~/.agents/skills/`. These tests pin the contract of the
229
+ // `writeBizarSkillLock` helper that the installer uses.
230
+
231
+ describe('writeBizarSkillLock() — Cline marketplace registration', () => {
232
+ let home, skillsSrc, agentsDir;
233
+
234
+ beforeEach(() => {
235
+ home = freshHome();
236
+ process.env.HOME = home;
237
+ delete process.env.XDG_CONFIG_HOME;
238
+ skillsSrc = join(home, 'config', 'skills');
239
+ agentsDir = join(home, '.agents');
240
+ mkdirSync(skillsSrc, { recursive: true });
241
+ mkdirSync(join(skillsSrc, 'bizar'), { recursive: true });
242
+ writeFileSync(join(skillsSrc, 'bizar', 'SKILL.md'), '# Bizar skill');
243
+ mkdirSync(join(skillsSrc, '9router'), { recursive: true });
244
+ writeFileSync(join(skillsSrc, '9router', 'SKILL.md'), '# 9router skill');
245
+ mkdirSync(agentsDir, { recursive: true });
246
+ });
247
+
248
+ afterEach(() => {
249
+ restoreHome();
250
+ if (home && existsSync(home)) rmSync(home, { recursive: true, force: true });
251
+ });
252
+
253
+ test('writes lock file with all skills as bizar/builtin', async () => {
254
+ const { writeBizarSkillLock } = await import('./provision.mjs');
255
+ const result = writeBizarSkillLock({ skillsSrc, agentsDir });
256
+ assert.equal(result.ok, true);
257
+ assert.equal(result.count, 2);
258
+
259
+ const lockPath = join(agentsDir, '.skill-lock.json');
260
+ assert.ok(existsSync(lockPath));
261
+ const lock = JSON.parse(readFileSync(lockPath, 'utf8'));
262
+ assert.ok(lock.skills.bizar, 'bizar entry should exist');
263
+ assert.ok(lock.skills['9router'], '9router entry should exist');
264
+ assert.equal(lock.skills.bizar.source, 'bizar/builtin');
265
+ assert.equal(lock.skills['9router'].source, 'bizar/builtin');
266
+ assert.equal(lock.skills.bizar.pluginName, 'bizar');
267
+ assert.ok(lock.skills.bizar.installedAt);
268
+ assert.ok(lock.skills.bizar.updatedAt);
269
+ });
270
+
271
+ test('preserves user-installed skills (does not overwrite non-bizar source)', async () => {
272
+ const lockPath = join(agentsDir, '.skill-lock.json');
273
+ const preExisting = {
274
+ version: 3,
275
+ skills: {
276
+ 'user-installed-skill': {
277
+ source: 'vercel-labs/agent-skills',
278
+ sourceType: 'github',
279
+ sourceUrl: 'https://github.com/vercel-labs/agent-skills.git',
280
+ skillPath: 'skills/foo/SKILL.md',
281
+ installedAt: '2026-06-13T10:03:13.744Z',
282
+ updatedAt: '2026-06-13T10:03:13.744Z',
283
+ },
284
+ },
285
+ };
286
+ writeFileSync(lockPath, JSON.stringify(preExisting));
287
+
288
+ const { writeBizarSkillLock } = await import('./provision.mjs');
289
+ writeBizarSkillLock({ skillsSrc, agentsDir });
290
+
291
+ const lock = JSON.parse(readFileSync(lockPath, 'utf8'));
292
+ assert.ok(lock.skills['user-installed-skill'], 'user skill must remain');
293
+ assert.equal(
294
+ lock.skills['user-installed-skill'].source,
295
+ 'vercel-labs/agent-skills',
296
+ 'user skill source must NOT be overwritten',
297
+ );
298
+ assert.ok(lock.skills.bizar, 'bizar entry should be added');
299
+ });
300
+
301
+ test('is idempotent — re-running updates timestamps, does not duplicate', async () => {
302
+ const { writeBizarSkillLock } = await import('./provision.mjs');
303
+ writeBizarSkillLock({ skillsSrc, agentsDir });
304
+ const lock1 = JSON.parse(readFileSync(join(agentsDir, '.skill-lock.json'), 'utf8'));
305
+ const firstInstalledAt = lock1.skills.bizar.installedAt;
306
+
307
+ // tiny delay to make updatedAt observable
308
+ await new Promise((r) => setTimeout(r, 10));
309
+ writeBizarSkillLock({ skillsSrc, agentsDir });
310
+ const lock2 = JSON.parse(readFileSync(join(agentsDir, '.skill-lock.json'), 'utf8'));
311
+ assert.equal(lock2.skills.bizar.installedAt, firstInstalledAt);
312
+ assert.notEqual(lock2.skills.bizar.updatedAt, lock1.skills.bizar.updatedAt);
313
+ });
314
+
315
+ test('returns ok=true with count=0 if skillsSrc missing', async () => {
316
+ rmSync(skillsSrc, { recursive: true, force: true });
317
+ const { writeBizarSkillLock } = await import('./provision.mjs');
318
+ const result = writeBizarSkillLock({ skillsSrc, agentsDir });
319
+ assert.equal(result.ok, true);
320
+ assert.equal(result.count, 0);
321
+ });
322
+ });
323
+
222
324
  console.log(' provision.mjs tests loaded — run with: node --test cli/provision.test.mjs');