vexi-cli 0.8.0 → 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/agent.js CHANGED
@@ -22,18 +22,20 @@ import * as nodeRl from 'node:readline';
22
22
  import ora from 'ora';
23
23
  import { loadConfig, saveConfig, CONFIG_PATH } from './config.js';
24
24
  import { SnapshotManager } from './snapshots/index.js';
25
- import { createProviderFromConfig, detectProvider, sanitizeKey, PROVIDER_INFO, ProviderError, } from './providers/index.js';
25
+ import { createProviderFromConfig, detectProvider, sanitizeKey, refreshManifest, PROVIDER_INFO, ProviderError, } from './providers/index.js';
26
26
  import { scanProject, projectSummary } from './scanner/index.js';
27
27
  import { loadMemory, saveMemory, memoryBlock, compressIntoMemory, KEEP_RECENT, COMPRESS_INTERVAL, } from './memory/index.js';
28
28
  import { loadSkills, skillsBlock } from './skills/index.js';
29
29
  import { SessionRecorder } from './replay/recorder.js';
30
30
  import { McpManager, parseToolCall } from './mcp/client.js';
31
31
  import { loadMcpConfig } from './mcp/config.js';
32
+ import { parseBuiltinToolCall, executeBuiltinTool, builtinToolsBlock, buildNativeTools, dispatchNativeTool } from './tools/index.js';
33
+ import { UsageTracker } from './usage/index.js';
32
34
  import { ARABIC_RTL_NOTE, getStrings, t } from './i18n/index.js';
33
35
  import { gitPush } from './git/index.js';
34
36
  import { accent, dim, err, ok, printBanner, printStatusLine, userPrompt, vexiLabel, warn } from './ui/index.js';
35
37
  /** Extract shell commands from fenced code blocks in an AI reply. */
