vexi-cli 0.5.5 → 0.9.0

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/dist/cli.js CHANGED
@@ -17,8 +17,11 @@
17
17
  * vexi learn [--apply] learn your coding style from past sessions
18
18
  */
19
19
  import { Command } from 'commander';
20
+ import { confirm } from '@inquirer/prompts';
21
+ import { VERSION } from './version.js';
20
22
  import ora from 'ora';
21
- import { runAgent } from './agent.js';
23
+ import { checkForUpdate, runUpdate, runUninstall } from './update/index.js';
24
+ import { runAgent, runPrint } from './agent.js';
22
25
  import { loadConfig, resetConfig, CONFIG_PATH } from './config.js';
23
26
  import { loadSkills, addSkill, removeSkill } from './skills/index.js';
24
27
  import { listSessions } from './replay/recorder.js';
@@ -28,11 +31,13 @@ import { buildGraph } from './graph/index.js';
28
31
  import { exportGraphHtml } from './graph/html.js';
29
32
  import { loadMcpConfig, saveMcpConfig, MCP_CONFIG_PATH } from './mcp/config.js';
30
33
  import { learn, applyLearned, DEFAULT_MAX_SESSIONS } from './learn/index.js';
31
- import { createProvider, PROVIDER_INFO } from './providers/index.js';
34
+ import { createProviderFromConfig, PROVIDER_INFO } from './providers/index.js';
35
+ import { runOnboarding } from './onboarding.js';
36
+ import * as nodeRlPromises from 'node:readline/promises';
32
37
  import { openInDefaultApp } from './utils/open.js';
38
+ import { SnapshotManager } from './snapshots/index.js';
33
39
  import { detectSystemLang, getStrings, normalizeLang, t, SUPPORTED_LANGS } from './i18n/index.js';
34
40
  import { accent, dim, err, ok } from './ui/index.js';
35
- export const VERSION = '0.5.5';
36
41
  /** Resolve the active language: --lang flag > saved config > system locale. */
