jbai-cli 1.9.1 → 1.9.6

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/bin/jbai.js CHANGED
@@ -3,8 +3,12 @@
3
3
  const { spawn, execSync } = require('child_process');
4
4
  const readline = require('readline');
5
5
  const https = require('https');
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const os = require('os');
6
9
  const config = require('../lib/config');
7
10
  const { createHandoff } = require('../lib/handoff');
11
+ const { getGroupsForTool, showModelsForTool } = require('../lib/model-list');
8
12
 
9
13
  const TOOLS = {
10
14
  claude: {
@@ -53,11 +57,12 @@ jbai-cli v${VERSION} - JetBrains AI Platform CLI Tools
53
57
  COMMANDS:
54
58
  jbai token Show token status
55
59
  jbai token set Set token interactively
56
- jbai token refresh Auto-refresh token via API (no UI needed)
60
+ jbai token refresh Auto-refresh token via API
61
+ jbai token refresh <token> Set new token (saves to ~/.jbai/token + ~/.zshrc)
57
62
  jbai test Test API endpoints (incl. Codex /responses)
58
63
  jbai handoff Continue task in Orca Lab
59
64
  jbai env [staging|production] Switch environment
60
- jbai models List available models
65
+ jbai models [tool] List Grazie models (all|claude|codex|gemini|opencode|goose|continue)
61
66
  jbai install Install all AI tools (claude, codex, gemini, opencode, goose, continue)
62
67
  jbai install claude Install specific tool
63
68
  jbai doctor Check which tools are installed
@@ -74,6 +79,7 @@ TOOL WRAPPERS:
74
79
  jbai-codex Launch Codex CLI with JetBrains AI
75
80
  jbai-gemini Launch Gemini CLI with JetBrains AI
76
81
  jbai-opencode Launch OpenCode with JetBrains AI
82
+ jbai-council Launch Claude + Codex + OpenCode in tmux council mode
77
83
 
78
84
  SUPER MODE:
79
85
  Add --super (or --yolo or -s) to skip confirmations:
@@ -86,6 +92,8 @@ EXAMPLES:
86
92
  jbai-claude # Start Claude Code
87
93
  jbai-codex exec "explain code" # Run Codex task
88
94
  jbai-gemini # Start Gemini CLI
95
+ jbai-council # Launch all 3 agents in tmux
96
+ jbai-council --super # All agents in super mode
89
97
  jbai handoff --task "fix lint" # Handoff task to Orca Lab
90
98
 
91
99
  TOKEN:
@@ -157,7 +165,47 @@ async function setToken() {
157
165
  });
158
166
  }
159
167
 
