@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.
- package/cli/bin.mjs +14 -0
- package/cli/commands/sandbox.mjs +220 -0
- package/cli/commands/validate.mjs +66 -0
- package/cli/commands/validate.test.mjs +72 -0
- package/cli/provision.mjs +107 -0
- package/cli/provision.test.mjs +102 -0
- package/config/agents/_shared/AGENT_BASELINE.md +104 -645
- package/config/agents/_shared/CLINE_TOOLS.md +398 -0
- package/config/agents/agent-browser.md +1 -1
- package/config/agents/baldr.md +1 -1
- package/config/agents/forseti.md +1 -1
- package/config/agents/frigg.md +1 -1
- package/config/agents/heimdall.md +1 -1
- package/config/agents/hermod.md +1 -1
- package/config/agents/mimir.md +1 -1
- package/config/agents/odin.md +1 -1
- package/config/agents/quick.md +1 -1
- package/config/agents/semble-search.md +1 -1
- package/config/agents/thor.md +1 -1
- package/config/agents/tyr.md +1 -1
- package/config/agents/vidarr.md +1 -1
- package/config/agents/vor.md +1 -1
- package/config/cline.json.template +2 -2
- package/config/skills/bizar/SKILL.md +197 -0
- package/config/skills/cubesandbox/SKILL.md +148 -0
- package/config/skills/harness-engineering/SKILL.md +142 -0
- package/package.json +1 -1
- package/plugins/bizar/index.ts +57 -4
- package/plugins/bizar/package.json +1 -1
- package/plugins/bizar/src/clineruntime.ts +18 -3
- package/plugins/bizar/src/fingerprint.ts +11 -11
- package/plugins/bizar/src/options.ts +14 -7
- package/plugins/bizar/src/tools/sandbox.ts +232 -0
- package/plugins/bizar/src/trajectory.ts +2 -2
- package/plugins/bizar/tests/clineruntime-config.test.ts +45 -4
- package/plugins/bizar/tests/fingerprint.test.ts +28 -0
- package/plugins/bizar/tests/options.test.ts +6 -6
- package/plugins/bizar/tests/safety.test.ts +25 -0
- package/plugins/bizar/tests/tools/sandbox.test.ts +117 -0
- package/scripts/bh-full-e2e.mjs +22 -0
- package/scripts/check-agents.mjs +73 -0
- package/scripts/mirror-agents-md.sh +69 -0
- 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
|
package/cli/provision.test.mjs
CHANGED
|
@@ -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');
|