36
- function extractShellBlocks(reply) {
38
+ export function extractShellBlocks(reply) {
37
39
  const blocks = [];
38
40
  const re = /```(?:bash|sh|shell|cmd|powershell|ps1)\n([\s\S]*?)```/gi;
39
41
  let m;
@@ -44,11 +46,24 @@ function extractShellBlocks(reply) {
44
46
  }
45
47
  return blocks;
46
48
  }
49
+ /** Hard cap on how long an AI-suggested command may run before it's killed. */
50
+ export const COMMAND_TIMEOUT_MS = 2 * 60 * 1000;
47
51
  /** Run a shell command and return { stdout, stderr, code }. */
48
- function runCommand(cmd, cwd) {
52
+ export function runCommand(cmd, cwd, timeoutMs = COMMAND_TIMEOUT_MS) {
49
53
  return new Promise((resolve) => {
50
- cpExec(cmd, { cwd, shell: process.platform === 'win32' ? 'powershell.exe' : '/bin/sh', maxBuffer: 1024 * 1024 * 4 }, (err, stdout, stderr) => {
51
- resolve({ stdout: stdout ?? '', stderr: stderr ?? '', code: err?.code ?? 0 });
54
+ cpExec(cmd, {
55
+ cwd,
56
+ shell: process.platform === 'win32' ? 'powershell.exe' : '/bin/sh',
57
+ maxBuffer: 1024 * 1024 * 4,
58
+ timeout: timeoutMs,
59
+ killSignal: 'SIGKILL',
60
+ }, (err, stdout, stderr) => {
61
+ const timedOut = Boolean(err?.killed && err.signal === 'SIGKILL');
62
+ const extraNote = timedOut ? `\n[vexi] command killed after exceeding ${timeoutMs / 1000}s timeout` : '';
63
+ // A killed process has no real exit code (err.code is undefined) — falling back to
64
+ // 0 would look like success to callers, so use the conventional timeout code (124).
65
+ const code = timedOut ? 124 : (err?.code ?? 0);
66
+ resolve({ stdout: stdout ?? '', stderr: (stderr ?? '') + extraNote, code });
52
67
  });
53
68
  });
54
69
  }
@@ -120,6 +135,8 @@ export async function runAgent(opts) {
120
135
  }
121
136
  let provider = createProviderFromConfig(config);
122
137
  const root = process.cwd();
138
+ // Best-effort: refresh the remote model-default manifest for the next run.
139
+ void refreshManifest();
123
140
  // ── Full project understanding: scan + load memory + load skills ──────
124
141
  const scanSpinner = ora({ text: dim(s.scanning), spinner: 'dots' }).start();
125
142
  let project = null;
@@ -187,13 +204,16 @@ export async function runAgent(opts) {
187
204
  const projectBlock = project ? projectSummary(project) : '';
188
205
  const skillsText = skillsBlock(skills);
189
206
  let compressing = false;
207
+ const usage = new UsageTracker();
208
+ const trackUsage = (u) => usage.add(u);
190
209
  /**
191
210
  * The system prompt is rebuilt every turn because the memory block
192
211
  * changes as the Context Compression Engine folds in old messages.
193
212
  */
213
+ const nativeToolsEnabled = Boolean(provider.supportsTools && provider.streamTools);
194
214
  const buildSystem = () => ({
195
215
  role: 'system',
196
- content: buildSystemPrompt(opts.lang, projectBlock, skillsText, memoryBlock(memory), mcp.promptBlock()),
216
+ content: buildSystemPrompt(opts.lang, projectBlock, skillsText, memoryBlock(memory), mcp.promptBlock(), nativeToolsEnabled),
197
217
  });
198
218
  /**
199
219
  * Running-summary compression: when enough messages have accumulated
@@ -219,6 +239,43 @@ export async function runAgent(opts) {
219
239
  compressing = false;
220
240
  });
221
241
  };
242
+ // Native function-calling: used when the provider reliably supports it,
243
+ // otherwise the text-based `vexi-tool` protocol below is used instead.
244
+ const nativeTools = provider.supportsTools && provider.streamTools ? buildNativeTools(mcp) : null;
245
+ /**
246
+ * Run any ```bash``` shell blocks in an AI reply (confirm → snapshot → run
247
+ * → feed output back). Shared by the native and text tool paths, since shell
248
+ * commands are proposed as fenced blocks regardless of tool mode.
249
+ */
250
+ const runShellBlocks = async (reply) => {
251
+ for (const cmd of extractShellBlocks(reply)) {
252
+ console.log(accent('▶ run? ') + dim(cmd.slice(0, 120) + (cmd.length > 120 ? '…' : '')));
253
+ const yes = await confirm({ message: 'Execute', default: true }).catch(() => false);
254
+ if (!yes) {
255
+ history.push({ role: 'user', content: `COMMAND SKIPPED: ${cmd}` });
256
+ continue;
257
+ }
258
+ const filesToSnap = SnapshotManager.extractFilePaths(cmd, root);
259
+ if (filesToSnap.length > 0) {
260
+ await snapshots.takeSnapshot(filesToSnap, cmd.slice(0, 80)).catch(() => { });
261
+ }
262
+ const runSpinner = ora({ text: dim('running…'), spinner: 'dots' }).start();
263
+ const { stdout, stderr, code } = await runCommand(cmd, root);
264
+ runSpinner.stop();
265
+ const output = [
266
+ stdout.trim() ? `STDOUT:\n${stdout.trim()}` : '',
267
+ stderr.trim() ? `STDERR:\n${stderr.trim()}` : '',
268
+ `EXIT CODE: ${code}`,
269
+ ].filter(Boolean).join('\n');
270
+ console.log(code === 0 ? ok('✓ done') : err(`✗ exit ${code}`));
271
+ if (stdout.trim())
272
+ console.log(dim(stdout.trim().slice(0, 800)));
273
+ if (stderr.trim())
274
+ console.log(warn(stderr.trim().slice(0, 400)));
275
+ history.push({ role: 'user', content: `COMMAND RESULT (${cmd.slice(0, 60)}):\n${output.slice(0, 6000)}` });
276
+ recorder.add('user', `COMMAND RESULT:\n${output.slice(0, 6000)}`);
277
+ }
278
+ };
222
279
  while (true) {
223
280
  let line;
224
281
  try {
@@ -226,6 +283,8 @@ export async function runAgent(opts) {
226
283
  }
227
284
  catch {
228
285
  // Ctrl+C / closed stdin
286
+ if (usage.hasData)
287
+ console.log('\n' + dim(`session usage: ${usage.summary(provider.model)}`));
229
288
  console.log('\n' + ok(s.goodbye));
230
289
  return;
231
290
  }
@@ -238,9 +297,14 @@ export async function runAgent(opts) {
238
297
  switch (cmd) {
239
298
  case '/exit':
240
299
  case '/quit':
300
+ if (usage.hasData)
301
+ console.log(dim(`session usage: ${usage.summary(provider.model)}`));
241
302
  console.log(ok(s.goodbye));
242
303
  await mcp.close();
243
304
  return;
305
+ case '/usage':
306
+ console.log(dim(usage.summary(provider.model)) + '\n');
307
+ continue;
244
308
  case '/help':
245
309
  console.log(dim(s.helpText) + '\n');
246
310
  continue;
@@ -335,50 +399,64 @@ export async function runAgent(opts) {
335
399
  const spinner = ora({ text: dim(s.thinking), spinner: 'dots' }).start();
336
400
  let started = false;
337
401
  try {
338
- // Up to 5 tool-call rounds per user turn, then a final plain answer.
402
+ // Up to 5 tool-call rounds per user turn (round < 5 below), plus one
403
+ // extra final round where a ```vexi-tool``` reply is no longer honored
404
+ // — so the model is forced to give a plain answer. 6 stream() calls total.
339
405
  for (let round = 0; round < 6; round++) {
340
- const reply = await provider.stream([buildSystem(), ...history], (chunk) => {
406
+ const onChunk = (chunk) => {
341
407
  if (!started) {
342
408
  spinner.stop();
343
409
  process.stdout.write(vexiLabel + ' ');
344
410
  started = true;
345
411
  }
346
412
  process.stdout.write(chunk);
347
- });
413
+ };
414
+ // ── Native function-calling path ─────────────────────────────
415
+ if (nativeTools) {
416
+ const { text: reply, toolCalls } = await provider.streamTools([buildSystem(), ...history], nativeTools.defs, onChunk, trackUsage);
417
+ if (!started)
418
+ spinner.stop();
419
+ process.stdout.write('\n\n');
420
+ history.push({
421
+ role: 'assistant',
422
+ content: reply,
423
+ toolCalls: toolCalls.length ? toolCalls : undefined,
424
+ });
425
+ recorder.add('assistant', reply);
426
+ await runShellBlocks(reply);
427
+ if (toolCalls.length === 0 || round === 5)
428
+ break;
429
+ for (const tc of toolCalls) {
430
+ const toolSpinner = ora({ text: dim(`${tc.name}…`), spinner: 'dots' }).start();
431
+ const { result } = await dispatchNativeTool(tc.name, tc.arguments, nativeTools.route, root, snapshots, mcp);
432
+ toolSpinner.stop();
433
+ console.log(result.startsWith('TOOL ERROR') ? err(`${tc.name}: ${result}`) : ok(`✓ ${tc.name}`));
434
+ history.push({ role: 'tool', content: result.slice(0, 8000), toolCallId: tc.id, toolName: tc.name });
435
+ recorder.add('user', `TOOL RESULT (${tc.name}):\n${result.slice(0, 8000)}`);
436
+ }
437
+ started = false; // next round streams with a fresh label
438
+ continue;
439
+ }
440
+ // ── Text-based `vexi-tool` path (providers without native tools) ─
441
+ const reply = await provider.stream([buildSystem(), ...history], onChunk, trackUsage);
348
442
  if (!started)
349
443
  spinner.stop(); // empty reply edge case
350
444
  process.stdout.write('\n\n');
351
445
  history.push({ role: 'assistant', content: reply });
352
446
  recorder.add('assistant', reply);
353
- // ── Auto-run shell commands suggested by the AI ──────────────
354
- const shellBlocks = extractShellBlocks(reply);
355
- for (const cmd of shellBlocks) {
356
- console.log(accent('▶ run? ') + dim(cmd.slice(0, 120) + (cmd.length > 120 ? '…' : '')));
357
- const yes = await confirm({ message: 'Execute', default: true }).catch(() => false);
358
- if (!yes) {
359
- history.push({ role: 'user', content: `COMMAND SKIPPED: ${cmd}` });
360
- continue;
361
- }
362
- // Silent snapshot of any files the command is about to modify
363
- const filesToSnap = SnapshotManager.extractFilePaths(cmd, root);
364
- if (filesToSnap.length > 0) {
365
- await snapshots.takeSnapshot(filesToSnap, cmd.slice(0, 80)).catch(() => { });
366
- }
367
- const runSpinner = ora({ text: dim('running…'), spinner: 'dots' }).start();
368
- const { stdout, stderr, code } = await runCommand(cmd, root);
369
- runSpinner.stop();
370
- const output = [
371
- stdout.trim() ? `STDOUT:\n${stdout.trim()}` : '',
372
- stderr.trim() ? `STDERR:\n${stderr.trim()}` : '',
373
- `EXIT CODE: ${code}`,
374
- ].filter(Boolean).join('\n');
375
- console.log(code === 0 ? ok('✓ done') : err(`✗ exit ${code}`));
376
- if (stdout.trim())
377
- console.log(dim(stdout.trim().slice(0, 800)));
378
- if (stderr.trim())
379
- console.log(warn(stderr.trim().slice(0, 400)));
380
- history.push({ role: 'user', content: `COMMAND RESULT (${cmd.slice(0, 60)}):\n${output.slice(0, 6000)}` });
381
- recorder.add('user', `COMMAND RESULT:\n${output.slice(0, 6000)}`);
447
+ await runShellBlocks(reply);
448
+ // Built-in file tool requested by the model? (read/write/edit)
449
+ const builtinCall = round < 5 ? parseBuiltinToolCall(reply) : null;
450
+ if (builtinCall) {
451
+ const fileSpinner = ora({ text: dim(`${builtinCall.tool}…`), spinner: 'dots' }).start();
452
+ const { result } = await executeBuiltinTool(builtinCall, root, snapshots);
453
+ fileSpinner.stop();
454
+ console.log(result.startsWith('TOOL ERROR') ? err(result) : ok(result));
455
+ const toolMessage = `TOOL RESULT (${builtinCall.tool}):\n${result.slice(0, 8000)}`;
456
+ history.push({ role: 'user', content: toolMessage });
457
+ recorder.add('user', toolMessage);
458
+ started = false; // next round streams with a fresh label
459
+ continue;
382
460
  }
383
461
  // MCP tool call requested by the model?
384
462
  const call = mcp.tools.length > 0 && round < 5 ? parseToolCall(reply) : null;
@@ -424,6 +502,130 @@ export async function runAgent(opts) {
424
502
  }
425
503
  }
426
504
  }
505
+ /**
506
+ * Non-interactive, single-turn mode for scripting/CI (`vexi -p "<prompt>"`).
507
+ * Same system prompt (project scan + memory + skills + MCP tools) and the
508
+ * same tool-call loop as the interactive session, but no readline, no
509
+ * spinners, and no session recording. Requires an existing config — it
510
+ * never runs the interactive BYOK onboarding.
511
+ */
512
+ export async function runPrint(opts) {
513
+ const config = await loadConfig();
514
+ if (!config) {
515
+ console.error(err('No API key configured yet. Run `vexi` once interactively to set one up, then retry with -p.'));
516
+ process.exitCode = 1;
517
+ return;
518
+ }
519
+ const provider = createProviderFromConfig(config);
520
+ const root = process.cwd();
521
+ let project = null;
522
+ try {
523
+ project = await scanProject(root);
524
+ }
525
+ catch {
526
+ // scanning is best-effort — the prompt still works without it
527
+ }
528
+ const memory = await loadMemory(root);
529
+ const skills = await loadSkills(root);
530
+ const mcp = new McpManager();
531
+ const mcpConfig = await loadMcpConfig();
532
+ if (Object.keys(mcpConfig.mcpServers).length > 0) {
533
+ await mcp.connect().catch(() => { });
534
+ }
535
+ const snapshots = new SnapshotManager(root, Date.now().toString(36));
536
+ await snapshots.registerAsCurrentSession().catch(() => { });
537
+ const projectBlock = project ? projectSummary(project) : '';
538
+ const skillsText = skillsBlock(skills);
539
+ const nativeToolsEnabled = Boolean(provider.supportsTools && provider.streamTools);
540
+ const buildSystem = () => ({
541
+ role: 'system',
542
+ content: buildSystemPrompt(opts.lang, projectBlock, skillsText, memoryBlock(memory), mcp.promptBlock(), nativeToolsEnabled),
543
+ });
544
+ const history = [{ role: 'user', content: opts.prompt }];
545
+ const usage = new UsageTracker();
546
+ const trackUsage = (u) => usage.add(u);
547
+ const nativeTools = provider.supportsTools && provider.streamTools ? buildNativeTools(mcp) : null;
548
+ const runShellBlocks = async (reply) => {
549
+ for (const cmd of extractShellBlocks(reply)) {
550
+ if (!opts.autoYes) {
551
+ console.error(dim(`[skipped — pass --yes to auto-run] $ ${cmd.slice(0, 120)}`));
552
+ history.push({ role: 'user', content: `COMMAND SKIPPED (non-interactive, no --yes flag): ${cmd}` });
553
+ continue;
554
+ }
555
+ console.error(accent('$ ') + cmd.slice(0, 120));
556
+ const filesToSnap = SnapshotManager.extractFilePaths(cmd, root);
557
+ if (filesToSnap.length > 0) {
558
+ await snapshots.takeSnapshot(filesToSnap, cmd.slice(0, 80)).catch(() => { });
559
+ }
560
+ const { stdout, stderr, code } = await runCommand(cmd, root);
561
+ console.error(code === 0 ? ok('✓ done') : err(`✗ exit ${code}`));
562
+ const output = [
563
+ stdout.trim() ? `STDOUT:\n${stdout.trim()}` : '',
564
+ stderr.trim() ? `STDERR:\n${stderr.trim()}` : '',
565
+ `EXIT CODE: ${code}`,
566
+ ].filter(Boolean).join('\n');
567
+ history.push({ role: 'user', content: `COMMAND RESULT (${cmd.slice(0, 60)}):\n${output.slice(0, 6000)}` });
568
+ }
569
+ };
570
+ try {
571
+ for (let round = 0; round < 6; round++) {
572
+ // ── Native function-calling path ─────────────────────────────
573
+ if (nativeTools) {
574
+ const { text: reply, toolCalls } = await provider.streamTools([buildSystem(), ...history], nativeTools.defs, (chunk) => process.stdout.write(chunk), trackUsage);
575
+ process.stdout.write('\n');
576
+ history.push({
577
+ role: 'assistant',
578
+ content: reply,
579
+ toolCalls: toolCalls.length ? toolCalls : undefined,
580
+ });
581
+ await runShellBlocks(reply);
582
+ if (toolCalls.length === 0 || round === 5)
583
+ break;
584
+ for (const tc of toolCalls) {
585
+ const { result } = await dispatchNativeTool(tc.name, tc.arguments, nativeTools.route, root, snapshots, mcp);
586
+ console.error(result.startsWith('TOOL ERROR') ? err(`${tc.name}: ${result}`) : ok(`✓ ${tc.name}`));
587
+ history.push({ role: 'tool', content: result.slice(0, 8000), toolCallId: tc.id, toolName: tc.name });
588
+ }
589
+ continue;
590
+ }
591
+ // ── Text-based `vexi-tool` path ──────────────────────────────
592
+ const reply = await provider.stream([buildSystem(), ...history], (chunk) => {
593
+ process.stdout.write(chunk);
594
+ }, trackUsage);
595
+ process.stdout.write('\n');
596
+ history.push({ role: 'assistant', content: reply });
597
+ await runShellBlocks(reply);
598
+ const builtinCall = round < 5 ? parseBuiltinToolCall(reply) : null;
599
+ if (builtinCall) {
600
+ const { result } = await executeBuiltinTool(builtinCall, root, snapshots);
601
+ console.error(result.startsWith('TOOL ERROR') ? err(result) : ok(result));
602
+ history.push({ role: 'user', content: `TOOL RESULT (${builtinCall.tool}):\n${result.slice(0, 8000)}` });
603
+ continue;
604
+ }
605
+ const call = mcp.tools.length > 0 && round < 5 ? parseToolCall(reply) : null;
606
+ if (!call)
607
+ break;
608
+ let result;
609
+ try {
610
+ result = await mcp.callTool(call.server, call.tool, call.arguments);
611
+ }
612
+ catch (e) {
613
+ result = `TOOL ERROR: ${e instanceof Error ? e.message : String(e)}`;
614
+ }
615
+ history.push({ role: 'user', content: `TOOL RESULT (${call.server}/${call.tool}):\n${result.slice(0, 8000)}` });
616
+ }
617
+ }
618
+ catch (e) {
619
+ const message = e instanceof Error ? e.message : String(e);
620
+ console.error(err(message));
621
+ process.exitCode = 1;
622
+ }
623
+ finally {
624
+ if (usage.hasData)
625
+ console.error(dim(`usage: ${usage.summary(provider.model)}`));
626
+ await mcp.close();
627
+ }
628
+ }
427
629
  /** Ask for the API key, auto-detect the provider, save the config. */
428
630
  async function firstRunSetup(s, lang) {
429
631
  console.log(accent(s.welcome));
@@ -461,7 +663,7 @@ async function firstRunSetup(s, lang) {
461
663
  return config;
462
664
  }
463
665
  /** System prompt for the chat session. */
464
- function buildSystemPrompt(lang, projectBlock, skillsText, memoryText, mcpText) {
666
+ function buildSystemPrompt(lang, projectBlock, skillsText, memoryText, mcpText, nativeTools = false) {
465
667
  const langNames = {
466
668
  en: 'English',
467
669
  ar: 'Arabic',
@@ -491,13 +693,23 @@ function buildSystemPrompt(lang, projectBlock, skillsText, memoryText, mcpText)
491
693
  'Always use `bash` as the code block language tag for commands — never `python`, `java`, etc.',
492
694
  'After seeing the output, continue helping based on the result.',
493
695
  ];
696
+ if (nativeTools) {
697
+ // Native function-calling: file tools (and MCP tools) are advertised
698
+ // through the API's tools parameter, so only a short pointer is needed.
699
+ parts.push('', '## File tools', 'You have native tools to read and edit files (read_file, write_file, edit_file). '
700
+ + 'Use them to inspect and change files instead of shell cat/sed. Read a file before editing it.');
701
+ }
702
+ else {
703
+ parts.push('', builtinToolsBlock());
704
+ }
494
705
  if (projectBlock)
495
706
  parts.push('', '## Project map', projectBlock);
496
707
  if (memoryText)
497
708
  parts.push('', memoryText);
498
709
  if (skillsText)
499
710
  parts.push('', skillsText);
500
- if (mcpText)
711
+ // In native mode, MCP tools are sent via the API — skip the text protocol block.
712
+ if (mcpText && !nativeTools)
501
713
  parts.push('', mcpText);
502
714
  return parts.join('\n');
503
715
  }
@@ -0,0 +1,66 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { tmpdir } from 'node:os';
3
+ import { extractShellBlocks, runCommand } from './agent.js';
4
+ describe('extractShellBlocks', () => {
5
+ it('extracts a single bash block', () => {
6
+ const reply = 'Run this:\n```bash\nnpm install\n```\nThen restart.';
7
+ expect(extractShellBlocks(reply)).toEqual(['npm install']);
8
+ });
9
+ it('extracts multiple blocks across different fence languages', () => {
10
+ const reply = [
11
+ '```sh\necho one\n```',
12
+ 'some text in between',
13
+ '```powershell\nWrite-Output "two"\n```',
14
+ '```cmd\necho three\n```',
15
+ ].join('\n');
16
+ expect(extractShellBlocks(reply)).toEqual(['echo one', 'Write-Output "two"', 'echo three']);
17
+ });
18
+ it('is case-insensitive on the fence language tag', () => {
19
+ const reply = '```BASH\nls -la\n```';
20
+ expect(extractShellBlocks(reply)).toEqual(['ls -la']);
21
+ });
22
+ it('trims whitespace inside the block', () => {
23
+ const reply = '```bash\n\n npm test \n\n```';
24
+ expect(extractShellBlocks(reply)).toEqual(['npm test']);
25
+ });
26
+ it('skips empty blocks', () => {
27
+ const reply = '```bash\n\n\n```\nSome text\n```sh\necho hi\n```';
28
+ expect(extractShellBlocks(reply)).toEqual(['echo hi']);
29
+ });
30
+ it('ignores non-shell fenced blocks (e.g. ```ts, ```json)', () => {
31
+ const reply = '```ts\nconst x = 1;\n```\n```json\n{"a": 1}\n```';
32
+ expect(extractShellBlocks(reply)).toEqual([]);
33
+ });
34
+ it('returns an empty array when the reply has no code fences', () => {
35
+ expect(extractShellBlocks('just a plain text answer, no commands here')).toEqual([]);
36
+ });
37
+ it('handles multiple blocks of the same language', () => {
38
+ const reply = '```bash\necho a\n```\n```bash\necho b\n```';
39
+ expect(extractShellBlocks(reply)).toEqual(['echo a', 'echo b']);
40
+ });
41
+ });
42
+ // Shell-specific commands: runCommand picks powershell.exe on win32, /bin/sh elsewhere.
43
+ const isWin = process.platform === 'win32';
44
+ const echoCmd = (text) => (isWin ? `Write-Output '${text}'` : `echo '${text}'`);
45
+ const sleepCmd = (seconds) => (isWin ? `Start-Sleep -Seconds ${seconds}` : `sleep ${seconds}`);
46
+ describe('runCommand', () => {
47
+ it('captures stdout and a zero exit code on success', async () => {
48
+ const { stdout, code } = await runCommand(echoCmd('hello-vexi'), tmpdir());
49
+ expect(stdout).toContain('hello-vexi');
50
+ expect(code).toBe(0);
51
+ });
52
+ it('captures a non-zero exit code on failure', async () => {
53
+ const { code } = await runCommand('exit 3', tmpdir());
54
+ expect(code).toBe(3);
55
+ });
56
+ it('kills the process and reports a timeout note once the deadline passes', async () => {
57
+ const { stderr, code } = await runCommand(sleepCmd(5), tmpdir(), 300);
58
+ expect(stderr).toContain('timeout');
59
+ expect(code).not.toBe(0);
60
+ }, 10_000);
61
+ it('does not time out when the command finishes well within the deadline', async () => {
62
+ const { stdout, stderr } = await runCommand(echoCmd('fast'), tmpdir(), 10_000);
63
+ expect(stdout).toContain('fast');
64
+ expect(stderr).not.toContain('timeout');
65
+ });
66
+ });
package/dist/cli.js CHANGED
@@ -17,10 +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';
20
21
  import { VERSION } from './version.js';
21
22
  import ora from 'ora';
22
23
  import { checkForUpdate, runUpdate, runUninstall } from './update/index.js';
23
- import { runAgent } from './agent.js';
24
+ import { runAgent, runPrint } from './agent.js';
24
25
  import { loadConfig, resetConfig, CONFIG_PATH } from './config.js';
25
26
  import { loadSkills, addSkill, removeSkill } from './skills/index.js';
26
27
  import { listSessions } from './replay/recorder.js';
@@ -57,6 +58,8 @@ export function buildCli() {
57
58
  .description('Open-source AI coding agent for your terminal. BYOK, zero config, multilingual.')
58
59
  .version(VERSION, '-v, --version')
59
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)')
60
63
  .option('--mcp-server', 'run Vexi as an MCP server over stdio (for Claude Desktop, Cursor, etc.)')
61
64
  .option('--no-update-check', 'skip the daily background update check')
62
65
  .action(async (options) => {
@@ -67,6 +70,10 @@ export function buildCli() {
67
70
  return;
68
71
  }
69
72
  const lang = await resolveLang(options.lang);
73
+ if (options.print) {
74
+ await runPrint({ prompt: options.print, lang, autoYes: !!options.yes });
75
+ return;
76
+ }
70
77
  const noUpdateCheck = !options.updateCheck || !!process.env['VEXI_NO_UPDATE_CHECK'];
71
78
  const updateCheckPromise = noUpdateCheck ? Promise.resolve(null) : checkForUpdate();
72
79
  await runAgent({ lang, version: VERSION, updateCheckPromise });
@@ -123,7 +130,15 @@ export function buildCli() {
123
130
  .action(async (source) => {
124
131
  const s = getStrings(await resolveLang());
125
132
  try {
126
- 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
+ });
127
142
  console.log(ok(t(s.skillAdded, { name })));
128
143
  }
129
144
  catch (e) {
@@ -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
- }
package/dist/git/index.js CHANGED
@@ -10,6 +10,18 @@
10
10
  function shellQuote(s) {
11
11
  return "'" + s.replace(/'/g, "'\\''") + "'";
12
12
  }
13
+ /** Max length for a model-drafted commit message (well above conventional-commit norms). */
14
+ const MAX_COMMIT_MSG_LEN = 200;
15
+ /**
16
+ * Take the model's raw reply and turn it into a safe single-line commit message:
17
+ * first line only, control/escape characters stripped (so a malicious or broken
18
+ * reply can't inject terminal escape sequences when echoed back), length-capped.
19
+ */
20
+ function sanitizeCommitMessage(raw) {
21
+ const firstLine = raw.replace(/`/g, '').split('\n')[0] ?? '';
22
+ const noControlChars = firstLine.replace(/[\x00-\x1f\x7f]/g, '');
23
+ return noControlChars.trim().slice(0, MAX_COMMIT_MSG_LEN);
24
+ }
13
25
  const AUTH_SIGNATURES = [
14
26
  'could not read',
15
27
  'authentication failed',
@@ -78,7 +90,7 @@ export async function gitPush(opts) {
78
90
  ];
79
91
  try {
80
92
  const raw = await provider.stream(messages, () => { });
81
- commitMsg = raw.replace(/`/g, '').split('\n')[0]?.trim() ?? '';
93
+ commitMsg = sanitizeCommitMessage(raw);
82
94
  }
83
95
  catch {
84
96
  // fall through to default below
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { promises as fs } from 'node:fs';
14
14
  import { join } from 'node:path';
15
+ import { escapeHtml } from '../utils/html.js';
15
16
  const LABELS = {
16
17
  en: {
17
18
  title: 'Vexi code graph',
@@ -85,7 +86,7 @@ export function buildGraphHtml(graph, lang) {
85
86
  <meta charset="utf-8">
86
87
  <meta name="viewport" content="width=device-width, initial-scale=1">
87
88
  <title>${L.title} — ${escapeHtml(graph.project)}</title>
88
- <script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
89
+ <script src="https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js" integrity="sha384-CjloA8y00+1SDAUkjs099PVfnY2KmDC2BZnws9kh8D/lX1s46w6EPhpXdqMfjK6i" crossorigin="anonymous"></script>
89
90
  <style>
90
91
  :root { --accent: #2979FF; --bg: #0a0a0f; --panel: #12121a; --text: #e8e8f0; --dim: #8888a0; }
91
92
  * { box-sizing: border-box; }
@@ -216,10 +217,3 @@ document.getElementById('search').addEventListener('input', (ev) => {
216
217
  </html>
217
218
  `;
218
219
  }
219
- function escapeHtml(text) {
220
- return text
221
- .replaceAll('&', '&amp;')
222
- .replaceAll('<', '&lt;')
223
- .replaceAll('>', '&gt;')
224
- .replaceAll('"', '&quot;');
225
- }
@@ -26,7 +26,7 @@ const en = {
26
26
  apiError: 'API error: {message}',
27
27
  emptyKey: 'No key entered.',
28
28
  historyCleared: 'Conversation history cleared.',
29
- helpText: '/help show this help\n/model switch model (e.g. /model gpt-4o)\n/memory show compressed project memory\n/clear clear conversation history\n/undo revert last AI file edit\n/redo re-apply last undone edit\n/history list recent AI file edits\n/push stage, commit and push to git (/push --only to skip commit)\n/exit quit Vexi',
29
+ helpText: '/help show this help\n/model switch model (e.g. /model gpt-4o)\n/memory show compressed project memory\n/clear clear conversation history\n/undo revert last AI file edit\n/redo re-apply last undone edit\n/history list recent AI file edits\n/push stage, commit and push to git (/push --only to skip commit)\n/usage token & cost estimate this session\n/exit quit Vexi',
30
30
  modelSwitched: 'Model switched to {model}',
31
31
  configReset: 'Configuration deleted. Run `vexi` to set up again.',
32
32
  configResetNone: 'No configuration found.',
@@ -84,7 +84,7 @@ const es = {
84
84
  apiError: 'Error de API: {message}',
85
85
  emptyKey: 'No se introdujo ninguna clave.',
86
86
  historyCleared: 'Historial de conversación borrado.',
87
- helpText: '/help mostrar esta ayuda\n/model cambiar modelo (p. ej. /model gpt-4o)\n/memory ver la memoria comprimida del proyecto\n/clear borrar historial de conversación\n/push confirmar y enviar cambios a git\n/exit salir de Vexi',
87
+ helpText: '/help mostrar esta ayuda\n/model cambiar modelo (p. ej. /model gpt-4o)\n/memory ver la memoria comprimida del proyecto\n/clear borrar historial de conversación\n/push confirmar y enviar cambios a git\n/usage tokens y coste estimado de la sesión\n/exit salir de Vexi',
88
88
  modelSwitched: 'Modelo cambiado a {model}',
89
89
  configReset: 'Configuración eliminada. Ejecuta `vexi` para configurar de nuevo.',
90
90
  configResetNone: 'No se encontró configuración.',
@@ -142,7 +142,7 @@ const pt = {
142
142
  apiError: 'Erro de API: {message}',
143
143
  emptyKey: 'Nenhuma chave inserida.',
144
144
  historyCleared: 'Histórico de conversa apagado.',
145
- helpText: '/help mostrar esta ajuda\n/model trocar modelo (ex.: /model gpt-4o)\n/memory ver a memória comprimida do projeto\n/clear apagar histórico de conversa\n/push confirmar e enviar mudanças ao git\n/exit sair do Vexi',
145
+ helpText: '/help mostrar esta ajuda\n/model trocar modelo (ex.: /model gpt-4o)\n/memory ver a memória comprimida do projeto\n/clear apagar histórico de conversa\n/push confirmar e enviar mudanças ao git\n/usage tokens e custo estimado da sessão\n/exit sair do Vexi',
146
146
  modelSwitched: 'Modelo alterado para {model}',
147
147
  configReset: 'Configuração excluída. Execute `vexi` para configurar novamente.',
148
148
  configResetNone: 'Nenhuma configuração encontrada.',
@@ -200,7 +200,7 @@ const fr = {
200
200
  apiError: 'Erreur API : {message}',
201
201
  emptyKey: 'Aucune clé saisie.',
202
202
  historyCleared: 'Historique de conversation effacé.',
203
- helpText: '/help afficher cette aide\n/model changer de modèle (ex. /model gpt-4o)\n/memory voir la mémoire compressée du projet\n/clear effacer l\'historique de conversation\n/push valider et pousser les modifications git\n/exit quitter Vexi',
203
+ helpText: '/help afficher cette aide\n/model changer de modèle (ex. /model gpt-4o)\n/memory voir la mémoire compressée du projet\n/clear effacer l\'historique de conversation\n/push valider et pousser les modifications git\n/usage tokens et coût estimé de la session\n/exit quitter Vexi',
204
204
  modelSwitched: 'Modèle changé pour {model}',
205
205
  configReset: 'Configuration supprimée. Lancez `vexi` pour reconfigurer.',
206
206
  configResetNone: 'Aucune configuration trouvée.',
@@ -57,7 +57,11 @@ export class McpManager {
57
57
  const parsed = McpManager.ArgsSchema.safeParse(args);
58
58
  if (!parsed.success)
59
59
  throw new Error(`Invalid tool arguments: ${parsed.error.message}`);
60
- const result = await connection.client.callTool({ name: tool, arguments: parsed.data });
60
+ // Explicit timeout (matches the SDK default) so a hung MCP server can
61
+ // never freeze the chat loop, regardless of future SDK default changes.
62
+ const result = await connection.client.callTool({ name: tool, arguments: parsed.data }, undefined, {
63
+ timeout: 60_000,
64
+ });
61
65
  const content = Array.isArray(result.content) ? result.content : [];
62
66
  const text = content
63
67
  .map((c) => (c.type === 'text' ? (c.text ?? '') : `[${c.type}]`))