@polderlabs/bizar 6.2.0 → 6.2.2

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 CHANGED
@@ -129,6 +129,7 @@ function showHelp() {
129
129
  eval Evaluate AI agent outputs against golden fixtures
130
130
  plan [v6.0.0+] Reserved for future plan management
131
131
  validate Validate the Bizar install (21 checks)
132
+ setup-provider Configure a provider in cline.json (since v6.2.2 installer doesn't touch providers)
132
133
 
133
134
  Examples:
134
135
  bizar install
@@ -205,6 +206,7 @@ async function main() {
205
206
  'audit', 'init', 'export', 'test-gate', 'dev-link', 'dev-unlink',
206
207
  'doctor', 'repair', 'heads-up', 'bg', 'digest', 'backup', 'restore',
207
208
  'agent-browser', 'update', 'providers', 'plan', 'validate',
209
+ 'setup-provider',
208
210
  ]);
209
211
  const UTIL_ALIASES = new Set(['dashboard', 'agent-browser-up']);
210
212
  let mod;
@@ -515,6 +517,23 @@ async function main() {
515
517
  break;
516
518
  }
517
519
 
520
+ case 'setup-provider': {
521
+ const mod = await importCommand('setup-provider');
522
+ if (!mod) {
523
+ console.error(chalk.red(` ✗ Could not load setup-provider command module`));
524
+ process.exit(EXIT_ERROR);
525
+ return;
526
+ }
527
+ dbg('loaded command module:', 'setup-provider');
528
+ const found = await mod.run(cmd, cmdArgs, isHelpRequest);
529
+ if (!found) {
530
+ console.error(chalk.red(` ✗ Unknown command: ${cmd}`));
531
+ showHelp();
532
+ process.exit(EXIT_ERROR);
533
+ }
534
+ break;
535
+ }
536
+
518
537
  default: {
519
538
  console.error(chalk.red(` ✗ Unknown command: ${cmd}`));
520
539
  showHelp();
@@ -0,0 +1,292 @@
1
+ /**
2
+ * cli/commands/setup-provider.mjs
3
+ *
4
+ * v6.2.2 — `bizar setup-provider` subcommand.
5
+ *
6
+ * The Bizar installer no longer touches provider config. This command
7
+ * is the supported way to add or update a provider in `~/.cline/cline.json`.
8
+ *
9
+ * Usage:
10
+ * bizar setup-provider # interactive; default 9Router
11
+ * bizar setup-provider --list # print the model catalog
12
+ * bizar setup-provider --gateway <url> # override gateway URL
13
+ * bizar setup-provider --key <key> # provider API key
14
+ * bizar setup-provider --provider <name> # provider name in cline.json
15
+ * bizar setup-provider --remove <name> # remove a provider
16
+ *
17
+ * The default gateway is the local 9Router at http://localhost:20128/v1.
18
+ * The catalog is fetched live from `${gateway}/v1/models` so the user
19
+ * always gets the up-to-date list.
20
+ */
21
+ import chalk from 'chalk';
22
+ import { readFileSync, writeFileSync, existsSync, renameSync, copyFileSync } from 'node:fs';
23
+ import { join, dirname } from 'node:path';
24
+ import { homedir } from 'node:os';
25
+
26
+ function clineDir() {
27
+ if (process.env.CLINE_DIR && process.env.CLINE_DIR.trim()) {
28
+ return process.env.CLINE_DIR.trim();
29
+ }
30
+ if (process.platform === 'win32') {
31
+ return join(process.env.APPDATA || homedir(), 'cline');
32
+ }
33
+ return join(homedir(), '.cline');
34
+ }
35
+
36
+ const CLINE_DIR = clineDir();
37
+ function clineJsonPath() { return join(clineDir(), 'cline.json'); }
38
+ const DEFAULT_GATEWAY = 'http://localhost:20128/v1';
39
+ const DEFAULT_PROVIDER = '9router';
40
+
41
+ function readJsonSafe(file) {
42
+ try {
43
+ if (!existsSync(file)) return null;
44
+ return JSON.parse(readFileSync(file, 'utf8'));
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ function writeJsonAtomic(file, data) {
51
+ const tmp = `${file}.tmp`;
52
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
53
+ renameSync(tmp, file);
54
+ }
55
+
56
+ /** Pretty-print the live model catalog from `${gateway}/v1/models`. */
57
+ export async function listGatewayModels(gateway) {
58
+ const url = `${gateway.replace(/\/$/, '')}/models`;
59
+ const ac = new AbortController();
60
+ const timer = setTimeout(() => ac.abort(), 10000);
61
+ try {
62
+ const res = await fetch(url, { signal: ac.signal });
63
+ if (!res.ok) throw new Error(`GET ${url} returned HTTP ${res.status}`);
64
+ const body = await res.json();
65
+ const data = Array.isArray(body?.data) ? body.data : [];
66
+ return data.map((m) => ({
67
+ id: m.id,
68
+ ownedBy: m.owned_by || '(unknown)',
69
+ }));
70
+ } finally {
71
+ clearTimeout(timer);
72
+ }
73
+ }
74
+
75
+ /** Heuristic: an all-caps-with-underscores string is an env var name. */
76
+ function looksLikeEnvVar(s) {
77
+ return /^[A-Z][A-Z0-9_]{1,}$/.test(s);
78
+ }
79
+
80
+ /** Build the cline.json provider block. */
81
+ export function buildProviderBlock({ name, gateway, apiKey, models }) {
82
+ // Cline supports two ways to express a model:
83
+ // - just a string modelId under `models`
84
+ // - an object with metadata (interleaved, reasoning, etc.)
85
+ // We emit the simplest possible form: the bare modelId. The user can
86
+ // add metadata later by editing the file.
87
+ const modelsObj = {};
88
+ for (const m of models) {
89
+ modelsObj[m] = {};
90
+ }
91
+ return {
92
+ [name]: {
93
+ baseUrl: gateway,
94
+ apiKey: looksLikeEnvVar(apiKey) ? `\${env:${apiKey}}` : apiKey,
95
+ models: modelsObj,
96
+ },
97
+ };
98
+ }
99
+
100
+ /** Apply a provider block atomically to ~/.cline/cline.json. */
101
+ export function applyProviderBlock(block) {
102
+ if (!existsSync(clineJsonPath())) {
103
+ throw new Error(
104
+ `${clineJsonPath()} not found. Run \`bizar install\` first to set up the Bizar scaffolding.`,
105
+ );
106
+ }
107
+ // Back up the original before mutating.
108
+ const backup = `${clineJsonPath()}.bak`;
109
+ try { copyFileSync(clineJsonPath(), backup); } catch { /* best-effort */ }
110
+
111
+ const cfg = readJsonSafe(clineJsonPath(), {});
112
+ if (!cfg.provider || typeof cfg.provider !== 'object') cfg.provider = {};
113
+ for (const [name, body] of Object.entries(block)) {
114
+ cfg.provider[name] = body;
115
+ }
116
+ writeJsonAtomic(clineJsonPath(), cfg);
117
+ return { name: Object.keys(block)[0], path: clineJsonPath(), backup };
118
+ }
119
+
120
+ /** Remove a provider by name. */
121
+ export function removeProvider(name) {
122
+ if (!existsSync(clineJsonPath())) {
123
+ throw new Error(`${clineJsonPath()} not found.`);
124
+ }
125
+ const cfg = readJsonSafe(clineJsonPath(), {});
126
+ if (!cfg.provider || !cfg.provider[name]) {
127
+ return { removed: false, name, reason: 'not present' };
128
+ }
129
+ delete cfg.provider[name];
130
+ writeJsonAtomic(clineJsonPath(), cfg);
131
+ return { removed: true, name, path: clineJsonPath() };
132
+ }
133
+
134
+ export function showSetupProviderHelp() {
135
+ console.log(`
136
+ bizar setup-provider — Configure a provider in cline.json
137
+
138
+ Usage:
139
+ bizar setup-provider Interactive setup (default: 9Router gateway)
140
+ bizar setup-provider --list Print the live model catalog
141
+ bizar setup-provider --gateway <url> Override the gateway URL (default: http://localhost:20128/v1)
142
+ bizar setup-provider --key <key> Provider API key
143
+ - All-caps-with-underscores → \${env:KEY}
144
+ - Otherwise → literal value
145
+ bizar setup-provider --provider <name> Provider name in cline.json (default: 9router)
146
+ bizar setup-provider --remove <name> Remove a provider by name
147
+ bizar setup-provider --help Show this help
148
+
149
+ Description:
150
+ Since v6.2.2 the Bizar installer no longer touches provider config.
151
+ Use this command to add or update a provider in ~/.cline/cline.json.
152
+
153
+ The default gateway is the local 9Router at http://localhost:20128/v1.
154
+ The model catalog is fetched live from \${gateway}/v1/models.
155
+
156
+ Examples:
157
+ bizar setup-provider
158
+ bizar setup-provider --list
159
+ bizar setup-provider --gateway https://api.anthropic.com/v1 --key sk-ant-...
160
+
161
+ Related:
162
+ bizar connect Interactive TUI for provider setup
163
+ bizar install Installs the Bizar scaffolding (NOT provider config)
164
+ bizar validate Confirms provider config is wired correctly
165
+ `);
166
+ }
167
+
168
+ export function parseFlags(args) {
169
+ const flags = {};
170
+ for (let i = 0; i < args.length; i++) {
171
+ const a = args[i];
172
+ if (a === '--list') flags.list = true;
173
+ else if (a === '--gateway' && i + 1 < args.length) { flags.gateway = args[++i]; }
174
+ else if (a.startsWith('--gateway=')) flags.gateway = a.slice('--gateway='.length);
175
+ else if (a === '--key' && i + 1 < args.length) { flags.key = args[++i]; }
176
+ else if (a.startsWith('--key=')) flags.key = a.slice('--key='.length);
177
+ else if (a === '--provider' && i + 1 < args.length) { flags.provider = args[++i]; }
178
+ else if (a.startsWith('--provider=')) flags.provider = a.slice('--provider='.length);
179
+ else if (a === '--remove' && i + 1 < args.length) { flags.remove = args[++i]; }
180
+ else if (a.startsWith('--remove=')) flags.remove = a.slice('--remove='.length);
181
+ }
182
+ return flags;
183
+ }
184
+
185
+ export async function runSetupProvider(args = []) {
186
+ if (args.includes('--help') || args.includes('-h')) {
187
+ showSetupProviderHelp();
188
+ return;
189
+ }
190
+ const flags = parseFlags(args);
191
+
192
+ // --list — print the catalog and exit
193
+ if (flags.list) {
194
+ const gateway = flags.gateway || DEFAULT_GATEWAY;
195
+ console.log(chalk.cyan(`\n Model catalog from ${gateway}/models:\n`));
196
+ try {
197
+ const models = await listGatewayModels(gateway);
198
+ if (models.length === 0) {
199
+ console.log(chalk.yellow(' (catalog is empty)'));
200
+ return;
201
+ }
202
+ // Group by owner.
203
+ const byOwner = new Map();
204
+ for (const m of models) {
205
+ if (!byOwner.has(m.ownedBy)) byOwner.set(m.ownedBy, []);
206
+ byOwner.get(m.ownedBy).push(m.id);
207
+ }
208
+ for (const [owner, ids] of byOwner) {
209
+ console.log(chalk.bold(` ${owner}:`));
210
+ for (const id of ids) {
211
+ console.log(` ${id}`);
212
+ }
213
+ }
214
+ } catch (err) {
215
+ console.log(chalk.red(` ✗ Failed to fetch catalog: ${err.message}`));
216
+ process.exit(1);
217
+ }
218
+ return;
219
+ }
220
+
221
+ // --remove <name>
222
+ if (flags.remove) {
223
+ try {
224
+ const r = removeProvider(flags.remove);
225
+ if (r.removed) {
226
+ console.log(chalk.green(` ✓ Removed provider '${r.name}' from ${r.path}`));
227
+ } else {
228
+ console.log(chalk.yellow(` ! Provider '${r.name}' was not present`));
229
+ }
230
+ } catch (err) {
231
+ console.log(chalk.red(` ✗ ${err.message}`));
232
+ process.exit(1);
233
+ }
234
+ return;
235
+ }
236
+
237
+ // Default flow — set up a provider
238
+ const gateway = flags.gateway || DEFAULT_GATEWAY;
239
+ const name = flags.provider || DEFAULT_PROVIDER;
240
+ const apiKey = flags.key || process.env.NINEROUTER_KEY || process.env.OPENAI_API_KEY || 'sk-replace-me';
241
+
242
+ // Interactive prompt for the key if missing and we're on a TTY.
243
+ if (!flags.key && !process.env.NINEROUTER_KEY && !process.env.OPENAI_API_KEY &&
244
+ process.stdin.isTTY && process.stdout.isTTY) {
245
+ const { createInterface } = await import('node:readline/promises');
246
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
247
+ try {
248
+ const answer = await rl.question(` Enter API key for ${name} (or env var name like NINEROUTER_KEY): `);
249
+ if (answer.trim()) {
250
+ Object.assign(flags, parseFlags(['--key', answer.trim()]));
251
+ }
252
+ } finally {
253
+ rl.close();
254
+ }
255
+ }
256
+
257
+ const finalKey = flags.key || apiKey;
258
+
259
+ console.log(chalk.cyan(`\n Fetching model catalog from ${gateway}...`));
260
+ let models = [];
261
+ try {
262
+ const catalog = await listGatewayModels(gateway);
263
+ models = catalog.map((m) => m.id);
264
+ console.log(chalk.green(` ✓ Found ${models.length} model(s)`));
265
+ } catch (err) {
266
+ console.log(chalk.yellow(` ! Could not fetch catalog (${err.message})`));
267
+ console.log(chalk.yellow(` Continuing with a minimal catalog.`));
268
+ models = ['minimax/MiniMax-M3', 'minimax/MiniMax-M2.7'];
269
+ }
270
+
271
+ const block = buildProviderBlock({ name, gateway, apiKey: finalKey, models });
272
+ try {
273
+ const result = applyProviderBlock(block);
274
+ console.log(chalk.green(`\n ✓ Provider '${result.name}' written to ${result.path}`));
275
+ console.log(chalk.dim(` backup: ${result.backup}`));
276
+ console.log(chalk.dim(` baseUrl: ${gateway}`));
277
+ console.log(chalk.dim(` models: ${models.length}`));
278
+ console.log('');
279
+ console.log(chalk.cyan(' Next: run `bizar validate` to confirm everything is wired up.'));
280
+ } catch (err) {
281
+ console.log(chalk.red(` ✗ ${err.message}`));
282
+ process.exit(1);
283
+ }
284
+ }
285
+
286
+ // ── run() entry point (used by bin.mjs dispatcher) ─────────────────────────
287
+
288
+ export async function run(name, args, isHelpRequest) {
289
+ if (name !== 'setup-provider') return false;
290
+ await runSetupProvider(args);
291
+ return true;
292
+ }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * cli/commands/setup-provider.test.mjs
3
+ *
4
+ * v6.2.2 — Unit tests for the setup-provider subcommand.
5
+ *
6
+ * Exercises the pure helpers (`buildProviderBlock`, `parseFlags`)
7
+ * and the filesystem mutations (`applyProviderBlock`, `removeProvider`)
8
+ * with a mocked CLINE_DIR.
9
+ */
10
+ import { test, describe, beforeEach, afterEach } from 'node:test';
11
+ import assert from 'node:assert/strict';
12
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { join } from 'node:path';
15
+
16
+ const {
17
+ buildProviderBlock,
18
+ applyProviderBlock,
19
+ removeProvider,
20
+ listGatewayModels,
21
+ parseFlags,
22
+ } = await import('./setup-provider.mjs');
23
+
24
+ const ORIG_CLINE_DIR = process.env.CLINE_DIR;
25
+ let workDir;
26
+
27
+ function freshWorkDir() {
28
+ workDir = mkdtempSync(join(tmpdir(), 'bizar-setup-provider-'));
29
+ process.env.CLINE_DIR = workDir;
30
+ return workDir;
31
+ }
32
+
33
+ function makeFakeClineJson(root, body = {}) {
34
+ const cfg = {
35
+ $schema: 'https://docs.cline.bot/config.json',
36
+ plugin: [],
37
+ default_agent: 'odin',
38
+ permission: 'allow',
39
+ snapshot: false,
40
+ ...body,
41
+ };
42
+ writeFileSync(join(root, 'cline.json'), JSON.stringify(cfg, null, 2));
43
+ }
44
+
45
+ beforeEach(() => {
46
+ workDir = freshWorkDir();
47
+ });
48
+
49
+ afterEach(() => {
50
+ if (workDir && existsSync(workDir)) rmSync(workDir, { recursive: true, force: true });
51
+ if (ORIG_CLINE_DIR === undefined) delete process.env.CLINE_DIR;
52
+ else process.env.CLINE_DIR = ORIG_CLINE_DIR;
53
+ });
54
+
55
+ describe('buildProviderBlock()', () => {
56
+ test('emits a baseUrl, apiKey, and bare modelIds', () => {
57
+ const block = buildProviderBlock({
58
+ name: '9router',
59
+ gateway: 'http://localhost:20128/v1',
60
+ apiKey: 'sk-test',
61
+ models: ['minimax/MiniMax-M3', 'minimax/MiniMax-M2.7'],
62
+ });
63
+ assert.ok(block['9router']);
64
+ assert.equal(block['9router'].baseUrl, 'http://localhost:20128/v1');
65
+ assert.equal(block['9router'].apiKey, 'sk-test');
66
+ assert.deepEqual(Object.keys(block['9router'].models), ['minimax/MiniMax-M3', 'minimax/MiniMax-M2.7']);
67
+ });
68
+
69
+ test('wraps env-var-style keys in ${env:...}', () => {
70
+ const block = buildProviderBlock({
71
+ name: '9router',
72
+ gateway: 'http://localhost:20128/v1',
73
+ apiKey: 'NINEROUTER_KEY',
74
+ models: ['minimax/MiniMax-M3'],
75
+ });
76
+ assert.equal(block['9router'].apiKey, '${env:NINEROUTER_KEY}');
77
+ });
78
+
79
+ test('keeps literal keys as-is when not env-var shaped', () => {
80
+ const block = buildProviderBlock({
81
+ name: 'custom',
82
+ gateway: 'https://x.example/v1',
83
+ apiKey: 'sk-abc-123',
84
+ models: ['m1'],
85
+ });
86
+ assert.equal(block['custom'].apiKey, 'sk-abc-123');
87
+ });
88
+ });
89
+
90
+ describe('applyProviderBlock()', () => {
91
+ test('writes a new provider to ~/.cline/cline.json', () => {
92
+ makeFakeClineJson(workDir);
93
+ const block = buildProviderBlock({
94
+ name: '9router',
95
+ gateway: 'http://localhost:20128/v1',
96
+ apiKey: 'sk-test',
97
+ models: ['minimax/MiniMax-M3'],
98
+ });
99
+ const r = applyProviderBlock(block);
100
+ assert.equal(r.name, '9router');
101
+ const cfg = JSON.parse(readFileSync(join(workDir, 'cline.json'), 'utf8'));
102
+ assert.ok(cfg.provider['9router']);
103
+ assert.equal(cfg.provider['9router'].baseUrl, 'http://localhost:20128/v1');
104
+ });
105
+
106
+ test('preserves other provider entries when adding a new one', () => {
107
+ makeFakeClineJson(workDir, {
108
+ provider: { existing: { baseUrl: 'https://a', apiKey: 'k', models: { m: {} } } },
109
+ });
110
+ const block = buildProviderBlock({
111
+ name: '9router',
112
+ gateway: 'http://localhost:20128/v1',
113
+ apiKey: 'sk-test',
114
+ models: ['minimax/MiniMax-M3'],
115
+ });
116
+ applyProviderBlock(block);
117
+ const cfg = JSON.parse(readFileSync(join(workDir, 'cline.json'), 'utf8'));
118
+ assert.ok(cfg.provider.existing, 'existing provider preserved');
119
+ assert.ok(cfg.provider['9router'], 'new provider added');
120
+ });
121
+
122
+ test('overwrites an existing provider with the same name', () => {
123
+ makeFakeClineJson(workDir, {
124
+ provider: { '9router': { baseUrl: 'https://old', apiKey: 'old', models: {} } },
125
+ });
126
+ const block = buildProviderBlock({
127
+ name: '9router',
128
+ gateway: 'http://localhost:20128/v1',
129
+ apiKey: 'new',
130
+ models: ['minimax/MiniMax-M3'],
131
+ });
132
+ applyProviderBlock(block);
133
+ const cfg = JSON.parse(readFileSync(join(workDir, 'cline.json'), 'utf8'));
134
+ assert.equal(cfg.provider['9router'].baseUrl, 'http://localhost:20128/v1');
135
+ assert.equal(cfg.provider['9router'].apiKey, 'new');
136
+ assert.ok(cfg.provider['9router'].models['minimax/MiniMax-M3']);
137
+ });
138
+
139
+ test('throws when cline.json is missing', () => {
140
+ // No makeFakeClineJson call
141
+ const block = buildProviderBlock({
142
+ name: '9router',
143
+ gateway: 'http://localhost:20128/v1',
144
+ apiKey: 'sk-test',
145
+ models: ['minimax/MiniMax-M3'],
146
+ });
147
+ assert.throws(() => applyProviderBlock(block), /not found/);
148
+ });
149
+
150
+ test('creates a backup file', () => {
151
+ makeFakeClineJson(workDir, {
152
+ plugin: [['./plugins/bizar', {}]],
153
+ });
154
+ const block = buildProviderBlock({
155
+ name: '9router',
156
+ gateway: 'http://localhost:20128/v1',
157
+ apiKey: 'sk-test',
158
+ models: ['minimax/MiniMax-M3'],
159
+ });
160
+ const r = applyProviderBlock(block);
161
+ assert.ok(existsSync(r.backup), 'backup file created');
162
+ const backup = JSON.parse(readFileSync(r.backup, 'utf8'));
163
+ assert.deepEqual(backup.plugin, [['./plugins/bizar', {}]]);
164
+ });
165
+ });
166
+
167
+ describe('removeProvider()', () => {
168
+ test('removes a named provider', () => {
169
+ makeFakeClineJson(workDir, {
170
+ provider: {
171
+ a: { baseUrl: 'https://a', apiKey: 'k', models: {} },
172
+ b: { baseUrl: 'https://b', apiKey: 'k', models: {} },
173
+ },
174
+ });
175
+ const r = removeProvider('a');
176
+ assert.equal(r.removed, true);
177
+ const cfg = JSON.parse(readFileSync(join(workDir, 'cline.json'), 'utf8'));
178
+ assert.equal(cfg.provider.a, undefined);
179
+ assert.ok(cfg.provider.b, 'untouched provider preserved');
180
+ });
181
+
182
+ test('returns removed=false for a missing provider', () => {
183
+ makeFakeClineJson(workDir);
184
+ const r = removeProvider('nonexistent');
185
+ assert.equal(r.removed, false);
186
+ assert.equal(r.reason, 'not present');
187
+ });
188
+ });
189
+
190
+ describe('parseFlags()', () => {
191
+ test('parses --gateway, --key, --provider with space separator', () => {
192
+ const f = parseFlags(['--gateway', 'http://x', '--key', 'sk-x', '--provider', 'foo']);
193
+ assert.equal(f.gateway, 'http://x');
194
+ assert.equal(f.key, 'sk-x');
195
+ assert.equal(f.provider, 'foo');
196
+ });
197
+
198
+ test('parses --gateway=, --key=, --provider= equals form', () => {
199
+ const f = parseFlags(['--gateway=http://x', '--key=sk-x', '--provider=foo']);
200
+ assert.equal(f.gateway, 'http://x');
201
+ assert.equal(f.key, 'sk-x');
202
+ assert.equal(f.provider, 'foo');
203
+ });
204
+
205
+ test('parses --list and --remove', () => {
206
+ const f = parseFlags(['--list']);
207
+ assert.equal(f.list, true);
208
+ const g = parseFlags(['--remove', 'myprov']);
209
+ assert.equal(g.remove, 'myprov');
210
+ });
211
+ });
@@ -51,11 +51,16 @@ const REQUIRED_COMMANDS = [
51
51
  'audit.md', 'bizar.md', 'explain.md', 'init.md', 'learn.md',
52
52
  'plan.md', 'plow-through.md', 'pr-review.md', 'tailscale-serve.md',
53
53
  'visual-plan.md',
54
- 'team.md', 'test.md', 'validate.md',
54
+ 'team.md', 'test.md', 'validate.md', 'setup-provider.md',
55
55
  ];
56
56
 
57
+ // v6.2.1 — Cline-native hook scripts (executable, shebang, no extension).
58
+ // These are real Cline hooks (https://docs.cline.bot/customization/hooks).
59
+ // The previous v6.x hook contract was markdown behavioral files, which
60
+ // Cline silently ignored — leading to the "I see skills but no hooks"
61
+ // user report.
57
62
  const REQUIRED_HOOKS = [
58
- 'pre-tool-use.md', 'post-tool-use.md', 'README.md',
63
+ 'PreToolUse', 'PostToolUse', 'TaskStart', 'TaskResume', 'UserPromptSubmit',
59
64
  ];
60
65
 
61
66
  const REQUIRED_RUNTIME_DEPS = ['zod', '@cline/sdk', '@cline/core', '@cline/shared'];
@@ -222,24 +227,61 @@ const CHECKS = {
222
227
  if (missing.length > 0) {
223
228
  throw new Error(`missing: ${missing.join(', ')} — run \`bizar update\``);
224
229
  }
225
- return `all ${REQUIRED_HOOKS.length} hook file(s) present`;
230
+ // Verify hooks are executable (Cline silently skips non-executable
231
+ // hook files per the official contract). On Windows, chmod is a
232
+ // no-op so skip the check there.
233
+ if (process.platform !== 'win32') {
234
+ const { statSync } = await import('node:fs');
235
+ const nonExec = [];
236
+ for (const f of REQUIRED_HOOKS) {
237
+ try {
238
+ const st = statSync(join(dir, f));
239
+ // 0o755 = owner=rwx, group=r-x, other=r-x. Just check owner has x.
240
+ if ((st.mode & 0o100) === 0) nonExec.push(f);
241
+ } catch {
242
+ nonExec.push(f);
243
+ }
244
+ }
245
+ if (nonExec.length > 0) {
246
+ throw new Error(`hooks not executable: ${nonExec.join(', ')} — run \`chmod +x ~/.cline/hooks/*\``);
247
+ }
248
+ }
249
+ return `all ${REQUIRED_HOOKS.length} Cline-native hook file(s) executable in ${dir}`;
250
+ },
251
+
252
+ 'hooks-canonical-location': async () => {
253
+ // Cline's default global hooks location per the official docs:
254
+ // `~/Documents/Cline/Hooks/`. Verify our hooks are also there
255
+ // (Bizar install puts them in BOTH ~/.cline/hooks/ and
256
+ // ~/Documents/Cline/Hooks/ for max compatibility).
257
+ const canonical = join(homedir(), 'Documents', 'Cline', 'Hooks');
258
+ if (!existsSync(canonical)) {
259
+ return `optional: ${canonical} does not exist yet — \`bizar install\` creates it`;
260
+ }
261
+ const missing = REQUIRED_HOOKS.filter((f) => !existsSync(join(canonical, f)));
262
+ if (missing.length > 0) {
263
+ return `optional: ${missing.length} hook(s) missing in ${canonical} — re-run \`bizar install\``;
264
+ }
265
+ return `canonical hooks dir ${canonical} has all ${REQUIRED_HOOKS.length} hook(s)`;
226
266
  },
227
267
 
228
268
  'provider-config': async () => {
269
+ // v6.2.2 — The installer no longer adds a provider block. The user
270
+ // must configure their own provider. This check is therefore
271
+ // ALWAYS lenient (returns a hint, never fails). It just reports
272
+ // what's in cline.json so the user can see the current state.
229
273
  const cfg = readJsonSafe(clineJsonPath());
230
- if (!cfg?.provider) throw new Error('no provider block in cline.json');
231
- const nine = cfg.provider['9router'];
232
- if (nine && nine.baseUrl) {
233
- const models = nine.models || {};
234
- const count = Object.keys(models).length;
235
- if (count === 0) throw new Error('provider.9router has no models');
236
- return `provider.9router (${count} models, baseUrl=${nine.baseUrl})`;
237
- }
238
- const minimax = cfg.provider.minimax;
239
- if (!minimax) {
240
- throw new Error('no provider.9router AND no provider.minimax');
274
+ if (!cfg?.provider || Object.keys(cfg.provider).length === 0) {
275
+ return 'no provider configured — user must add one. `bizar install` no longer touches provider config (v6.2.2+).';
241
276
  }
242
- return 'provider.minimax (legacy fallback)';
277
+ const names = Object.keys(cfg.provider);
278
+ const summary = names.map((n) => {
279
+ const p = cfg.provider[n];
280
+ const url = p?.baseUrl || p?.options?.baseURL || '(no baseUrl)';
281
+ const modelCount = Object.keys(p?.models || {}).length;
282
+ return `${n} (${modelCount} models, baseUrl=${url})`;
283
+ });
284
+ return `user-configured providers: ${summary.join('; ')}`;
243
285
  },
244
286
 
245
287
  '9router-reachable': async () => {
@@ -325,13 +367,17 @@ const CHECK_ORDER = [
325
367
  'skills-installed',
326
368
  'rules-installed',
327
369
  'hooks-installed',
370
+ 'hooks-canonical-location',
328
371
  'default-agent-set',
329
372
  'instructions-loaded',
330
373
  'provider-config',
331
374
  '9router-reachable',
332
375
  ];
333
376
 
334
- const LENIENT_CHECKS = new Set(['9router-reachable']);
377
+ const LENIENT_CHECKS = new Set([
378
+ '9router-reachable',
379
+ 'provider-config', // v6.2.2+ — installer no longer touches provider config; user must configure
380
+ ]);
335
381
 
336
382
  export function showValidateHelp() {
337
383
  console.log(`