160
- async function refreshTokenCommand() {
168
+ function updateZshrcToken(newToken) {
169
+ const zshrc = path.join(os.homedir(), '.zshrc');
170
+ if (!fs.existsSync(zshrc)) return false;
171
+
172
+ const content = fs.readFileSync(zshrc, 'utf-8');
173
+ const pattern = /^(export JBAI_PROXY_KEY=").*(")/m;
174
+ if (!pattern.test(content)) return false;
175
+
176
+ const updated = content.replace(pattern, `$1${newToken}$2`);
177
+ fs.writeFileSync(zshrc, updated);
178
+ return true;
179
+ }
180
+
181
+ function saveTokenEverywhere(newToken) {
182
+ config.setToken(newToken);
183
+ console.log(' ✅ Saved to ~/.jbai/token');
184
+
185
+ if (updateZshrcToken(newToken)) {
186
+ console.log(' ✅ Updated JBAI_PROXY_KEY in ~/.zshrc');
187
+ }
188
+ }
189
+
190
+ async function refreshTokenCommand(providedToken) {
191
+ // If a token was passed as argument, save it directly (skip API refresh)
192
+ if (providedToken && providedToken.includes('.')) {
193
+ const expiry = config.getTokenExpiry(providedToken);
194
+ if (!expiry) {
195
+ console.log('❌ Invalid token format (could not parse JWT)');
196
+ return;
197
+ }
198
+ if (config.isTokenExpired(providedToken)) {
199
+ console.log('❌ Provided token is already expired');
200
+ return;
201
+ }
202
+ console.log('🔄 Saving provided token...');
203
+ saveTokenEverywhere(providedToken);
204
+ console.log('✅ Token updated!');
205
+ showTokenStatus();
206
+ return;
207
+ }
208
+
161
209
  const token = config.getToken();
162
210
  if (!token) {
163
211
  console.log('❌ No token found. Run: jbai token set');
@@ -179,12 +227,13 @@ async function refreshTokenCommand() {
179
227
  try {
180
228
  console.log('🔄 Refreshing token via API...');
181
229
  const newToken = await config.refreshTokenApi(token);
182
- config.setToken(newToken);
230
+ saveTokenEverywhere(newToken);
183
231
  console.log('✅ Token refreshed successfully!');
184
232
  showTokenStatus();
185
233
  } catch (e) {
186
- console.log(`❌ Refresh failed: ${e.message}`);
187
- console.log(` Get a new token manually: ${config.getEndpoints().tokenUrl}`);
234
+ console.log(`❌ API refresh failed: ${e.message}`);
235
+ console.log(` Paste token manually: jbai token refresh <token>`);
236
+ console.log(` Get a new token: ${config.getEndpoints().tokenUrl}`);
188
237
  }
189
238
  }
190
239
 
@@ -303,41 +352,21 @@ function httpPost(url, body, headers) {
303
352
  });
304
353
  }
305
354
 
306
- function showModels() {
307
- console.log('Available Models via JetBrains AI Platform (Grazie):\n');
355
+ function showModels(filter) {
356
+ const tool = filter || 'all';
357
+ const heading = tool === 'all'
358
+ ? 'Available Models via JetBrains AI Platform (Grazie):'
359
+ : `Available Models via JetBrains AI Platform (Grazie) for ${tool}:`;
308
360
 
309
- console.log('Claude (Anthropic) - jbai-claude:');
310
- config.MODELS.claude.available.forEach((m) => {
311
- const def = m === config.MODELS.claude.default ? ' (default)' : '';
312
- console.log(` - ${m}${def}`);
313
- });
361
+ showModelsForTool(tool, heading);
314
362
 
315
- console.log('\nGPT (OpenAI Chat) - jbai-opencode:');
316
- config.MODELS.openai.available.forEach((m) => {
317
- const def = m === config.MODELS.openai.default ? ' (default)' : '';
318
- console.log(` - ${m}${def}`);
319
- });
320
-
321
- console.log('\nCodex (OpenAI Responses) - jbai-codex:');
322
- config.MODELS.codex.available.forEach((m) => {
323
- const def = m === config.MODELS.codex.default ? ' (default)' : '';
324
- console.log(` - ${m}${def}`);
325
- });
326
-
327
- console.log('\nGemini (Google) - jbai-gemini:');
328
- config.MODELS.gemini.available.forEach((m) => {
329
- const def = m === config.MODELS.gemini.default ? ' (default)' : '';
330
- console.log(` - ${m}${def}`);
331
- });
332
-
333
- // Count total
334
- const total = config.MODELS.claude.available.length +
335
- config.MODELS.openai.available.length +
336
- config.MODELS.codex.available.length +
337
- config.MODELS.gemini.available.length;
338
- console.log(`\nTotal: ${total} models`);
339
- console.log('\nNote: Other providers (DeepSeek, Mistral, Qwen, XAI, Meta) are available');
340
- console.log('via Grazie native API but not via OpenAI-compatible CLI tools.');
363
+ const groups = getGroupsForTool(tool);
364
+ const total = groups.reduce((sum, group) => sum + group.models.length, 0);
365
+ console.log(`Total: ${total} models`);
366
+ if (tool === 'all') {
367
+ console.log('\nNote: Other providers (DeepSeek, Mistral, Qwen, XAI, Meta) are available');
368
+ console.log('via Grazie native API but not via OpenAI-compatible CLI tools.');
369
+ }
341
370
  }
342
371
 
343
372
  function setEnvironment(env) {
@@ -621,7 +650,7 @@ switch (command) {
621
650
  if (args[0] === 'set') {
622
651
  setToken();
623
652
  } else if (args[0] === 'refresh') {
624
- refreshTokenCommand();
653
+ refreshTokenCommand(args[1]);
625
654
  } else {
626
655
  showTokenStatus();
627
656
  }
@@ -633,7 +662,14 @@ switch (command) {
633
662
  handoffToOrca(args);
634
663
  break;
635
664
  case 'models':
636
- showModels();
665
+ if (args[0]) {
666
+ const allowed = new Set(['all', 'claude', 'codex', 'gemini', 'opencode', 'goose', 'continue']);
667
+ if (!allowed.has(args[0])) {
668
+ console.log('Usage: jbai models [all|claude|codex|gemini|opencode|goose|continue]');
669
+ break;
670
+ }
671
+ }
672
+ showModels(args[0]);
637
673
  break;
638
674
  case 'env':
639
675
  setEnvironment(args[0]);
@@ -0,0 +1,279 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * E2E CLI Task Testing Script
5
+ * Runs each jbai CLI across all configured models and verifies
6
+ * tic-tac-toe sources are created in a temp directory.
7
+ */
8
+
9
+ const { spawnSync } = require('child_process');
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const os = require('os');
13
+ const config = require('../lib/config');
14
+
15
+ const PROMPT = [
16
+ 'Create a minimal tic tac toe web app in the current directory.',
17
+ 'Write index.html, styles.css, and app.js.',
18
+ 'Use vanilla JS only. Keep the implementation small.',
19
+ 'After writing files, reply with exactly: JBAI_DONE.'
20
+ ].join(' ');
21
+
22
+ const BASE_DIR = path.join(os.tmpdir(), `jbai-cli-e2e-${Date.now()}`);
23
+ const DEFAULT_TIMEOUT_MS = 3 * 60 * 1000;
24
+ const FAST_TIMEOUT_MS = 60 * 1000;
25
+ const BIN_DIR = __dirname;
26
+
27
+ const REQUIRED_FILES = ['index.html', 'styles.css', 'app.js'];
28
+
29
+ function ensureDir(dir) {
30
+ fs.mkdirSync(dir, { recursive: true });
31
+ }
32
+
33
+ function hasRequiredFiles(dir) {
34
+ return REQUIRED_FILES.every((file) => {
35
+ const full = path.join(dir, file);
36
+ return fs.existsSync(full) && fs.statSync(full).size > 0;
37
+ });
38
+ }
39
+
40
+ function runCommand({ cmd, args, cwd, timeoutMs }) {
41
+ const result = spawnSync(cmd, args, {
42
+ cwd,
43
+ encoding: 'utf-8',
44
+ maxBuffer: 10 * 1024 * 1024,
45
+ timeout: timeoutMs || DEFAULT_TIMEOUT_MS,
46
+ });
47
+ return result;
48
+ }
49
+
50
+ function formatResult(ok, reason) {
51
+ return ok ? { ok: true } : { ok: false, reason: reason || 'unknown' };
52
+ }
53
+
54
+ function detectToolLimitation(output) {
55
+ const text = (output || '').toLowerCase();
56
+ if (text.includes('tool') && text.includes('not found in registry')) return true;
57
+ if (text.includes('no direct file writing tools')) return true;
58
+ if (text.includes('cannot proceed') && text.includes('files')) return true;
59
+ if (text.includes('apps/create_app') && text.includes('do not have direct')) return true;
60
+ return false;
61
+ }
62
+
63
+ function runTest(test) {
64
+ const dir = path.join(BASE_DIR, test.tool, test.modelId.replace(/[^a-zA-Z0-9._-]/g, '_'));
65
+ ensureDir(dir);
66
+
67
+ const result = runCommand({ cmd: test.cmd, args: test.args, cwd: dir, timeoutMs: test.timeoutMs });
68
+ const combinedOutput = `${result.stdout || ''}\n${result.stderr || ''}`;
69
+ const ok = result.status === 0 && hasRequiredFiles(dir);
70
+ const logPath = path.join(dir, 'run.log');
71
+ try {
72
+ fs.writeFileSync(logPath, combinedOutput);
73
+ } catch {
74
+ // ignore log write errors
75
+ }
76
+
77
+ return {
78
+ ok,
79
+ status: result.status,
80
+ signal: result.signal,
81
+ timedOut: result.error && result.error.code === 'ETIMEDOUT',
82
+ output: combinedOutput,
83
+ dir,
84
+ };
85
+ }
86
+
87
+ function buildTests() {
88
+ const tests = [];
89
+
90
+ // Claude Code
91
+ for (const model of config.MODELS.claude.available) {
92
+ tests.push({
93
+ tool: 'jbai-claude',
94
+ modelId: model,
95
+ cmd: process.execPath,
96
+ args: [path.join(BIN_DIR, 'jbai-claude.js'), '--super', '--model', model, '-p', PROMPT],
97
+ });
98
+ }
99
+
100
+ // Codex CLI
101
+ for (const model of config.MODELS.codex.available) {
102
+ tests.push({
103
+ tool: 'jbai-codex',
104
+ modelId: model,
105
+ cmd: process.execPath,
106
+ args: [path.join(BIN_DIR, 'jbai-codex.js'), '--super', '--model', model, 'exec', '--skip-git-repo-check', PROMPT],
107
+ });
108
+ }
109
+
110
+ // OpenCode (OpenAI models)
111
+ const environment = config.getEnvironment();
112
+ const openaiProvider = environment === 'staging' ? 'jbai-staging' : 'jbai';
113
+ const anthropicProvider = environment === 'staging' ? 'jbai-anthropic-staging' : 'jbai-anthropic';
114
+
115
+ for (const model of config.MODELS.openai.available) {
116
+ tests.push({
117
+ tool: 'jbai-opencode',
118
+ modelId: model,
119
+ cmd: process.execPath,
120
+ args: [path.join(BIN_DIR, 'jbai-opencode.js'), '--super', 'run', '--model', `${openaiProvider}/${model}`, PROMPT],
121
+ });
122
+ }
123
+ for (const model of config.MODELS.claude.available) {
124
+ tests.push({
125
+ tool: 'jbai-opencode',
126
+ modelId: model,
127
+ cmd: process.execPath,
128
+ args: [path.join(BIN_DIR, 'jbai-opencode.js'), '--super', 'run', '--model', `${anthropicProvider}/${model}`, PROMPT],
129
+ });
130
+ }
131
+
132
+ // Gemini CLI
133
+ for (const model of config.MODELS.gemini.available) {
134
+ tests.push({
135
+ tool: 'jbai-gemini',
136
+ modelId: model,
137
+ cmd: process.execPath,
138
+ args: [path.join(BIN_DIR, 'jbai-gemini.js'), '--super', '--model', model, '-p', PROMPT],
139
+ timeoutMs: FAST_TIMEOUT_MS,
140
+ });
141
+ }
142
+
143
+ // Goose (OpenAI + Claude)
144
+ for (const model of config.MODELS.openai.available) {
145
+ tests.push({
146
+ tool: 'jbai-goose',
147
+ modelId: model,
148
+ cmd: process.execPath,
149
+ args: [path.join(BIN_DIR, 'jbai-goose.js'), '--super', 'run', '--provider', 'openai', '--model', model, '--with-builtin', 'code_execution', '-t', PROMPT],
150
+ timeoutMs: FAST_TIMEOUT_MS,
151
+ });
152
+ }
153
+ for (const model of config.MODELS.claude.available) {
154
+ tests.push({
155
+ tool: 'jbai-goose',
156
+ modelId: model,
157
+ cmd: process.execPath,
158
+ args: [path.join(BIN_DIR, 'jbai-goose.js'), '--super', 'run', '--provider', 'anthropic', '--model', model, '--with-builtin', 'code_execution', '-t', PROMPT],
159
+ timeoutMs: FAST_TIMEOUT_MS,
160
+ });
161
+ }
162
+
163
+ // Continue CLI
164
+ for (const model of config.MODELS.claude.available) {
165
+ tests.push({
166
+ tool: 'jbai-continue',
167
+ modelId: model,
168
+ cmd: process.execPath,
169
+ args: [path.join(BIN_DIR, 'jbai-continue.js'), '--super', '--model', model, '-p', PROMPT],
170
+ });
171
+ }
172
+ for (const model of config.MODELS.openai.available) {
173
+ tests.push({
174
+ tool: 'jbai-continue',
175
+ modelId: model,
176
+ cmd: process.execPath,
177
+ args: [path.join(BIN_DIR, 'jbai-continue.js'), '--super', '--model', model, '-p', PROMPT],
178
+ });
179
+ }
180
+
181
+ return tests;
182
+ }
183
+
184
+ function main() {
185
+ ensureDir(BASE_DIR);
186
+
187
+ const tests = buildTests();
188
+ const results = [];
189
+ const skippedTools = new Map();
190
+
191
+ console.log(`Running ${tests.length} CLI task tests...`);
192
+ console.log(`Artifacts: ${BASE_DIR}`);
193
+ console.log('');
194
+
195
+ for (const test of tests) {
196
+ if (skippedTools.has(test.tool)) {
197
+ results.push({
198
+ tool: test.tool,
199
+ model: test.modelId,
200
+ ok: false,
201
+ skipped: true,
202
+ reason: skippedTools.get(test.tool),
203
+ });
204
+ continue;
205
+ }
206
+
207
+ const label = `${test.tool} :: ${test.modelId}`;
208
+ process.stdout.write(`${label} ... `);
209
+
210
+ const result = runTest(test);
211
+ const output = result.output || '';
212
+
213
+ if (result.ok) {
214
+ console.log('OK');
215
+ results.push({ tool: test.tool, model: test.modelId, ok: true, dir: result.dir });
216
+ continue;
217
+ }
218
+
219
+ let reason = 'unknown';
220
+ if (result.timedOut) {
221
+ reason = 'timeout';
222
+ } else if (result.status !== 0) {
223
+ reason = `exit ${result.status}`;
224
+ } else if (!hasRequiredFiles(result.dir)) {
225
+ reason = 'files missing';
226
+ }
227
+
228
+ console.log(`FAIL (${reason})`);
229
+ results.push({ tool: test.tool, model: test.modelId, ok: false, reason, output, dir: result.dir });
230
+
231
+ if (detectToolLimitation(output)) {
232
+ skippedTools.set(test.tool, 'missing file-write tooling');
233
+ } else if (test.tool === 'jbai-goose' && reason === 'files missing') {
234
+ skippedTools.set(test.tool, 'missing file-write tooling');
235
+ } else if (test.tool === 'jbai-goose' && reason === 'timeout') {
236
+ skippedTools.set(test.tool, 'likely missing file-write tooling (timeout)');
237
+ }
238
+ }
239
+
240
+ const summary = results.reduce((acc, r) => {
241
+ acc.total += 1;
242
+ if (r.ok) acc.passed += 1;
243
+ else if (r.skipped) acc.skipped += 1;
244
+ else acc.failed += 1;
245
+ return acc;
246
+ }, { total: 0, passed: 0, failed: 0, skipped: 0 });
247
+
248
+ console.log('\nSummary');
249
+ console.log(`Total: ${summary.total}`);
250
+ console.log(`Passed: ${summary.passed}`);
251
+ console.log(`Failed: ${summary.failed}`);
252
+ console.log(`Skipped: ${summary.skipped}`);
253
+
254
+ const failures = results.filter(r => !r.ok && !r.skipped);
255
+ if (failures.length > 0) {
256
+ console.log('\nFailures:');
257
+ for (const fail of failures) {
258
+ console.log(`- ${fail.tool} :: ${fail.model} (${fail.reason})`);
259
+ }
260
+ }
261
+
262
+ const skipped = results.filter(r => r.skipped);
263
+ if (skipped.length > 0) {
264
+ const byTool = skipped.reduce((acc, r) => {
265
+ if (!acc[r.tool]) acc[r.tool] = r.reason || 'skipped';
266
+ return acc;
267
+ }, {});
268
+ console.log('\nSkipped tools:');
269
+ for (const [tool, reason] of Object.entries(byTool)) {
270
+ console.log(`- ${tool}: ${reason}`);
271
+ }
272
+ }
273
+
274
+ if (summary.failed > 0) {
275
+ process.exit(1);
276
+ }
277
+ }
278
+
279
+ main();
@@ -98,11 +98,9 @@ async function testClaude(model) {
98
98
  }
99
99
  }
100
100
 
101
- // Test Codex (OpenAI Responses API - used by codex CLI)
102
- async function testCodex(model) {
101
+ // Test OpenAI Chat (used by OpenCode)
102
+ async function testOpenAIChat(model) {
103
103
  try {
104
- // Codex uses the OpenAI "responses" API format
105
- // For testing, we use chat/completions which is what the proxy supports
106
104
  const needsCompletionTokens = model.startsWith('gpt-5') || model.startsWith('o1') ||
107
105
  model.startsWith('o3') || model.startsWith('o4');
108
106
 
@@ -136,10 +134,44 @@ async function testCodex(model) {
136
134
  }
137
135
  }
138
136
 
137
+ // Test Codex (OpenAI Responses API - used by codex CLI)
138
+ async function testCodex(model) {
139
+ try {
140
+ const result = await httpPost(
141
+ `${endpoints.openai}/responses`,
142
+ {
143
+ model: model,
144
+ input: 'Reply with exactly: JBAI_OK',
145
+ max_output_tokens: 64
146
+ },
147
+ { 'Grazie-Authenticate-JWT': token }
148
+ );
149
+
150
+ if (result.status === 200) {
151
+ const outputs = Array.isArray(result.data?.output) ? result.data.output : [];
152
+ const text = outputs
153
+ .flatMap(item => Array.isArray(item?.content) ? item.content : [])
154
+ .map(part => part?.text)
155
+ .filter(Boolean)
156
+ .join('\n');
157
+
158
+ if (text.includes('JBAI_OK')) {
159
+ return { success: true, message: 'OK', response: text };
160
+ }
161
+ return { success: true, message: 'OK (response varied)', response: text };
162
+ } else if (result.status === 429) {
163
+ return { success: true, message: 'Rate limited (model works)', error: 'Rate limit' };
164
+ } else {
165
+ return { success: false, message: `Status ${result.status}`, error: result.data.error?.message || JSON.stringify(result.data).substring(0, 100) };
166
+ }
167
+ } catch (e) {
168
+ return { success: false, message: 'Error', error: e.message };
169
+ }
170
+ }
171
+
139
172
  // Test OpenCode (OpenAI Chat Completions API)
140
173
  async function testOpenCode(model) {
141
- // OpenCode uses the same OpenAI API as Codex
142
- return testCodex(model);
174
+ return testOpenAIChat(model);
143
175
  }
144
176
 
145
177
  // Test Gemini (Vertex AI API)
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * E2E Model List Testing Script
5
+ * Verifies each jbai client exposes only Grazie models in its model list output.
6
+ */
7
+
8
+ const { execFileSync } = require('child_process');
9
+ const path = require('path');
10
+ const { getGroupsForTool } = require('../lib/model-list');
11
+
12
+ const tools = [
13
+ { key: 'claude', script: 'jbai-claude.js' },
14
+ { key: 'codex', script: 'jbai-codex.js' },
15
+ { key: 'gemini', script: 'jbai-gemini.js' },
16
+ { key: 'opencode', script: 'jbai-opencode.js' },
17
+ { key: 'goose', script: 'jbai-goose.js' },
18
+ { key: 'continue', script: 'jbai-continue.js' },
19
+ ];
20
+
21
+ const colors = {
22
+ reset: '\x1b[0m',
23
+ green: '\x1b[32m',
24
+ red: '\x1b[31m',
25
+ cyan: '\x1b[36m',
26
+ dim: '\x1b[2m',
27
+ };
28
+
29
+ function parseModels(output) {
30
+ return output
31
+ .split('\n')
32
+ .map(line => line.trim())
33
+ .filter(line => line.startsWith('- '))
34
+ .map(line => line.slice(2).replace(/\s+\(default\)$/, ''))
35
+ .filter(Boolean);
36
+ }
37
+
38
+ function expectedModels(toolKey) {
39
+ const groups = getGroupsForTool(toolKey);
40
+ return groups.flatMap(group => group.models);
41
+ }
42
+
43
+ function runTool(script) {
44
+ const scriptPath = path.join(__dirname, script);
45
+ return execFileSync(process.execPath, [scriptPath, '--models'], { encoding: 'utf-8' });
46
+ }
47
+
48
+ function diffModels(expected, actual) {
49
+ const expectedSet = new Set(expected);
50
+ const actualSet = new Set(actual);
51
+ const missing = expected.filter(m => !actualSet.has(m));
52
+ const extra = actual.filter(m => !expectedSet.has(m));
53
+ return { missing, extra };
54
+ }
55
+
56
+ async function run() {
57
+ console.log(`\n${'='.repeat(70)}`);
58
+ console.log(`${colors.cyan}JBAI-CLI MODEL LIST E2E${colors.reset}`);
59
+ console.log(`${'='.repeat(70)}\n`);
60
+
61
+ let failures = 0;
62
+
63
+ for (const tool of tools) {
64
+ const expected = expectedModels(tool.key);
65
+ process.stdout.write(`Testing jbai-${tool.key} models... `);
66
+ try {
67
+ const output = runTool(tool.script);
68
+ const actual = parseModels(output);
69
+ const { missing, extra } = diffModels(expected, actual);
70
+ if (missing.length === 0 && extra.length === 0) {
71
+ console.log(`${colors.green}✓ OK${colors.reset}`);
72
+ } else {
73
+ failures += 1;
74
+ console.log(`${colors.red}✗ Mismatch${colors.reset}`);
75
+ if (missing.length > 0) {
76
+ console.log(` ${colors.dim}Missing:${colors.reset} ${missing.join(', ')}`);
77
+ }
78
+ if (extra.length > 0) {
79
+ console.log(` ${colors.dim}Extra:${colors.reset} ${extra.join(', ')}`);
80
+ }
81
+ }
82
+ } catch (err) {
83
+ failures += 1;
84
+ console.log(`${colors.red}✗ Error${colors.reset}`);
85
+ const msg = err && err.message ? err.message : String(err);
86
+ console.log(` ${colors.dim}${msg}${colors.reset}`);
87
+ }
88
+ }
89
+
90
+ console.log(`\n${'='.repeat(70)}\n`);
91
+
92
+ if (failures > 0) {
93
+ process.exit(1);
94
+ }
95
+ }
96
+
97
+ run().catch((err) => {
98
+ console.error(err);
99
+ process.exit(1);
100
+ });