vexi-cli 0.5.1

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 ADDED
@@ -0,0 +1,333 @@
1
+ /**
2
+ * CLI definition (commander).
3
+ *
4
+ * vexi start a chat session (first run: BYOK setup)
5
+ * vexi --lang ar override the UI/output language
6
+ * vexi config show config location + provider
7
+ * vexi config reset delete the stored API key & settings
8
+ * vexi skill list show active skills
9
+ * vexi skill add <src> add a skill (local .md file or GitHub URL)
10
+ * vexi skill remove <name> remove a skill
11
+ * vexi replay list recorded sessions
12
+ * vexi replay --export export latest session as standalone HTML
13
+ * vexi explain <path> --ar explain a file/folder in your language
14
+ * vexi graph [--visual] interactive dependency graph (d3 HTML)
15
+ * vexi mcp list/add/remove manage external MCP servers (~/.vexi/mcp.json)
16
+ * vexi --mcp-server expose Vexi as an MCP server (stdio)
17
+ * vexi learn [--apply] learn your coding style from past sessions
18
+ */
19
+ import { Command } from 'commander';
20
+ import ora from 'ora';
21
+ import { runAgent } from './agent.js';
22
+ import { loadConfig, resetConfig, CONFIG_PATH } from './config.js';
23
+ import { loadSkills, addSkill, removeSkill } from './skills/index.js';
24
+ import { listSessions } from './replay/recorder.js';
25
+ import { exportReplay } from './replay/export.js';
26
+ import { explain } from './explain/index.js';
27
+ import { buildGraph } from './graph/index.js';
28
+ import { exportGraphHtml } from './graph/html.js';
29
+ import { loadMcpConfig, saveMcpConfig, MCP_CONFIG_PATH } from './mcp/config.js';
30
+ import { learn, applyLearned, DEFAULT_MAX_SESSIONS } from './learn/index.js';
31
+ import { createProvider, PROVIDER_INFO } from './providers/index.js';
32
+ import { openInDefaultApp } from './utils/open.js';
33
+ import { detectSystemLang, getStrings, normalizeLang, t, SUPPORTED_LANGS } from './i18n/index.js';
34
+ import { accent, dim, err, ok } from './ui/index.js';
35
+ export const VERSION = '0.5.0';
36
+ /** Resolve the active language: --lang flag > saved config > system locale. */
37
+ async function resolveLang(flag) {
38
+ if (flag) {
39
+ const lang = normalizeLang(flag);
40
+ if (!lang) {
41
+ console.error(err(`Unsupported language "${flag}". Supported: ${SUPPORTED_LANGS.join(', ')}`));
42
+ process.exit(1);
43
+ }
44
+ return lang;
45
+ }
46
+ const config = await loadConfig();
47
+ return normalizeLang(config?.lang) ?? detectSystemLang();
48
+ }
49
+ export function buildCli() {
50
+ const program = new Command();
51
+ program
52
+ .name('vexi')
53
+ .description('Open-source AI coding agent for your terminal. BYOK, zero config, multilingual.')
54
+ .version(VERSION, '-v, --version')
55
+ .option('-l, --lang <lang>', `UI language (${SUPPORTED_LANGS.join('/')})`)
56
+ .option('--mcp-server', 'run Vexi as an MCP server over stdio (for Claude Desktop, Cursor, etc.)')
57
+ .action(async (options) => {
58
+ if (options.mcpServer) {
59
+ // stdout becomes the JSON-RPC channel — no banner, no prompts.
60
+ const { runMcpServer } = await import('./mcp/server.js');
61
+ await runMcpServer();
62
+ return;
63
+ }
64
+ const lang = await resolveLang(options.lang);
65
+ await runAgent({ lang, version: VERSION });
66
+ });
67
+ const config = program.command('config').description('Manage Vexi configuration');
68
+ config
69
+ .command('show', { isDefault: true })
70
+ .description('Show config location and current provider')
71
+ .action(async () => {
72
+ const cfg = await loadConfig();
73
+ console.log(dim('config: ') + accent(CONFIG_PATH));
74
+ 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));
77
+ console.log(dim('lang: ') + accent(cfg.lang ?? 'auto'));
78
+ }
79
+ else {
80
+ console.log(dim('No configuration yet — run `vexi` to set up.'));
81
+ }
82
+ });
83
+ config
84
+ .command('reset')
85
+ .description('Delete the stored API key and settings')
86
+ .action(async () => {
87
+ const lang = await resolveLang();
88
+ const s = getStrings(lang);
89
+ const deleted = await resetConfig();
90
+ console.log(deleted ? ok(s.configReset) : dim(s.configResetNone));
91
+ });
92
+ // ── Custom Skills (Feature 2.5) ──────────────────────────────────────
93
+ const skill = program.command('skill').description('Manage project skills (.vexi/skills/*.md)');
94
+ skill
95
+ .command('list', { isDefault: true })
96
+ .description('Show active skills')
97
+ .action(async () => {
98
+ const s = getStrings(await resolveLang());
99
+ const skills = await loadSkills(process.cwd());
100
+ if (skills.length === 0) {
101
+ console.log(dim(s.skillListEmpty));
102
+ return;
103
+ }
104
+ for (const sk of skills) {
105
+ const firstLine = sk.content.split('\n')[0].replace(/^#+\s*/, '').slice(0, 80);
106
+ console.log(accent(sk.name) + dim(` — ${firstLine}`));
107
+ }
108
+ });
109
+ skill
110
+ .command('add <source>')
111
+ .description('Add a skill from a local .md file or a GitHub URL')
112
+ .action(async (source) => {
113
+ const s = getStrings(await resolveLang());
114
+ try {
115
+ const name = await addSkill(process.cwd(), source);
116
+ console.log(ok(t(s.skillAdded, { name })));
117
+ }
118
+ catch (e) {
119
+ console.error(err(e instanceof Error ? e.message : String(e)));
120
+ process.exitCode = 1;
121
+ }
122
+ });
123
+ skill
124
+ .command('remove <name>')
125
+ .description('Remove a skill by name')
126
+ .action(async (name) => {
127
+ const s = getStrings(await resolveLang());
128
+ const removed = await removeSkill(process.cwd(), name);
129
+ console.log(removed ? ok(t(s.skillRemoved, { name })) : err(t(s.skillNotFound, { name })));
130
+ if (!removed)
131
+ process.exitCode = 1;
132
+ });
133
+ // ── Vexi Replay (Feature 3) ─────────────────────────────────────────
134
+ program
135
+ .command('replay')
136
+ .description('List recorded sessions, or export one as a standalone HTML replay')
137
+ .option('-e, --export', 'export a session as HTML')
138
+ .option('-s, --session <name>', 'session file name (default: most recent)')
139
+ .option('-o, --out <file>', 'output HTML path')
140
+ .option('-l, --lang <lang>', `replay language (${SUPPORTED_LANGS.join('/')})`)
141
+ .action(async (options) => {
142
+ const lang = await resolveLang(options.lang);
143
+ const s = getStrings(lang);
144
+ if (!options.export) {
145
+ const sessions = await listSessions(process.cwd());
146
+ if (sessions.length === 0) {
147
+ console.log(dim(s.replayNone));
148
+ return;
149
+ }
150
+ for (const file of sessions)
151
+ console.log(accent(file));
152
+ console.log(dim('\nvexi replay --export [--session <name>] [--lang ar]'));
153
+ return;
154
+ }
155
+ try {
156
+ const path = await exportReplay(process.cwd(), {
157
+ lang,
158
+ session: options.session,
159
+ out: options.out,
160
+ });
161
+ console.log(ok(t(s.replayExported, { path })));
162
+ openInDefaultApp(path);
163
+ }
164
+ catch (e) {
165
+ console.error(err(e instanceof Error ? e.message : String(e)));
166
+ process.exitCode = 1;
167
+ }
168
+ });
169
+ // ── Multilingual explain (Feature 4) ─────────────────────────────────
170
+ program
171
+ .command('explain <path>')
172
+ .description('Explain a file or folder in your language (Arabic opens as RTL HTML)')
173
+ .option('-l, --lang <lang>', `output language (${SUPPORTED_LANGS.join('/')})`)
174
+ .option('--ar', 'Arabic').option('--en', 'English').option('--es', 'Spanish')
175
+ .option('--pt', 'Portuguese').option('--fr', 'French')
176
+ .action(async (target, options) => {
177
+ // Shorthand flags (--ar) win over --lang, which wins over config/system.
178
+ const short = SUPPORTED_LANGS.find((l) => options[l] === true);
179
+ const lang = short ?? (await resolveLang(options.lang));
180
+ const s = getStrings(lang);
181
+ const cfg = await loadConfig();
182
+ if (!cfg) {
183
+ console.error(err('No API key configured — run `vexi` once to set up.'));
184
+ process.exitCode = 1;
185
+ return;
186
+ }
187
+ const provider = createProvider(cfg.provider, cfg.apiKey, cfg.model);
188
+ const spinner = ora({ text: dim(s.explaining), spinner: 'dots' }).start();
189
+ let started = false;
190
+ try {
191
+ const result = await explain(provider, target, lang, (chunk) => {
192
+ if (!started) {
193
+ spinner.stop();
194
+ started = true;
195
+ }
196
+ process.stdout.write(chunk);
197
+ });
198
+ spinner.stop();
199
+ if (result) {
200
+ // Arabic → written to RTL HTML + markdown, opened in the browser
201
+ console.log(ok(t(s.explainSavedFile, { path: result.htmlPath })));
202
+ console.log(dim(result.mdPath));
203
+ openInDefaultApp(result.htmlPath);
204
+ }
205
+ else {
206
+ process.stdout.write('\n');
207
+ }
208
+ }
209
+ catch (e) {
210
+ spinner.stop();
211
+ console.error(err(e instanceof Error ? e.message : String(e)));
212
+ process.exitCode = 1;
213
+ }
214
+ });
215
+ // ── Visual code graph (Feature 5) ─────────────────────────────────────
216
+ program
217
+ .command('graph')
218
+ .description('Generate an interactive dependency graph (single HTML, opens in browser)')
219
+ .option('--visual', 'open the graph in the browser (default behavior)')
220
+ .option('-o, --out <file>', 'output HTML path')
221
+ .option('-l, --lang <lang>', `page language (${SUPPORTED_LANGS.join('/')})`)
222
+ .action(async (options) => {
223
+ const lang = await resolveLang(options.lang);
224
+ const s = getStrings(lang);
225
+ const spinner = ora({ text: dim(s.graphBuilding), spinner: 'dots' }).start();
226
+ try {
227
+ const graph = await buildGraph(process.cwd());
228
+ const path = await exportGraphHtml(process.cwd(), graph, lang, options.out);
229
+ spinner.stop();
230
+ console.log(ok(t(s.graphExported, { path })));
231
+ console.log(dim(`${graph.nodes.length} modules · ${graph.edges.length} imports`));
232
+ openInDefaultApp(path);
233
+ }
234
+ catch (e) {
235
+ spinner.stop();
236
+ console.error(err(e instanceof Error ? e.message : String(e)));
237
+ process.exitCode = 1;
238
+ }
239
+ });
240
+ // ── MCP client config (Feature 6a) ───────────────────────────────────
241
+ const mcp = program.command('mcp').description('Manage external MCP servers (~/.vexi/mcp.json)');
242
+ mcp
243
+ .command('list', { isDefault: true })
244
+ .description('Show configured MCP servers')
245
+ .action(async () => {
246
+ const s = getStrings(await resolveLang());
247
+ const config = await loadMcpConfig();
248
+ const entries = Object.entries(config.mcpServers);
249
+ console.log(dim('config: ') + accent(MCP_CONFIG_PATH));
250
+ if (entries.length === 0) {
251
+ console.log(dim(s.mcpListEmpty));
252
+ return;
253
+ }
254
+ for (const [name, server] of entries) {
255
+ console.log(accent(name) + dim(` — ${server.command} ${(server.args ?? []).join(' ')}`));
256
+ }
257
+ });
258
+ mcp
259
+ .command('add <name> <command> [args...]')
260
+ .description('Add an MCP server (e.g. vexi mcp add github npx -y @modelcontextprotocol/server-github)')
261
+ .action(async (name, command, args) => {
262
+ const s = getStrings(await resolveLang());
263
+ const config = await loadMcpConfig();
264
+ config.mcpServers[name] = { command, ...(args.length ? { args } : {}) };
265
+ await saveMcpConfig(config);
266
+ console.log(ok(t(s.mcpAdded, { name })));
267
+ });
268
+ mcp
269
+ .command('remove <name>')
270
+ .description('Remove an MCP server')
271
+ .action(async (name) => {
272
+ const s = getStrings(await resolveLang());
273
+ const config = await loadMcpConfig();
274
+ if (!config.mcpServers[name]) {
275
+ console.error(err(t(s.mcpNotFound, { name })));
276
+ process.exitCode = 1;
277
+ return;
278
+ }
279
+ delete config.mcpServers[name];
280
+ await saveMcpConfig(config);
281
+ console.log(ok(t(s.mcpRemoved, { name })));
282
+ });
283
+ // ── Vexi Learn (Feature 7) ──────────────────────────────────────
284
+ program
285
+ .command('learn')
286
+ .description('Learn your personal coding style from past sessions and turn it into a skill')
287
+ .option('-a, --apply', 'save the result as .vexi/skills/learned-style.md')
288
+ .option('-n, --sessions <count>', 'number of recent sessions to analyze', String(DEFAULT_MAX_SESSIONS))
289
+ .option('-l, --lang <lang>', `UI language (${SUPPORTED_LANGS.join('/')})`)
290
+ .action(async (options) => {
291
+ const lang = await resolveLang(options.lang);
292
+ const s = getStrings(lang);
293
+ const cfg = await loadConfig();
294
+ if (!cfg) {
295
+ console.error(err('No API key configured — run `vexi` once to set up.'));
296
+ process.exitCode = 1;
297
+ return;
298
+ }
299
+ const provider = createProvider(cfg.provider, cfg.apiKey, cfg.model);
300
+ const maxSessions = Math.max(1, parseInt(options.sessions ?? '', 10) || DEFAULT_MAX_SESSIONS);
301
+ const spinner = ora({ text: dim(s.learnAnalyzing), spinner: 'dots' }).start();
302
+ try {
303
+ const result = await learn(provider, process.cwd(), maxSessions);
304
+ spinner.stop();
305
+ if (result.evidence.sessionsAnalyzed === 0) {
306
+ console.log(dim(s.learnNoSessions));
307
+ return;
308
+ }
309
+ if (!result.markdown) {
310
+ console.log(dim(s.learnNothing));
311
+ return;
312
+ }
313
+ console.log(ok(t(s.learnPreview, {
314
+ sessions: String(result.evidence.sessionsAnalyzed),
315
+ signals: String(result.evidence.signals.length),
316
+ })));
317
+ console.log('\n' + result.markdown + '\n');
318
+ if (options.apply) {
319
+ const path = await applyLearned(process.cwd(), result.markdown);
320
+ console.log(ok(t(s.learnApplied, { path })));
321
+ }
322
+ else {
323
+ console.log(dim(s.learnApplyHint));
324
+ }
325
+ }
326
+ catch (e) {
327
+ spinner.stop();
328
+ console.error(err(e instanceof Error ? e.message : String(e)));
329
+ process.exitCode = 1;
330
+ }
331
+ });
332
+ return program;
333
+ }
package/dist/config.js ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Local configuration: ~/.vexi/config.json
3
+ *
4
+ * Stores the API key, provider, model and language preference.
5
+ * - Written atomically (temp file + rename) to avoid corruption.
6
+ * - File mode 0o600 so the key is only readable by the current OS user
7
+ * (chmod is a no-op on Windows, where the user profile dir is already
8
+ * protected by NTFS ACLs).
9
+ */
10
+ import { promises as fs } from 'node:fs';
11
+ import { homedir } from 'node:os';
12
+ import { join } from 'node:path';
13
+ import { readJson, writeJsonAtomic } from './utils/fs-atomic.js';
14
+ export const VEXI_DIR = join(homedir(), '.vexi');
15
+ export const CONFIG_PATH = join(VEXI_DIR, 'config.json');
16
+ /** Load the config, or `null` if it doesn't exist / is unreadable. */
17
+ export async function loadConfig() {
18
+ const config = await readJson(CONFIG_PATH);
19
+ if (!config?.apiKey || !config.provider)
20
+ return null;
21
+ return config;
22
+ }
23
+ /** Save the config atomically with owner-only permissions. */
24
+ export async function saveConfig(config) {
25
+ await writeJsonAtomic(CONFIG_PATH, config, { mode: 0o600, dirMode: 0o700 });
26
+ }
27
+ /** Delete the stored config (used by `vexi config reset`). */
28
+ export async function resetConfig() {
29
+ try {
30
+ await fs.unlink(CONFIG_PATH);
31
+ return true;
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ }
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Multilingual code explanation — Feature 4.
3
+ *
4
+ * vexi explain auth.ts --ar → explains the file in Arabic
5
+ * vexi explain src/ --es → explains a folder in Spanish
6
+ *
7
+ * Output strategy (terminal RTL limitation):
8
+ * - Latin-script languages (en/es/pt/fr): streamed directly to the terminal.
9
+ * - Arabic: terminals render RTL text broken, so the explanation is written
10
+ * to a generated .html file (dir="rtl", renders perfectly) and opened in
11
+ * the default browser. The raw markdown is saved alongside as .md.
12
+ */
13
+ import { promises as fs } from 'node:fs';
14
+ import { basename, join, relative, resolve } from 'node:path';
15
+ /** Limits to keep prompts within context windows. */
16
+ const MAX_FILE_BYTES = 100 * 1024;
17
+ const MAX_TOTAL_BYTES = 120 * 1024;
18
+ const MAX_FILES = 20;
19
+ /** Source-code extensions considered when explaining a folder. */
20
+ const CODE_EXTS = new Set([
21
+ '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '.go', '.rs', '.java',
22
+ '.kt', '.rb', '.php', '.cs', '.cpp', '.c', '.h', '.swift', '.vue', '.svelte',
23
+ '.sql', '.sh', '.css', '.scss', '.html', '.json', '.yml', '.yaml', '.toml', '.md',
24
+ ]);
25
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.vexi', '.next', 'out']);
26
+ /** Collect source files from a file or directory path, with size caps. */
27
+ export async function gatherSource(target) {
28
+ const abs = resolve(target);
29
+ const stat = await fs.stat(abs); // throws a clear ENOENT for bad paths
30
+ const files = [];
31
+ let total = 0;
32
+ let truncated = false;
33
+ async function addFile(path, rel) {
34
+ if (files.length >= MAX_FILES || total >= MAX_TOTAL_BYTES) {
35
+ truncated = true;
36
+ return;
37
+ }
38
+ const s = await fs.stat(path).catch(() => null);
39
+ if (!s || s.size > MAX_FILE_BYTES) {
40
+ if (s)
41
+ truncated = true;
42
+ return;
43
+ }
44
+ const content = await fs.readFile(path, 'utf8').catch(() => null);
45
+ if (content === null)
46
+ return;
47
+ total += content.length;
48
+ files.push({ rel, content });
49
+ }
50
+ if (stat.isFile()) {
51
+ await addFile(abs, basename(abs));
52
+ }
53
+ else {
54
+ async function walk(dir) {
55
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
56
+ for (const entry of entries) {
57
+ if (files.length >= MAX_FILES || total >= MAX_TOTAL_BYTES) {
58
+ truncated = true;
59
+ return;
60
+ }
61
+ const path = join(dir, entry.name);
62
+ if (entry.isDirectory()) {
63
+ if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith('.'))
64
+ await walk(path);
65
+ }
66
+ else if (entry.isFile()) {
67
+ const ext = entry.name.slice(entry.name.lastIndexOf('.'));
68
+ if (CODE_EXTS.has(ext)) {
69
+ await addFile(path, relative(abs, path).replaceAll('\\', '/'));
70
+ }
71
+ }
72
+ }
73
+ }
74
+ await walk(abs);
75
+ }
76
+ if (files.length === 0) {
77
+ throw new Error(`No readable source files found at: ${target}`);
78
+ }
79
+ return { files, truncated };
80
+ }
81
+ const LANG_NAMES = {
82
+ en: 'English',
83
+ ar: 'Modern Standard Arabic (العربية الفصحى)',
84
+ es: 'Spanish',
85
+ pt: 'Portuguese',
86
+ fr: 'French',
87
+ };
88
+ /** Build the explanation prompt for the model. */
89
+ export function buildExplainMessages(source, lang) {
90
+ const code = source.files
91
+ .map((f) => `### FILE: ${f.rel}\n\`\`\`\n${f.content}\n\`\`\``)
92
+ .join('\n\n');
93
+ return [
94
+ {
95
+ role: 'system',
96
+ content: [
97
+ `You are Vexi, an expert code explainer. Write the entire explanation in fluent ${LANG_NAMES[lang]}.`,
98
+ 'Structure the output as Markdown with exactly these parts:',
99
+ '1. A heading with the file/project name, then a short purpose summary.',
100
+ '2. A function-by-function (or section-by-section) breakdown, citing line numbers.',
101
+ '3. A final note on how the pieces fit together (business logic, not just syntax).',
102
+ 'Keep code identifiers, keywords and file names in English; explain around them.',
103
+ lang === 'ar' ? 'اكتب الشرح كاملًا بالعربية الفصحى السليمة.' : '',
104
+ ].filter(Boolean).join('\n'),
105
+ },
106
+ {
107
+ role: 'user',
108
+ content: `Explain the following code:\n\n${code}${source.truncated ? '\n\n(Note: some files were omitted due to size limits.)' : ''}`,
109
+ },
110
+ ];
111
+ }
112
+ /**
113
+ * Run the explanation.
114
+ * - Latin-script langs: streams to `onText` (terminal) and returns null.
115
+ * - Arabic: collects silently, writes .md + .html next to cwd and returns
116
+ * the HTML path for the caller to open.
117
+ */
118
+ export async function explain(provider, target, lang, onText) {
119
+ const source = await gatherSource(target);
120
+ const messages = buildExplainMessages(source, lang);
121
+ if (lang !== 'ar') {
122
+ await provider.stream(messages, onText);
123
+ return null;
124
+ }
125
+ // Arabic → file output (RTL renders perfectly in the browser)
126
+ const markdown = await provider.stream(messages, () => { });
127
+ const base = `vexi-explain-${basename(resolve(target)).replace(/[^a-z0-9_-]+/gi, '-')}`;
128
+ const mdPath = join(process.cwd(), `${base}.md`);
129
+ const htmlPath = join(process.cwd(), `${base}.html`);
130
+ await fs.writeFile(mdPath, markdown, 'utf8');
131
+ await fs.writeFile(htmlPath, explanationHtml(markdown, basename(resolve(target))), 'utf8');
132
+ return { htmlPath, mdPath };
133
+ }
134
+ /** Wrap the markdown explanation in a standalone RTL HTML page. */
135
+ export function explanationHtml(markdown, title) {
136
+ return `<!DOCTYPE html>
137
+ <html lang="ar" dir="rtl">
138
+ <head>
139
+ <meta charset="utf-8">
140
+ <meta name="viewport" content="width=device-width, initial-scale=1">
141
+ <title>شرح ${escapeHtml(title)} — Vexi</title>
142
+ <style>
143
+ :root { --accent: #2979FF; --bg: #0a0a0f; --panel: #12121a; --text: #e8e8f0; --dim: #8888a0; }
144
+ body { margin: 0; background: var(--bg); color: var(--text);
145
+ font: 17px/1.9 "Segoe UI", Tahoma, "Noto Naskh Arabic", sans-serif; }
146
+ main { max-width: 820px; margin: 0 auto; padding: 32px 20px 80px; }
147
+ h1, h2, h3 { color: var(--accent); }
148
+ code, pre { direction: ltr; text-align: left; font: 14px/1.6 ui-monospace, Consolas, monospace; }
149
+ code { background: var(--panel); border-radius: 6px; padding: 2px 8px; }
150
+ pre { background: var(--panel); border: 1px solid #1e1e2c; border-inline-start: 3px solid var(--accent);
151
+ border-radius: 10px; padding: 14px 16px; overflow-x: auto; }
152
+ pre code { background: none; padding: 0; }
153
+ blockquote { border-inline-start: 3px solid var(--accent); margin-inline-start: 0;
154
+ padding-inline-start: 16px; color: var(--dim); }
155
+ footer { text-align: center; color: var(--dim); padding: 24px; font-size: 13px; }
156
+ footer code { color: var(--accent); }
157
+ </style>
158
+ </head>
159
+ <body>
160
+ <main>${renderMarkdown(markdown)}</main>
161
+ <footer>Generated by Vexi · <code>npm install -g vexi</code> ·
162
+ <a href="https://github.com/Elomami1976/vexi" style="color:var(--dim)">GitHub</a></footer>
163
+ </body>
164
+ </html>
165
+ `;
166
+ }
167
+ /**
168
+ * Minimal markdown → HTML renderer (headings, fenced code, inline code,
169
+ * bold, lists, paragraphs). Everything is HTML-escaped first — no raw
170
+ * model output ever reaches the DOM unescaped.
171
+ */
172
+ export function renderMarkdown(md) {
173
+ const out = [];
174
+ const lines = md.split(/\r?\n/);
175
+ let inCode = false;
176
+ let codeBuffer = [];
177
+ let inList = false;
178
+ const closeList = () => {
179
+ if (inList) {
180
+ out.push('</ul>');
181
+ inList = false;
182
+ }
183
+ };
184
+ for (const line of lines) {
185
+ if (line.trimStart().startsWith('```')) {
186
+ if (inCode) {
187
+ out.push(`<pre><code>${codeBuffer.join('\n')}</code></pre>`);
188
+ codeBuffer = [];
189
+ inCode = false;
190
+ }
191
+ else {
192
+ closeList();
193
+ inCode = true;
194
+ }
195
+ continue;
196
+ }
197
+ if (inCode) {
198
+ codeBuffer.push(escapeHtml(line));
199
+ continue;
200
+ }
201
+ const heading = line.match(/^(#{1,4})\s+(.*)$/);
202
+ if (heading) {
203
+ closeList();
204
+ const level = heading[1].length;
205
+ out.push(`<h${level}>${inline(heading[2])}</h${level}>`);
206
+ continue;
207
+ }
208
+ const item = line.match(/^\s*[-*]\s+(.*)$/);
209
+ if (item) {
210
+ if (!inList) {
211
+ out.push('<ul>');
212
+ inList = true;
213
+ }
214
+ out.push(`<li>${inline(item[1])}</li>`);
215
+ continue;
216
+ }
217
+ closeList();
218
+ if (line.trim())
219
+ out.push(`<p>${inline(line)}</p>`);
220
+ }
221
+ if (inCode)
222
+ out.push(`<pre><code>${codeBuffer.join('\n')}</code></pre>`);
223
+ closeList();
224
+ return out.join('\n');
225
+ }
226
+ /** Inline markdown: escape first, then bold and inline code. */
227
+ function inline(text) {
228
+ return escapeHtml(text)
229
+ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
230
+ .replace(/`([^`]+)`/g, '<code>$1</code>');
231
+ }
232
+ function escapeHtml(text) {
233
+ return text
234
+ .replaceAll('&', '&amp;')
235
+ .replaceAll('<', '&lt;')
236
+ .replaceAll('>', '&gt;')
237
+ .replaceAll('"', '&quot;');
238
+ }