@polderlabs/bizar 6.2.1 → 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 +19 -0
- package/cli/commands/setup-provider.mjs +292 -0
- package/cli/commands/setup-provider.test.mjs +211 -0
- package/cli/commands/validate.mjs +19 -14
- package/cli/commands/validate.test.mjs +36 -10
- package/cli/provision.mjs +52 -77
- 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 +20 -93
- package/config/commands/setup-provider.md +95 -0
- package/package.json +2 -2
- package/plugins/bizar/package.json +1 -1
- package/scripts/bh-full-e2e.mjs +7 -5
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,7 +51,7 @@ 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
57
|
// v6.2.1 — Cline-native hook scripts (executable, shebang, no extension).
|
|
@@ -266,20 +266,22 @@ const CHECKS = {
|
|
|
266
266
|
},
|
|
267
267
|
|
|
268
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.
|
|
269
273
|
const cfg = readJsonSafe(clineJsonPath());
|
|
270
|
-
if (!cfg?.provider
|
|
271
|
-
|
|
272
|
-
if (nine && nine.baseUrl) {
|
|
273
|
-
const models = nine.models || {};
|
|
274
|
-
const count = Object.keys(models).length;
|
|
275
|
-
if (count === 0) throw new Error('provider.9router has no models');
|
|
276
|
-
return `provider.9router (${count} models, baseUrl=${nine.baseUrl})`;
|
|
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+).';
|
|
277
276
|
}
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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('; ')}`;
|
|
283
285
|
},
|
|
284
286
|
|
|
285
287
|
'9router-reachable': async () => {
|
|
@@ -372,7 +374,10 @@ const CHECK_ORDER = [
|
|
|
372
374
|
'9router-reachable',
|
|
373
375
|
];
|
|
374
376
|
|
|
375
|
-
const LENIENT_CHECKS = new Set([
|
|
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
|
+
]);
|
|
376
381
|
|
|
377
382
|
export function showValidateHelp() {
|
|
378
383
|
console.log(`
|
|
@@ -68,7 +68,7 @@ function makeFakeClineInstall(root) {
|
|
|
68
68
|
'audit.md', 'bizar.md', 'explain.md', 'init.md', 'learn.md',
|
|
69
69
|
'plan.md', 'plow-through.md', 'pr-review.md', 'tailscale-serve.md',
|
|
70
70
|
'visual-plan.md',
|
|
71
|
-
'team.md', 'test.md', 'validate.md',
|
|
71
|
+
'team.md', 'test.md', 'validate.md', 'setup-provider.md',
|
|
72
72
|
]) {
|
|
73
73
|
writeFileSync(join(cmdsDir, f), '# fake command');
|
|
74
74
|
}
|
|
@@ -205,28 +205,54 @@ describe('runValidate() — JSON output', () => {
|
|
|
205
205
|
assert.equal(exitCode, 1, 'should fail when enableAgentTeams is false');
|
|
206
206
|
});
|
|
207
207
|
|
|
208
|
-
test('
|
|
208
|
+
test('passes when no provider is configured (v6.2.2+ — user must configure)', async () => {
|
|
209
|
+
// v6.2.2: the installer no longer touches provider config. The
|
|
210
|
+
// user must add their own provider. The provider-config check
|
|
211
|
+
// is therefore ALWAYS lenient — it just reports state.
|
|
209
212
|
const cfg = JSON.parse(readFileSync(join(workDir, 'cline.json'), 'utf8'));
|
|
210
213
|
delete cfg.provider;
|
|
211
214
|
writeFileSync(join(workDir, 'cline.json'), JSON.stringify(cfg, null, 2));
|
|
212
|
-
const { exitCode } = await captureRun({ json: true });
|
|
213
|
-
assert.equal(exitCode,
|
|
215
|
+
const { exitCode, stdout } = await captureRun({ json: true });
|
|
216
|
+
assert.equal(exitCode, 0, 'no-provider must not fail validation (v6.2.2+ contract)');
|
|
217
|
+
const parsed = JSON.parse(stdout);
|
|
218
|
+
const p = parsed.results.find((r) => r.name === 'provider-config');
|
|
219
|
+
assert.ok(p, 'provider-config check should be present');
|
|
220
|
+
assert.equal(p.ok, true, 'provider-config always returns ok (lenient)');
|
|
221
|
+
assert.match(p.message, /no provider configured/);
|
|
214
222
|
});
|
|
215
223
|
|
|
216
|
-
test('
|
|
224
|
+
test('reports user-configured providers when present', async () => {
|
|
225
|
+
// User has set up their own provider (anything goes).
|
|
217
226
|
const cfg = JSON.parse(readFileSync(join(workDir, 'cline.json'), 'utf8'));
|
|
218
|
-
|
|
227
|
+
cfg.provider = {
|
|
228
|
+
'my-custom-provider': {
|
|
229
|
+
baseUrl: 'https://my-llm.example/v1',
|
|
230
|
+
apiKey: 'sk-test',
|
|
231
|
+
models: { 'my-model-1': {}, 'my-model-2': {} },
|
|
232
|
+
},
|
|
233
|
+
};
|
|
219
234
|
writeFileSync(join(workDir, 'cline.json'), JSON.stringify(cfg, null, 2));
|
|
220
|
-
const { exitCode } = await captureRun({ json: true });
|
|
235
|
+
const { exitCode, stdout } = await captureRun({ json: true });
|
|
221
236
|
assert.equal(exitCode, 0);
|
|
237
|
+
const parsed = JSON.parse(stdout);
|
|
238
|
+
const p = parsed.results.find((r) => r.name === 'provider-config');
|
|
239
|
+
assert.match(p.message, /my-custom-provider/);
|
|
240
|
+
assert.match(p.message, /2 models/);
|
|
222
241
|
});
|
|
223
242
|
|
|
224
|
-
test('
|
|
243
|
+
test('reports both providers if user has multiple', async () => {
|
|
225
244
|
const cfg = JSON.parse(readFileSync(join(workDir, 'cline.json'), 'utf8'));
|
|
226
|
-
|
|
245
|
+
cfg.provider = {
|
|
246
|
+
'a': { baseUrl: 'https://a.example/v1', models: { 'm1': {} } },
|
|
247
|
+
'b': { baseUrl: 'https://b.example/v1', models: { 'm2': {}, 'm3': {} } },
|
|
248
|
+
};
|
|
227
249
|
writeFileSync(join(workDir, 'cline.json'), JSON.stringify(cfg, null, 2));
|
|
228
|
-
const { exitCode } = await captureRun({ json: true });
|
|
250
|
+
const { exitCode, stdout } = await captureRun({ json: true });
|
|
229
251
|
assert.equal(exitCode, 0);
|
|
252
|
+
const parsed = JSON.parse(stdout);
|
|
253
|
+
const p = parsed.results.find((r) => r.name === 'provider-config');
|
|
254
|
+
assert.match(p.message, /\ba\b/);
|
|
255
|
+
assert.match(p.message, /\bb\b/);
|
|
230
256
|
});
|
|
231
257
|
|
|
232
258
|
test('default mode is lenient on 9router-unreachable', async () => {
|