37
42
  async function resolveLang(flag) {
38
43
  if (flag) {
@@ -53,16 +58,25 @@ export function buildCli() {
53
58
  .description('Open-source AI coding agent for your terminal. BYOK, zero config, multilingual.')
54
59
  .version(VERSION, '-v, --version')
55
60
  .option('-l, --lang <lang>', `UI language (${SUPPORTED_LANGS.join('/')})`)
61
+ .option('-p, --print <prompt>', 'non-interactive: send one prompt, print the reply, exit (for scripts/CI)')
62
+ .option('-y, --yes', 'with --print, auto-run any shell commands the model proposes (no confirmation)')
56
63
  .option('--mcp-server', 'run Vexi as an MCP server over stdio (for Claude Desktop, Cursor, etc.)')
64
+ .option('--no-update-check', 'skip the daily background update check')
57
65
  .action(async (options) => {
58
66
  if (options.mcpServer) {
59
- // stdout becomes the JSON-RPC channel no banner, no prompts.
67
+ // stdout becomes the JSON-RPC channel -- no banner, no prompts.
60
68
  const { runMcpServer } = await import('./mcp/server.js');
61
69
  await runMcpServer();
62
70
  return;
63
71
  }
64
72
  const lang = await resolveLang(options.lang);
65
- await runAgent({ lang, version: VERSION });
73
+ if (options.print) {
74
+ await runPrint({ prompt: options.print, lang, autoYes: !!options.yes });
75
+ return;
76
+ }
77
+ const noUpdateCheck = !options.updateCheck || !!process.env['VEXI_NO_UPDATE_CHECK'];
78
+ const updateCheckPromise = noUpdateCheck ? Promise.resolve(null) : checkForUpdate();
79
+ await runAgent({ lang, version: VERSION, updateCheckPromise });
66
80
  });
67
81
  const config = program.command('config').description('Manage Vexi configuration');
68
82
  config
@@ -72,12 +86,16 @@ export function buildCli() {
72
86
  const cfg = await loadConfig();
73
87
  console.log(dim('config: ') + accent(CONFIG_PATH));
74
88
  if (cfg) {
75
- console.log(dim('provider: ') + accent(PROVIDER_INFO[cfg.provider].label));
76
- console.log(dim('model: ') + accent(cfg.model ?? PROVIDER_INFO[cfg.provider].defaultModel));
89
+ const providerLabel = cfg.displayName ?? PROVIDER_INFO[cfg.provider]?.label ?? cfg.provider;
90
+ console.log(dim('provider: ') + accent(providerLabel));
91
+ const defaultModel = PROVIDER_INFO[cfg.provider]?.defaultModel ?? 'unknown';
92
+ console.log(dim('model: ') + accent(cfg.model ?? defaultModel));
93
+ if (cfg.baseUrl)
94
+ console.log(dim('baseUrl: ') + accent(cfg.baseUrl));
77
95
  console.log(dim('lang: ') + accent(cfg.lang ?? 'auto'));
78
96
  }
79
97
  else {
80
- console.log(dim('No configuration yet — run `vexi` to set up.'));
98
+ console.log(dim('No configuration yet — run `vexi` or `vexi setup` to configure.'));
81
99
  }
82
100
  });
83
101
  config
@@ -112,7 +130,15 @@ export function buildCli() {
112
130
  .action(async (source) => {
113
131
  const s = getStrings(await resolveLang());
114
132
  try {
115
- const name = await addSkill(process.cwd(), source);
133
+ const name = await addSkill(process.cwd(), source, {
134
+ confirmRemoteContent: async (content, skillName, sourceUrl) => {
135
+ console.log(dim(`\nFetched from ${sourceUrl} — this will be added to every prompt as an instruction the AI must follow:\n`));
136
+ console.log(dim('─'.repeat(60)));
137
+ console.log(content.slice(0, 2000) + (content.length > 2000 ? dim('\n… (truncated)') : ''));
138
+ console.log(dim('─'.repeat(60)));
139
+ return confirm({ message: `Save as skill "${skillName}"?`, default: false });
140
+ },
141
+ });
116
142
  console.log(ok(t(s.skillAdded, { name })));
117
143
  }
118
144
  catch (e) {
@@ -184,7 +210,7 @@ export function buildCli() {
184
210
  process.exitCode = 1;
185
211
  return;
186
212
  }
187
- const provider = createProvider(cfg.provider, cfg.apiKey, cfg.model);
213
+ const provider = createProviderFromConfig(cfg);
188
214
  const spinner = ora({ text: dim(s.explaining), spinner: 'dots' }).start();
189
215
  let started = false;
190
216
  try {
@@ -296,7 +322,7 @@ export function buildCli() {
296
322
  process.exitCode = 1;
297
323
  return;
298
324
  }
299
- const provider = createProvider(cfg.provider, cfg.apiKey, cfg.model);
325
+ const provider = createProviderFromConfig(cfg);
300
326
  const maxSessions = Math.max(1, parseInt(options.sessions ?? '', 10) || DEFAULT_MAX_SESSIONS);
301
327
  const spinner = ora({ text: dim(s.learnAnalyzing), spinner: 'dots' }).start();
302
328
  try {
@@ -329,5 +355,115 @@ export function buildCli() {
329
355
  process.exitCode = 1;
330
356
  }
331
357
  });
358
+ // ── Undo / Redo / History / Clean (Feature 8) ────────────────────────
359
+ program
360
+ .command('undo')
361
+ .description('Revert the last AI file edit in the current session')
362
+ .action(async () => {
363
+ const s = getStrings(await resolveLang());
364
+ const mgr = await SnapshotManager.forCurrentSession(process.cwd());
365
+ if (!mgr) {
366
+ console.log(dim(s.snapshotNoSession));
367
+ return;
368
+ }
369
+ const entry = await mgr.undo().catch(() => null);
370
+ if (!entry) {
371
+ console.log(dim(s.undoNone));
372
+ }
373
+ else {
374
+ console.log(ok(t(s.undoDone, { files: entry.files.join(', ') })));
375
+ }
376
+ });
377
+ program
378
+ .command('redo')
379
+ .description('Re-apply the last undone AI file edit')
380
+ .action(async () => {
381
+ const s = getStrings(await resolveLang());
382
+ const mgr = await SnapshotManager.forCurrentSession(process.cwd());
383
+ if (!mgr) {
384
+ console.log(dim(s.snapshotNoSession));
385
+ return;
386
+ }
387
+ const entry = await mgr.redo().catch(() => null);
388
+ if (!entry) {
389
+ console.log(dim(s.redoNone));
390
+ }
391
+ else {
392
+ console.log(ok(t(s.redoDone, { files: entry.files.join(', ') })));
393
+ }
394
+ });
395
+ program
396
+ .command('history')
397
+ .description('List recent AI file edits in this session (with timestamps)')
398
+ .action(async () => {
399
+ const s = getStrings(await resolveLang());
400
+ const mgr = await SnapshotManager.forCurrentSession(process.cwd());
401
+ if (!mgr) {
402
+ console.log(dim(s.snapshotNoSession));
403
+ return;
404
+ }
405
+ const entries = await mgr.list().catch(() => []);
406
+ if (entries.length === 0) {
407
+ console.log(dim(s.historyNone));
408
+ return;
409
+ }
410
+ console.log(dim(s.historyHeader));
411
+ for (const e of entries) {
412
+ const time = new Date(e.at).toLocaleTimeString();
413
+ console.log(accent(` ${time}`) + dim(` ${e.files.join(', ')}`) + dim(` — ${e.label.slice(0, 60)}`));
414
+ }
415
+ });
416
+ program
417
+ .command('clean')
418
+ .description('Clear old snapshot sessions to free disk space (.vexi/snapshots/)')
419
+ .action(async () => {
420
+ const s = getStrings(await resolveLang());
421
+ const mgr = await SnapshotManager.forCurrentSession(process.cwd());
422
+ const count = await SnapshotManager.cleanAll(process.cwd(), mgr?.sessionId);
423
+ console.log(ok(t(s.cleanDone, { count: String(count) })));
424
+ });
425
+ // ── URL-based provider setup (vexi setup) ────────────────────────────
426
+ program
427
+ .command('setup')
428
+ .description('Configure your API provider by pasting an endpoint URL')
429
+ .action(async () => {
430
+ const io = {
431
+ prompt: async (question) => {
432
+ const iface = nodeRlPromises.createInterface({
433
+ input: process.stdin,
434
+ output: process.stdout,
435
+ });
436
+ try {
437
+ return await iface.question(question);
438
+ }
439
+ finally {
440
+ iface.close();
441
+ }
442
+ },
443
+ write: (line) => console.log(line),
444
+ };
445
+ try {
446
+ await runOnboarding(io);
447
+ }
448
+ catch (e) {
449
+ console.error(err(e instanceof Error ? e.message : String(e)));
450
+ process.exitCode = 1;
451
+ }
452
+ });
453
+ // ── Self-update (vexi update) ─────────────────────────────────────────
454
+ program
455
+ .command('update')
456
+ .description('Update Vexi to the latest version (npm install -g vexi-cli@latest)')
457
+ .action(async () => {
458
+ await runUpdate();
459
+ });
460
+ // ── Self-uninstall (vexi uninstall) ──────────────────────────────────
461
+ program
462
+ .command('uninstall')
463
+ .description('Uninstall Vexi. Keeps ~/.vexi unless --purge is passed.')
464
+ .option('--purge', 'also delete ~/.vexi (config, keys, memory, sessions)')
465
+ .action(async (options) => {
466
+ await runUninstall(!!options.purge);
467
+ });
332
468
  return program;
333
469
  }
package/dist/config.js CHANGED
@@ -10,15 +10,29 @@
10
10
  import { promises as fs } from 'node:fs';
11
11
  import { homedir } from 'node:os';
12
12
  import { join } from 'node:path';
13
+ import { z } from 'zod';
13
14
  import { readJson, writeJsonAtomic } from './utils/fs-atomic.js';
15
+ const VexiConfigSchema = z.object({
16
+ provider: z.string().min(1),
17
+ displayName: z.string().optional(),
18
+ apiKey: z.string().min(1),
19
+ baseUrl: z.string().optional(),
20
+ model: z.string().optional(),
21
+ lang: z.string().optional(),
22
+ });
14
23
  export const VEXI_DIR = join(homedir(), '.vexi');
15
24
  export const CONFIG_PATH = join(VEXI_DIR, 'config.json');
16
- /** Load the config, or `null` if it doesn't exist / is unreadable. */
25
+ /** Load the config, or `null` if it doesn't exist / is unreadable / fails validation. */
17
26
  export async function loadConfig() {
18
- const config = await readJson(CONFIG_PATH);
19
- if (!config?.apiKey || !config.provider)
27
+ const raw = await readJson(CONFIG_PATH);
28
+ if (!raw)
20
29
  return null;
21
- return config;
30
+ const result = VexiConfigSchema.safeParse(raw);
31
+ if (!result.success) {
32
+ process.stderr.write(`[vexi] config validation failed: ${result.error.message}\n`);
33
+ return null;
34
+ }
35
+ return result.data;
22
36
  }
23
37
  /** Save the config atomically with owner-only permissions. */
24
38
  export async function saveConfig(config) {
@@ -0,0 +1,174 @@
1
+ /**
2
+ * URL-based provider identification and model discovery.
3
+ *
4
+ * The user pastes an endpoint URL (e.g. https://openrouter.ai/api/v1).
5
+ * identifyFromUrl() inspects the hostname and path to determine which
6
+ * provider it is and how to authenticate. discoverModels() then calls
7
+ * the /models endpoint to get the live model list.
8
+ */
9
+ const OPERATION_SUFFIXES = [
10
+ '/chat/completions',
11
+ '/completions',
12
+ '/models',
13
+ '/messages',
14
+ ];
15
+ /** Strip trailing slashes and known operation path suffixes so the user can
16
+ * paste a full URL (e.g. .../chat/completions) and still get the API root. */
17
+ function normalizeBase(rawUrl) {
18
+ let url = rawUrl.trim().replace(/\/+$/, '');
19
+ for (const suffix of OPERATION_SUFFIXES) {
20
+ if (url.endsWith(suffix)) {
21
+ url = url.slice(0, url.length - suffix.length);
22
+ break;
23
+ }
24
+ }
25
+ return url;
26
+ }
27
+ function isPrivateIp(host) {
28
+ return (host === 'localhost' ||
29
+ host === '127.0.0.1' ||
30
+ host.endsWith('.local') ||
31
+ /^192\.168\.\d{1,3}\.\d{1,3}$/.test(host) ||
32
+ /^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host));
33
+ }
34
+ /**
35
+ * Identify the provider from a raw endpoint URL.
36
+ * Returns a UrlIdentity describing the provider, normalized base URL,
37
+ * API shape, and where to discover models.
38
+ */
39
+ export function identifyFromUrl(rawUrl) {
40
+ const baseUrl = normalizeBase(rawUrl);
41
+ let parsed;
42
+ try {
43
+ parsed = new URL(baseUrl);
44
+ }
45
+ catch {
46
+ throw new Error(`Invalid URL: ${rawUrl}`);
47
+ }
48
+ const host = parsed.hostname.toLowerCase();
49
+ const path = parsed.pathname;
50
+ // OpenRouter
51
+ if (host === 'openrouter.ai') {
52
+ return {
53
+ provider: 'openrouter',
54
+ displayName: 'OpenRouter',
55
+ baseUrl,
56
+ apiShape: 'openai',
57
+ modelsUrl: `${baseUrl}/models`,
58
+ };
59
+ }
60
+ // Z.ai
61
+ if (host.endsWith('z.ai')) {
62
+ const displayName = path.toLowerCase().includes('/coding/') ? 'Z.ai (Coding Plan)' : 'Z.ai';
63
+ return {
64
+ provider: 'zai',
65
+ displayName,
66
+ baseUrl,
67
+ apiShape: 'openai',
68
+ modelsUrl: `${baseUrl}/models`,
69
+ };
70
+ }
71
+ // Groq
72
+ if (host.endsWith('groq.com')) {
73
+ return {
74
+ provider: 'groq',
75
+ displayName: 'Groq',
76
+ baseUrl,
77
+ apiShape: 'openai',
78
+ modelsUrl: `${baseUrl}/models`,
79
+ };
80
+ }
81
+ // OpenAI
82
+ if (host === 'api.openai.com') {
83
+ return {
84
+ provider: 'openai',
85
+ displayName: 'OpenAI',
86
+ baseUrl,
87
+ apiShape: 'openai',
88
+ modelsUrl: `${baseUrl}/models`,
89
+ };
90
+ }
91
+ // Anthropic
92
+ if (host === 'api.anthropic.com') {
93
+ return {
94
+ provider: 'anthropic',
95
+ displayName: 'Anthropic',
96
+ baseUrl,
97
+ apiShape: 'anthropic',
98
+ modelsUrl: `${baseUrl}/models`,
99
+ };
100
+ }
101
+ // Google Gemini
102
+ if (host.endsWith('generativelanguage.googleapis.com')) {
103
+ return {
104
+ provider: 'gemini',
105
+ displayName: 'Google Gemini',
106
+ baseUrl,
107
+ apiShape: 'openai',
108
+ modelsUrl: `${baseUrl}/models`,
109
+ };
110
+ }
111
+ // Cloudflare Workers AI -- model is embedded in the path after /ai/run/
112
+ if (host.endsWith('api.cloudflare.com') && path.includes('/ai/run/')) {
113
+ const aiRunStart = path.indexOf('/ai/run/') + '/ai/run/'.length;
114
+ let modelFromPath = path.slice(aiRunStart);
115
+ if (modelFromPath.startsWith('@cf/')) {
116
+ modelFromPath = modelFromPath.slice('@cf/'.length);
117
+ }
118
+ // baseUrl is the account endpoint up to /ai/run (without the model)
119
+ const cfBase = `${parsed.protocol}//${parsed.host}${path.slice(0, aiRunStart - 1)}`;
120
+ return {
121
+ provider: 'cloudflare',
122
+ displayName: 'Cloudflare Workers AI',
123
+ baseUrl: cfBase,
124
+ apiShape: 'openai',
125
+ modelsUrl: null,
126
+ modelFromPath: modelFromPath || undefined,
127
+ };
128
+ }
129
+ // Local / private LAN servers (Ollama, LM Studio, vLLM, etc.)
130
+ if (isPrivateIp(host)) {
131
+ return {
132
+ provider: 'local',
133
+ displayName: 'Local (OpenAI-compatible)',
134
+ baseUrl,
135
+ apiShape: 'openai',
136
+ modelsUrl: `${baseUrl}/models`,
137
+ };
138
+ }
139
+ // Unknown / custom endpoint -- assume OpenAI-compatible
140
+ return {
141
+ provider: 'custom',
142
+ displayName: `Custom (${host})`,
143
+ baseUrl,
144
+ apiShape: 'openai',
145
+ modelsUrl: `${baseUrl}/models`,
146
+ };
147
+ }
148
+ /**
149
+ * Discover the available models for a given endpoint.
150
+ *
151
+ * If the URL embeds the model (Cloudflare), returns that model directly.
152
+ * If modelsUrl is null, returns an empty array.
153
+ * Otherwise calls GET /models and maps the response to a list of id strings.
154
+ *
155
+ * Accepts an optional fetchFn for testability (defaults to globalThis.fetch).
156
+ */
157
+ export async function discoverModels(identity, apiKey, fetchFn = globalThis.fetch) {
158
+ if (identity.modelFromPath) {
159
+ return [identity.modelFromPath];
160
+ }
161
+ if (!identity.modelsUrl) {
162
+ return [];
163
+ }
164
+ const headers = identity.apiShape === 'anthropic'
165
+ ? { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' }
166
+ : { Authorization: `Bearer ${apiKey}` };
167
+ const res = await fetchFn(identity.modelsUrl, { headers });
168
+ if (!res.ok) {
169
+ throw new Error(`Provider identified as "${identity.displayName}" but GET /models returned HTTP ${res.status}. ` +
170
+ `Enter the model ID manually.`);
171
+ }
172
+ const json = (await res.json());
173
+ return (json.data ?? []).map((m) => m.id).filter(Boolean);
174
+ }
@@ -0,0 +1,146 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { identifyFromUrl, discoverModels } from './endpoint.js';
3
+ // ── identifyFromUrl ──────────────────────────────────────────────────────────
4
+ describe('identifyFromUrl', () => {
5
+ it('identifies OpenRouter', () => {
6
+ const id = identifyFromUrl('https://openrouter.ai/api/v1');
7
+ expect(id.provider).toBe('openrouter');
8
+ expect(id.displayName).toBe('OpenRouter');
9
+ expect(id.baseUrl).toBe('https://openrouter.ai/api/v1');
10
+ expect(id.apiShape).toBe('openai');
11
+ expect(id.modelsUrl).toBe('https://openrouter.ai/api/v1/models');
12
+ });
13
+ it('strips /chat/completions suffix', () => {
14
+ const id = identifyFromUrl('https://openrouter.ai/api/v1/chat/completions');
15
+ expect(id.baseUrl).toBe('https://openrouter.ai/api/v1');
16
+ });
17
+ it('strips trailing slash', () => {
18
+ const id = identifyFromUrl('https://openrouter.ai/api/v1/');
19
+ expect(id.baseUrl).toBe('https://openrouter.ai/api/v1');
20
+ });
21
+ it('identifies Z.ai coding plan', () => {
22
+ const id = identifyFromUrl('https://api.z.ai/coding/v1');
23
+ expect(id.provider).toBe('zai');
24
+ expect(id.displayName).toBe('Z.ai (Coding Plan)');
25
+ expect(id.apiShape).toBe('openai');
26
+ });
27
+ it('identifies Z.ai generic', () => {
28
+ const id = identifyFromUrl('https://api.z.ai/v1');
29
+ expect(id.provider).toBe('zai');
30
+ expect(id.displayName).toBe('Z.ai');
31
+ });
32
+ it('identifies Anthropic', () => {
33
+ const id = identifyFromUrl('https://api.anthropic.com/v1');
34
+ expect(id.provider).toBe('anthropic');
35
+ expect(id.apiShape).toBe('anthropic');
36
+ expect(id.modelsUrl).toBe('https://api.anthropic.com/v1/models');
37
+ });
38
+ it('identifies OpenAI', () => {
39
+ const id = identifyFromUrl('https://api.openai.com/v1');
40
+ expect(id.provider).toBe('openai');
41
+ expect(id.apiShape).toBe('openai');
42
+ });
43
+ it('identifies Groq', () => {
44
+ const id = identifyFromUrl('https://api.groq.com/openai/v1');
45
+ expect(id.provider).toBe('groq');
46
+ expect(id.displayName).toBe('Groq');
47
+ });
48
+ it('identifies localhost as local', () => {
49
+ const id = identifyFromUrl('http://localhost:11434/v1');
50
+ expect(id.provider).toBe('local');
51
+ expect(id.displayName).toBe('Local (OpenAI-compatible)');
52
+ expect(id.modelsUrl).toBe('http://localhost:11434/v1/models');
53
+ });
54
+ it('identifies 192.168.x.x as local', () => {
55
+ const id = identifyFromUrl('http://192.168.1.100:8080/v1');
56
+ expect(id.provider).toBe('local');
57
+ });
58
+ it('identifies Cloudflare and extracts model from path', () => {
59
+ const id = identifyFromUrl('https://api.cloudflare.com/client/v4/accounts/abc123/ai/run/@cf/meta/llama-3.1-8b');
60
+ expect(id.provider).toBe('cloudflare');
61
+ expect(id.displayName).toBe('Cloudflare Workers AI');
62
+ expect(id.modelsUrl).toBeNull();
63
+ expect(id.modelFromPath).toBe('meta/llama-3.1-8b');
64
+ });
65
+ it('classifies unknown public host as custom', () => {
66
+ const id = identifyFromUrl('https://my-proxy.example.com/v1');
67
+ expect(id.provider).toBe('custom');
68
+ expect(id.displayName).toContain('my-proxy.example.com');
69
+ expect(id.apiShape).toBe('openai');
70
+ });
71
+ it('throws on invalid URL', () => {
72
+ expect(() => identifyFromUrl('not-a-url')).toThrow('Invalid URL');
73
+ });
74
+ });
75
+ // ── discoverModels ────────────────────────────────────────────────────────────
76
+ describe('discoverModels', () => {
77
+ it('returns modelFromPath directly without fetching', async () => {
78
+ const identity = {
79
+ provider: 'cloudflare',
80
+ displayName: 'Cloudflare Workers AI',
81
+ baseUrl: 'https://api.cloudflare.com/client/v4/accounts/abc/ai/run',
82
+ apiShape: 'openai',
83
+ modelsUrl: null,
84
+ modelFromPath: 'meta/llama-3.1-8b',
85
+ };
86
+ const fetchFn = vi.fn();
87
+ const models = await discoverModels(identity, 'key', fetchFn);
88
+ expect(models).toEqual(['meta/llama-3.1-8b']);
89
+ expect(fetchFn).not.toHaveBeenCalled();
90
+ });
91
+ it('returns empty array when modelsUrl is null and no modelFromPath', async () => {
92
+ const identity = {
93
+ provider: 'custom',
94
+ displayName: 'Custom',
95
+ baseUrl: 'https://example.com',
96
+ apiShape: 'openai',
97
+ modelsUrl: null,
98
+ };
99
+ const models = await discoverModels(identity, 'key', vi.fn());
100
+ expect(models).toEqual([]);
101
+ });
102
+ it('fetches /models and maps response data to ids', async () => {
103
+ const identity = {
104
+ provider: 'openrouter',
105
+ displayName: 'OpenRouter',
106
+ baseUrl: 'https://openrouter.ai/api/v1',
107
+ apiShape: 'openai',
108
+ modelsUrl: 'https://openrouter.ai/api/v1/models',
109
+ };
110
+ const mockFetch = vi.fn().mockResolvedValue({
111
+ ok: true,
112
+ json: async () => ({ data: [{ id: 'openai/gpt-4o' }, { id: 'anthropic/claude-3.5-sonnet' }] }),
113
+ });
114
+ const models = await discoverModels(identity, 'sk-test', mockFetch);
115
+ expect(models).toEqual(['openai/gpt-4o', 'anthropic/claude-3.5-sonnet']);
116
+ expect(mockFetch).toHaveBeenCalledWith('https://openrouter.ai/api/v1/models', expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer sk-test' }) }));
117
+ });
118
+ it('uses x-api-key header for Anthropic shape', async () => {
119
+ const identity = {
120
+ provider: 'anthropic',
121
+ displayName: 'Anthropic',
122
+ baseUrl: 'https://api.anthropic.com/v1',
123
+ apiShape: 'anthropic',
124
+ modelsUrl: 'https://api.anthropic.com/v1/models',
125
+ };
126
+ const mockFetch = vi.fn().mockResolvedValue({
127
+ ok: true,
128
+ json: async () => ({ data: [{ id: 'claude-3-5-sonnet-20241022' }] }),
129
+ });
130
+ await discoverModels(identity, 'sk-ant-test', mockFetch);
131
+ expect(mockFetch).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
132
+ headers: expect.objectContaining({ 'x-api-key': 'sk-ant-test' }),
133
+ }));
134
+ });
135
+ it('throws when /models returns non-OK status', async () => {
136
+ const identity = {
137
+ provider: 'groq',
138
+ displayName: 'Groq',
139
+ baseUrl: 'https://api.groq.com/openai/v1',
140
+ apiShape: 'openai',
141
+ modelsUrl: 'https://api.groq.com/openai/v1/models',
142
+ };
143
+ const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 401 });
144
+ await expect(discoverModels(identity, 'bad-key', mockFetch)).rejects.toThrow('HTTP 401');
145
+ });
146
+ });
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { promises as fs } from 'node:fs';
14
14
  import { basename, join, relative, resolve } from 'node:path';
15
+ import { escapeHtml } from '../utils/html.js';
15
16
  /** Limits to keep prompts within context windows. */
16
17
  const MAX_FILE_BYTES = 100 * 1024;
17
18
  const MAX_TOTAL_BYTES = 120 * 1024;
@@ -229,10 +230,3 @@ function inline(text) {
229
230
  .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
230
231
  .replace(/`([^`]+)`/g, '<code>$1</code>');
231
232
  }
232
- function escapeHtml(text) {
233
- return text
234
- .replaceAll('&', '&amp;')
235
- .replaceAll('<', '&lt;')
236
- .replaceAll('>', '&gt;')
237
- .replaceAll('"', '&quot;');
238
- }