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.
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Git push workflow for the /push slash command.
3
+ *
4
+ * Design: fully injected — run, confirm, and provider are passed in so the
5
+ * module is testable without touching real git, the filesystem, or the network.
6
+ * All user-facing output goes through the injected log function.
7
+ * Every mutating git command is gated behind an explicit confirm call.
8
+ */
9
+ /** Wrap a string in single quotes, escaping embedded single quotes. */
10
+ function shellQuote(s) {
11
+ return "'" + s.replace(/'/g, "'\\''") + "'";
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
+ }
25
+ const AUTH_SIGNATURES = [
26
+ 'could not read',
27
+ 'authentication failed',
28
+ 'permission denied',
29
+ 'terminal prompts disabled',
30
+ 'host key verification',
31
+ 'batch mode',
32
+ ];
33
+ function isAuthError(combined) {
34
+ const lower = combined.toLowerCase();
35
+ return AUTH_SIGNATURES.some((sig) => lower.includes(sig));
36
+ }
37
+ export async function gitPush(opts) {
38
+ const log = opts.log ?? ((l) => console.log(l));
39
+ const { cwd, run, confirm, provider } = opts;
40
+ // ── 1. Repo check ────────────────────────────────────────────────────────
41
+ const repoCheck = await run('git rev-parse --is-inside-work-tree', cwd);
42
+ if (repoCheck.code !== 0) {
43
+ log('Not a git repository. Initialize one with:');
44
+ log(' git init && git remote add origin <your-repo-url>');
45
+ return { ok: false, reason: 'not-a-repo' };
46
+ }
47
+ // ── 2. Remote check ──────────────────────────────────────────────────────
48
+ const remoteResult = await run('git remote', cwd);
49
+ if (!remoteResult.stdout.trim()) {
50
+ log('No git remote configured. Add one with:');
51
+ log(' git remote add origin <your-repo-url>');
52
+ return { ok: false, reason: 'no-remote' };
53
+ }
54
+ // ── 3. Resolve branch ────────────────────────────────────────────────────
55
+ const branch = opts.branch ?? (await run('git rev-parse --abbrev-ref HEAD', cwd)).stdout.trim();
56
+ if (!branch || branch === 'HEAD') {
57
+ log('Cannot determine current branch (detached HEAD?). Checkout a named branch first.');
58
+ return { ok: false, reason: 'aborted', detail: 'detached HEAD' };
59
+ }
60
+ // ── 4. Inspect working state ─────────────────────────────────────────────
61
+ const statusResult = await run('git status --porcelain', cwd);
62
+ // Preserve leading chars (XY status columns) — do NOT trim() the full output.
63
+ const statusLines = statusResult.stdout.split('\n').filter((l) => l.trim().length > 0);
64
+ const diffStat = (await run('git diff --stat HEAD', cwd)).stdout.trim();
65
+ if (diffStat)
66
+ log(diffStat);
67
+ // ── 5. Stage + commit (skipped when pushOnly) ─────────────────────────────
68
+ if (!opts.pushOnly) {
69
+ if (statusLines.length > 0) {
70
+ // Porcelain format: XY<space>filename — Y != ' ' means unstaged/untracked
71
+ const needsAdd = statusLines.some((l) => l.length >= 2 && l[1] !== ' ');
72
+ if (needsAdd) {
73
+ const addOk = await confirm('Stage all changes? (git add -A)');
74
+ if (!addOk)
75
+ return { ok: false, reason: 'aborted' };
76
+ await run('git add -A', cwd);
77
+ }
78
+ // Draft or use the provided commit message
79
+ let commitMsg = opts.message ?? '';
80
+ if (!commitMsg) {
81
+ const stagedDiff = (await run('git diff --staged', cwd)).stdout;
82
+ const trimmedDiff = stagedDiff.slice(0, 6000);
83
+ const userContent = 'Write a single-line Conventional Commits message (type(scope): description) ' +
84
+ 'for the following diff. Reply with ONLY the commit message — no explanation, ' +
85
+ 'no markdown fences, no backticks.\n\n' +
86
+ (trimmedDiff || '(no staged diff available)');
87
+ const messages = [
88
+ { role: 'system', content: 'You draft git commit messages. Reply with only the message.' },
89
+ { role: 'user', content: userContent },
90
+ ];
91
+ try {
92
+ const raw = await provider.stream(messages, () => { });
93
+ commitMsg = sanitizeCommitMessage(raw);
94
+ }
95
+ catch {
96
+ // fall through to default below
97
+ }
98
+ if (!commitMsg)
99
+ commitMsg = 'chore: update files';
100
+ }
101
+ log(`Proposed commit message: ${commitMsg}`);
102
+ const commitOk = await confirm(`Commit with: "${commitMsg}"?`);
103
+ if (!commitOk)
104
+ return { ok: false, reason: 'aborted' };
105
+ const commitResult = await run(`git commit -m ${shellQuote(commitMsg)}`, cwd);
106
+ if (commitResult.code !== 0) {
107
+ log(`Commit failed:\n${commitResult.stderr.trim()}`);
108
+ return { ok: false, reason: 'push-failed', detail: commitResult.stderr };
109
+ }
110
+ }
111
+ else {
112
+ // Nothing staged or unstaged — check for already-committed unpushed work
113
+ const logResult = await run('git log @{u}..HEAD --oneline', cwd);
114
+ if (logResult.code === 0 && !logResult.stdout.trim()) {
115
+ log('Nothing to commit and already up to date with remote.');
116
+ return { ok: false, reason: 'nothing-to-do' };
117
+ }
118
+ if (logResult.stdout.trim()) {
119
+ log(`Unpushed commits:\n${logResult.stdout.trim()}`);
120
+ }
121
+ // code !== 0 means no upstream yet — proceed, the push step will set -u
122
+ }
123
+ }
124
+ // ── 6. Auth pre-flight ───────────────────────────────────────────────────
125
+ const safeBranch = shellQuote(branch);
126
+ // Disable terminal prompts so git never hangs waiting for input.
127
+ // On Windows, Git for Windows respects GIT_TERMINAL_PROMPT via its own env.
128
+ const dryRunCmd = process.platform === 'win32'
129
+ ? `git -c core.askPass= push --dry-run origin ${safeBranch}`
130
+ : `GIT_TERMINAL_PROMPT=0 GIT_SSH_COMMAND='ssh -o BatchMode=yes' git -c core.askPass= push --dry-run origin ${safeBranch}`;
131
+ const dryResult = await run(dryRunCmd, cwd);
132
+ if (dryResult.code !== 0 && isAuthError(dryResult.stderr + dryResult.stdout)) {
133
+ log('Authentication failed. Set up git credentials:');
134
+ log(' • SSH key: https://docs.github.com/authentication/connecting-to-github-with-ssh');
135
+ log(' • HTTPS token: personal access token or git credential helper.');
136
+ return { ok: false, reason: 'auth-failed', detail: dryResult.stderr };
137
+ }
138
+ // ── 7. Push ──────────────────────────────────────────────────────────────
139
+ const upstreamResult = await run(`git rev-parse --abbrev-ref --symbolic-full-name ${shellQuote(`${branch}@{upstream}`)}`, cwd);
140
+ const hasUpstream = upstreamResult.code === 0 && upstreamResult.stdout.trim().length > 0;
141
+ const pushCmd = hasUpstream
142
+ ? `git push origin ${safeBranch}`
143
+ : `git push -u origin ${safeBranch}`;
144
+ const pushOk = await confirm(`Run: ${pushCmd}?`);
145
+ if (!pushOk)
146
+ return { ok: false, reason: 'aborted' };
147
+ const pushResult = await run(pushCmd, cwd);
148
+ if (pushResult.code !== 0) {
149
+ log(`Push failed:\n${pushResult.stderr.trim()}`);
150
+ return { ok: false, reason: 'push-failed', detail: pushResult.stderr };
151
+ }
152
+ log(`Pushed ${branch} to origin.`);
153
+ return { ok: true };
154
+ }
@@ -0,0 +1,178 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { gitPush } from './index.js';
3
+ /** Build a mock `run` that returns canned results matched by command substring. */
4
+ function makeRun(specs = []) {
5
+ const calls = [];
6
+ const run = async (cmd, _cwd) => {
7
+ calls.push(cmd);
8
+ for (const spec of specs) {
9
+ if (cmd.includes(spec.match))
10
+ return spec.out;
11
+ }
12
+ return { stdout: '', stderr: '', code: 0 };
13
+ };
14
+ return { run, calls };
15
+ }
16
+ /** Build a mock `confirm` that returns scripted booleans in sequence. */
17
+ function makeConfirm(answers = []) {
18
+ const calls = [];
19
+ let i = 0;
20
+ const confirm = async (msg) => {
21
+ calls.push(msg);
22
+ return answers[i++] ?? true;
23
+ };
24
+ return { confirm, calls };
25
+ }
26
+ const MOCK_PROVIDER = {
27
+ id: 'openai',
28
+ model: 'gpt-4o',
29
+ stream: async () => 'feat: add new feature',
30
+ };
31
+ const OK = { stdout: '', stderr: '', code: 0 };
32
+ /** Specs shared by all happy-path scenarios. */
33
+ function happySpecs(branch = 'main', opts = {}) {
34
+ return [
35
+ { match: '--is-inside-work-tree', out: { stdout: 'true\n', stderr: '', code: 0 } },
36
+ { match: 'git remote', out: { stdout: 'origin\n', stderr: '', code: 0 } },
37
+ { match: '--abbrev-ref HEAD', out: { stdout: `${branch}\n`, stderr: '', code: 0 } },
38
+ { match: 'status --porcelain', out: OK },
39
+ { match: 'diff --stat HEAD', out: OK },
40
+ { match: 'push --dry-run', out: OK }, // must come before 'push origin'
41
+ {
42
+ match: '--symbolic-full-name',
43
+ out: opts.upstreamExists === false
44
+ ? { stdout: '', stderr: 'fatal: no upstream configured', code: 128 }
45
+ : { stdout: `origin/${branch}\n`, stderr: '', code: 0 },
46
+ },
47
+ { match: 'push origin', out: OK },
48
+ { match: 'push -u', out: OK },
49
+ ];
50
+ }
51
+ // ── Test cases ────────────────────────────────────────────────────────────────
52
+ describe('gitPush', () => {
53
+ // 1. Not a git repo
54
+ it('returns not-a-repo and runs no mutating commands', async () => {
55
+ const { run, calls } = makeRun([
56
+ { match: '--is-inside-work-tree', out: { stdout: '', stderr: '', code: 128 } },
57
+ ]);
58
+ const { confirm } = makeConfirm();
59
+ const logs = [];
60
+ const result = await gitPush({
61
+ cwd: '/proj', run, confirm, provider: MOCK_PROVIDER,
62
+ log: (l) => logs.push(l),
63
+ });
64
+ expect(result).toEqual({ ok: false, reason: 'not-a-repo' });
65
+ expect(calls.some((c) => c.includes('git add'))).toBe(false);
66
+ expect(calls.some((c) => c.includes('git commit'))).toBe(false);
67
+ expect(calls.some((c) => c.includes('git push'))).toBe(false);
68
+ expect(logs.some((l) => l.includes('git init'))).toBe(true);
69
+ });
70
+ // 2. No remote configured
71
+ it('returns no-remote and nothing is pushed', async () => {
72
+ const { run, calls } = makeRun([
73
+ { match: '--is-inside-work-tree', out: { stdout: 'true\n', stderr: '', code: 0 } },
74
+ { match: 'git remote', out: { stdout: '', stderr: '', code: 0 } },
75
+ ]);
76
+ const { confirm } = makeConfirm();
77
+ const result = await gitPush({
78
+ cwd: '/proj', run, confirm, provider: MOCK_PROVIDER,
79
+ });
80
+ expect(result).toEqual({ ok: false, reason: 'no-remote' });
81
+ expect(calls.some((c) => c.includes('git push'))).toBe(false);
82
+ });
83
+ // 3. Auth pre-flight fails → real push is NEVER run
84
+ it('returns auth-failed when dry-run shows an auth-signature error', async () => {
85
+ const { run, calls } = makeRun([
86
+ { match: '--is-inside-work-tree', out: { stdout: 'true\n', stderr: '', code: 0 } },
87
+ { match: 'git remote', out: { stdout: 'origin\n', stderr: '', code: 0 } },
88
+ { match: '--abbrev-ref HEAD', out: { stdout: 'main\n', stderr: '', code: 0 } },
89
+ { match: 'status --porcelain', out: OK },
90
+ { match: 'diff --stat HEAD', out: OK },
91
+ // dry-run fails with auth-signature stderr
92
+ {
93
+ match: 'push --dry-run',
94
+ out: { stdout: '', stderr: 'could not read Username for remote: terminal prompts disabled', code: 1 },
95
+ },
96
+ ]);
97
+ const { confirm } = makeConfirm([true]); // any confirms before push would be true
98
+ const result = await gitPush({
99
+ cwd: '/proj', run, confirm, provider: MOCK_PROVIDER, pushOnly: true,
100
+ });
101
+ expect(result.ok).toBe(false);
102
+ expect(result.reason).toBe('auth-failed');
103
+ // The real (non-dry-run) push must NOT have been called
104
+ expect(calls.some((c) => c.includes('push') && !c.includes('--dry-run'))).toBe(false);
105
+ });
106
+ // 4. Happy path: unstaged changes, no message → add, AI-draft commit, push
107
+ it('stages, AI-drafts commit message, and pushes; returns ok: true', async () => {
108
+ const { run, calls } = makeRun([
109
+ { match: '--is-inside-work-tree', out: { stdout: 'true\n', stderr: '', code: 0 } },
110
+ { match: 'git remote', out: { stdout: 'origin\n', stderr: '', code: 0 } },
111
+ { match: '--abbrev-ref HEAD', out: { stdout: 'main\n', stderr: '', code: 0 } },
112
+ // ' M' → Y='M' → unstaged modification → needsAdd = true
113
+ { match: 'status --porcelain', out: { stdout: ' M src/foo.ts\n', stderr: '', code: 0 } },
114
+ { match: 'diff --stat HEAD', out: { stdout: '1 file changed, 2 insertions(+)\n', stderr: '', code: 0 } },
115
+ { match: 'add -A', out: OK },
116
+ { match: 'diff --staged', out: { stdout: '+added a line\n', stderr: '', code: 0 } },
117
+ { match: 'commit -m', out: OK },
118
+ { match: 'push --dry-run', out: OK },
119
+ { match: '--symbolic-full-name', out: { stdout: 'origin/main\n', stderr: '', code: 0 } },
120
+ { match: 'push origin', out: OK },
121
+ ]);
122
+ // all confirms: add, commit, push
123
+ const { confirm } = makeConfirm([true, true, true]);
124
+ const logs = [];
125
+ const result = await gitPush({
126
+ cwd: '/proj', run, confirm, provider: MOCK_PROVIDER,
127
+ log: (l) => logs.push(l),
128
+ });
129
+ expect(result).toEqual({ ok: true });
130
+ expect(calls.some((c) => c.includes('add -A'))).toBe(true);
131
+ expect(calls.some((c) => c.includes('commit -m'))).toBe(true);
132
+ // Real push (non-dry-run) was called
133
+ expect(calls.some((c) => c.includes('push origin') && !c.includes('--dry-run'))).toBe(true);
134
+ // AI-drafted message appears in the log
135
+ expect(logs.some((l) => l.includes('feat: add new feature'))).toBe(true);
136
+ // Final success message
137
+ expect(logs.some((l) => l.includes('Pushed main to origin'))).toBe(true);
138
+ });
139
+ // 5. pushOnly: true → no git add or git commit
140
+ it('pushOnly skips staging and committing, only pushes', async () => {
141
+ const { run, calls } = makeRun(happySpecs());
142
+ const { confirm } = makeConfirm([true]); // only the push confirm
143
+ const result = await gitPush({
144
+ cwd: '/proj', run, confirm, provider: MOCK_PROVIDER, pushOnly: true,
145
+ });
146
+ expect(result).toEqual({ ok: true });
147
+ expect(calls.some((c) => c.includes('add -A'))).toBe(false);
148
+ expect(calls.some((c) => c.includes('commit -m'))).toBe(false);
149
+ expect(calls.some((c) => c.includes('push origin') && !c.includes('--dry-run'))).toBe(true);
150
+ });
151
+ // 6. User declines push confirm → aborted, no real push runs
152
+ it('returns aborted when user declines the push confirm', async () => {
153
+ const { run, calls } = makeRun(happySpecs());
154
+ // pushOnly so there are no add/commit confirms; only one confirm: the push
155
+ const { confirm } = makeConfirm([false]);
156
+ const result = await gitPush({
157
+ cwd: '/proj', run, confirm, provider: MOCK_PROVIDER, pushOnly: true,
158
+ });
159
+ expect(result).toEqual({ ok: false, reason: 'aborted' });
160
+ // No real push was issued after the decline
161
+ expect(calls.some((c) => c.includes('push origin') && !c.includes('--dry-run'))).toBe(false);
162
+ expect(calls.some((c) => c.includes('push -u'))).toBe(false);
163
+ });
164
+ // 7. New branch with no upstream → git push -u origin <branch>
165
+ it('uses git push -u when no upstream is configured', async () => {
166
+ const branch = 'feature/new-thing';
167
+ const { run, calls } = makeRun(happySpecs(branch, { upstreamExists: false }));
168
+ const { confirm } = makeConfirm([true]);
169
+ const result = await gitPush({
170
+ cwd: '/proj', run, confirm, provider: MOCK_PROVIDER,
171
+ pushOnly: true, branch,
172
+ });
173
+ expect(result).toEqual({ ok: true });
174
+ // Must use -u form, not the plain push
175
+ expect(calls.some((c) => c.includes('push -u'))).toBe(true);
176
+ expect(calls.some((c) => c.includes('push origin') && !c.includes('push -u') && !c.includes('--dry-run'))).toBe(false);
177
+ });
178
+ });
@@ -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
- }
@@ -12,13 +12,13 @@
12
12
  export const SUPPORTED_LANGS = ['en', 'ar', 'es', 'pt', 'fr'];
13
13
  const en = {
14
14
  welcome: 'Welcome to Vexi — your AI coding agent in the terminal.',
15
- firstRunIntro: 'First run: Vexi needs an API key (Anthropic, OpenAI, OpenRouter, Groq or Gemini).\nIt is stored locally in ~/.vexi/config.json — no login, no server, no telemetry.',
15
+ firstRunIntro: 'First run: Vexi needs an API key.\nSupported: Anthropic · OpenAI · OpenRouter · Groq · Gemini · GLM · Mistral · Cerebras · DeepSeek · Qwen · Kimi · MiniMax\nIf auto-detection picks the wrong provider, just re-enter your key and select from the list.\nStored locally in ~/.vexi/config.json — no login, no server, no telemetry.',
16
16
  enterApiKey: 'Paste your API key',
17
17
  detectedProvider: 'Provider detected: {provider}',
18
18
  detectFailed: 'Could not auto-detect the provider for this key.',
19
19
  selectProvider: 'Select your provider',
20
20
  configSaved: 'Saved to {path} (readable only by your OS user).',
21
- chatHint: 'Type your message. Commands: /help /model /clear /exit',
21
+ chatHint: 'Type your message. Commands: /help /model /clear /undo /redo /history /push /exit',
22
22
  thinking: 'Thinking…',
23
23
  goodbye: 'Goodbye! 👋',
24
24
  invalidKey: 'The API key was rejected by the provider (unauthorized).',
@@ -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/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.',
@@ -59,16 +59,24 @@ const en = {
59
59
  learnPreview: 'Learned from {sessions} sessions ({signals} corrections found):',
60
60
  learnApplyHint: 'Looks right? Save it with: vexi learn --apply',
61
61
  learnApplied: 'Learned style saved: {path} — now injected into every session.',
62
+ undoNone: 'Nothing to undo — no file snapshots in this session.',
63
+ undoDone: 'Reverted: {files}',
64
+ redoNone: 'Nothing to redo.',
65
+ redoDone: 'Re-applied: {files}',
66
+ historyNone: 'No file snapshots yet — make some AI edits first.',
67
+ historyHeader: 'Recent AI file edits (newest first):',
68
+ cleanDone: 'Cleaned {count} old snapshot session(s).',
69
+ snapshotNoSession: 'No active Vexi session found. Run `vexi` first to start a session.',
62
70
  };
63
71
  const es = {
64
72
  welcome: 'Bienvenido a Vexi — tu agente de programación con IA en la terminal.',
65
- firstRunIntro: 'Primer uso: Vexi necesita una clave API (Anthropic, OpenAI, OpenRouter, Groq o Gemini).\nSe guarda localmente en ~/.vexi/config.json — sin registro, sin servidor, sin telemetría.',
73
+ firstRunIntro: 'Primer uso: Vexi necesita una clave API.\nCompatibles (varios son gratuitos): Anthropic · OpenAI · OpenRouter · Groq · Gemini · GLM · Mistral · Cerebras\nGuardada en ~/.vexi/config.json — sin registro, sin servidor, sin telemetría.',
66
74
  enterApiKey: 'Pega tu clave API',
67
75
  detectedProvider: 'Proveedor detectado: {provider}',
68
76
  detectFailed: 'No se pudo detectar automáticamente el proveedor de esta clave.',
69
77
  selectProvider: 'Selecciona tu proveedor',
70
78
  configSaved: 'Guardado en {path} (solo legible por tu usuario del sistema).',
71
- chatHint: 'Escribe tu mensaje. Comandos: /help /model /clear /exit',
79
+ chatHint: 'Escribe tu mensaje. Comandos: /help /model /clear /push /exit',
72
80
  thinking: 'Pensando…',
73
81
  goodbye: '¡Hasta luego! 👋',
74
82
  invalidKey: 'El proveedor rechazó la clave API (no autorizada).',
@@ -76,7 +84,7 @@ const es = {
76
84
  apiError: 'Error de API: {message}',
77
85
  emptyKey: 'No se introdujo ninguna clave.',
78
86
  historyCleared: 'Historial de conversación borrado.',
79
- 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/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',
80
88
  modelSwitched: 'Modelo cambiado a {model}',
81
89
  configReset: 'Configuración eliminada. Ejecuta `vexi` para configurar de nuevo.',
82
90
  configResetNone: 'No se encontró configuración.',
@@ -109,16 +117,24 @@ const es = {
109
117
  learnPreview: 'Aprendido de {sessions} sesiones ({signals} correcciones encontradas):',
110
118
  learnApplyHint: '¿Se ve bien? Guárdalo con: vexi learn --apply',
111
119
  learnApplied: 'Estilo aprendido guardado: {path} — ahora se inyecta en cada sesión.',
120
+ undoNone: 'Nada que deshacer — no hay instantáneas de archivos en esta sesión.',
121
+ undoDone: 'Revertido: {files}',
122
+ redoNone: 'Nada que rehacer.',
123
+ redoDone: 'Reaplicado: {files}',
124
+ historyNone: 'Aún no hay instantáneas de archivos — haz ediciones con IA primero.',
125
+ historyHeader: 'Ediciones de archivos recientes de la IA (más recientes primero):',
126
+ cleanDone: 'Se eliminaron {count} sesión(es) de instantáneas antiguas.',
127
+ snapshotNoSession: 'No se encontró ninguna sesión activa de Vexi. Ejecuta `vexi` primero.',
112
128
  };
113
129
  const pt = {
114
130
  welcome: 'Bem-vindo ao Vexi — seu agente de programação com IA no terminal.',
115
- firstRunIntro: 'Primeira execução: o Vexi precisa de uma chave de API (Anthropic, OpenAI, OpenRouter, Groq ou Gemini).\nEla é guardada localmente em ~/.vexi/config.json — sem login, sem servidor, sem telemetria.',
131
+ firstRunIntro: 'Primeira execução: o Vexi precisa de uma chave de API.\nCompatíveis (vários gratuitos): Anthropic · OpenAI · OpenRouter · Groq · Gemini · GLM · Mistral · Cerebras\nGuardada em ~/.vexi/config.json — sem login, sem servidor, sem telemetria.',
116
132
  enterApiKey: 'Cole sua chave de API',
117
133
  detectedProvider: 'Provedor detectado: {provider}',
118
134
  detectFailed: 'Não foi possível detectar automaticamente o provedor desta chave.',
119
135
  selectProvider: 'Selecione seu provedor',
120
136
  configSaved: 'Salvo em {path} (legível apenas pelo seu usuário do sistema).',
121
- chatHint: 'Digite sua mensagem. Comandos: /help /model /clear /exit',
137
+ chatHint: 'Digite sua mensagem. Comandos: /help /model /clear /push /exit',
122
138
  thinking: 'Pensando…',
123
139
  goodbye: 'Até logo! 👋',
124
140
  invalidKey: 'O provedor rejeitou a chave de API (não autorizada).',
@@ -126,7 +142,7 @@ const pt = {
126
142
  apiError: 'Erro de API: {message}',
127
143
  emptyKey: 'Nenhuma chave inserida.',
128
144
  historyCleared: 'Histórico de conversa apagado.',
129
- 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/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',
130
146
  modelSwitched: 'Modelo alterado para {model}',
131
147
  configReset: 'Configuração excluída. Execute `vexi` para configurar novamente.',
132
148
  configResetNone: 'Nenhuma configuração encontrada.',
@@ -159,16 +175,24 @@ const pt = {
159
175
  learnPreview: 'Aprendido de {sessions} sessões ({signals} correções encontradas):',
160
176
  learnApplyHint: 'Parece certo? Salve com: vexi learn --apply',
161
177
  learnApplied: 'Estilo aprendido salvo: {path} — agora é injetado em cada sessão.',
178
+ undoNone: 'Nada a desfazer — sem instantâneos de arquivos nesta sessão.',
179
+ undoDone: 'Revertido: {files}',
180
+ redoNone: 'Nada a refazer.',
181
+ redoDone: 'Reaplicado: {files}',
182
+ historyNone: 'Ainda não há instantâneos de arquivos — faça edições com IA primeiro.',
183
+ historyHeader: 'Edições de arquivos recentes da IA (mais recentes primeiro):',
184
+ cleanDone: 'Foram removidas {count} sessão(ões) de instantâneos antigas.',
185
+ snapshotNoSession: 'Nenhuma sessão ativa do Vexi encontrada. Execute `vexi` primeiro.',
162
186
  };
163
187
  const fr = {
164
188
  welcome: 'Bienvenue dans Vexi — votre agent de codage IA dans le terminal.',
165
- firstRunIntro: 'Première utilisation : Vexi a besoin d\'une clé API (Anthropic, OpenAI, OpenRouter, Groq ou Gemini).\nElle est stockée localement dans ~/.vexi/config.json — sans compte, sans serveur, sans télémétrie.',
189
+ firstRunIntro: 'Première utilisation : Vexi a besoin d\'une clé API.\nCompatibles (plusieurs gratuits) : Anthropic · OpenAI · OpenRouter · Groq · Gemini · GLM · Mistral · Cerebras\nStockée dans ~/.vexi/config.json — sans compte, sans serveur, sans télémétrie.',
166
190
  enterApiKey: 'Collez votre clé API',
167
191
  detectedProvider: 'Fournisseur détecté : {provider}',
168
192
  detectFailed: 'Impossible de détecter automatiquement le fournisseur de cette clé.',
169
193
  selectProvider: 'Sélectionnez votre fournisseur',
170
194
  configSaved: 'Enregistré dans {path} (lisible uniquement par votre utilisateur système).',
171
- chatHint: 'Tapez votre message. Commandes : /help /model /clear /exit',
195
+ chatHint: 'Tapez votre message. Commandes : /help /model /clear /push /exit',
172
196
  thinking: 'Réflexion…',
173
197
  goodbye: 'À bientôt ! 👋',
174
198
  invalidKey: 'La clé API a été rejetée par le fournisseur (non autorisée).',
@@ -176,7 +200,7 @@ const fr = {
176
200
  apiError: 'Erreur API : {message}',
177
201
  emptyKey: 'Aucune clé saisie.',
178
202
  historyCleared: 'Historique de conversation effacé.',
179
- 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/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',
180
204
  modelSwitched: 'Modèle changé pour {model}',
181
205
  configReset: 'Configuration supprimée. Lancez `vexi` pour reconfigurer.',
182
206
  configResetNone: 'Aucune configuration trouvée.',
@@ -209,6 +233,14 @@ const fr = {
209
233
  learnPreview: 'Appris de {sessions} sessions ({signals} corrections trouvées) :',
210
234
  learnApplyHint: 'Ça vous convient ? Enregistrez avec : vexi learn --apply',
211
235
  learnApplied: 'Style appris enregistré : {path} — désormais injecté dans chaque session.',
236
+ undoNone: 'Rien à annuler — aucun instantané de fichier dans cette session.',
237
+ undoDone: 'Rétabli : {files}',
238
+ redoNone: 'Rien à rétablir.',
239
+ redoDone: 'Réappliqué : {files}',
240
+ historyNone: 'Pas encore d\'instantanés de fichiers — effectuez d\'abord des modifications avec l\'IA.',
241
+ historyHeader: 'Modifications de fichiers récentes de l\'IA (plus récentes en premier) :',
242
+ cleanDone: '{count} session(s) d\'instantanés ancienne(s) supprimée(s).',
243
+ snapshotNoSession: 'Aucune session Vexi active trouvée. Exécutez `vexi` d\'abord.',
212
244
  };
213
245
  const STRINGS = {
214
246
  en,
@@ -9,6 +9,8 @@
9
9
  */
10
10
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
11
11
  import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
12
+ import { z } from 'zod';
13
+ import { VERSION } from '../version.js';
12
14
  import { loadMcpConfig } from './config.js';
13
15
  export class McpManager {
14
16
  connections = [];
@@ -26,7 +28,7 @@ export class McpManager {
26
28
  env: { ...process.env, ...server.env },
27
29
  stderr: 'ignore',
28
30
  });
29
- const client = new Client({ name: 'vexi', version: '0.5.0' });
31
+ const client = new Client({ name: 'vexi', version: VERSION });
30
32
  await client.connect(transport);
31
33
  const { tools } = await client.listTools();
32
34
  for (const tool of tools) {
@@ -46,12 +48,20 @@ export class McpManager {
46
48
  }
47
49
  return { connected, failed };
48
50
  }
51
+ static ArgsSchema = z.record(z.string(), z.unknown());
49
52
  /** Call a tool on a connected server; returns the text result. */
50
53
  async callTool(server, tool, args) {
51
54
  const connection = this.connections.find((c) => c.name === server);
52
55
  if (!connection)
53
56
  throw new Error(`MCP server "${server}" is not connected.`);
54
- const result = await connection.client.callTool({ name: tool, arguments: args });
57
+ const parsed = McpManager.ArgsSchema.safeParse(args);
58
+ if (!parsed.success)
59
+ throw new Error(`Invalid tool arguments: ${parsed.error.message}`);
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
+ });
55
65
  const content = Array.isArray(result.content) ? result.content : [];
56
66
  const text = content
57
67
  .map((c) => (c.type === 'text' ? (c.text ?? '') : `[${c.type}]`))
@@ -23,8 +23,9 @@
23
23
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
24
24
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
25
25
  import { z } from 'zod';
26
+ import { VERSION } from '../version.js';
26
27
  import { loadConfig } from '../config.js';
27
- import { createProvider } from '../providers/index.js';
28
+ import { createProviderFromConfig } from '../providers/index.js';
28
29
  import { scanProject, projectSummary } from '../scanner/index.js';
29
30
  import { loadMemory, memoryBlock } from '../memory/index.js';
30
31
  import { listSessions } from '../replay/recorder.js';
@@ -32,7 +33,7 @@ import { gatherSource, buildExplainMessages } from '../explain/index.js';
32
33
  import { SUPPORTED_LANGS } from '../i18n/index.js';
33
34
  export async function runMcpServer() {
34
35
  const root = process.cwd();
35
- const server = new McpServer({ name: 'vexi', version: '0.5.0' });
36
+ const server = new McpServer({ name: 'vexi', version: VERSION });
36
37
  // ── Resources ──────────────────────────────────────────────────────────
37
38
  server.registerResource('project-map', 'vexi://project', {
38
39
  title: 'Project map',
@@ -92,7 +93,7 @@ export async function runMcpServer() {
92
93
  };
93
94
  }
94
95
  try {
95
- const provider = createProvider(config.provider, config.apiKey, config.model);
96
+ const provider = createProviderFromConfig(config);
96
97
  const source = await gatherSource(path);
97
98
  const markdown = await provider.stream(buildExplainMessages(source, language), () => { });
98
99
  return { content: [{ type: 'text', text: markdown }] };
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { compressIntoMemory, KEEP_RECENT, COMPRESS_INTERVAL } from './index.js';
3
+ function makeMessages(n) {
4
+ return Array.from({ length: n }, (_, i) => ({
5
+ role: (i % 2 === 0 ? 'user' : 'assistant'),
6
+ content: `message ${i}`,
7
+ }));
8
+ }
9
+ const failingProvider = {
10
+ id: 'openai',
11
+ model: 'gpt-4o',
12
+ stream: async () => { throw new Error('provider failure'); },
13
+ };
14
+ const emptyMemory = {
15
+ version: 1,
16
+ summary: '',
17
+ decisions: [],
18
+ updatedAt: '',
19
+ compressedCount: 0,
20
+ };
21
+ describe('Fix 1: maybeCompress data-loss guard', () => {
22
+ it('returns original memory unchanged when provider.stream throws', async () => {
23
+ const messages = makeMessages(KEEP_RECENT + COMPRESS_INTERVAL + 2);
24
+ const original = { ...emptyMemory, summary: 'prior summary' };
25
+ const result = await compressIntoMemory(failingProvider, original, messages);
26
+ expect(result).toBe(original); // same reference — not mutated, not replaced
27
+ expect(result.summary).toBe('prior summary');
28
+ });
29
+ it('returns original memory when provider returns malformed JSON', async () => {
30
+ const badProvider = {
31
+ id: 'openai',
32
+ model: 'gpt-4o',
33
+ stream: async () => 'this is not json at all',
34
+ };
35
+ const original = { ...emptyMemory, decisions: ['keep this'] };
36
+ const result = await compressIntoMemory(badProvider, original, makeMessages(4));
37
+ expect(result).toBe(original);
38
+ expect(result.decisions).toEqual(['keep this']);
39
+ });
40
+ it('returns updated memory when provider returns valid JSON', async () => {
41
+ const goodProvider = {
42
+ id: 'openai',
43
+ model: 'gpt-4o',
44
+ stream: async () => '{"summary":"new summary","decisions":["decision A"]}',
45
+ };
46
+ const original = { ...emptyMemory };
47
+ const result = await compressIntoMemory(goodProvider, original, makeMessages(4));
48
+ expect(result).not.toBe(original);
49
+ expect(result.summary).toBe('new summary');
50
+ expect(result.decisions).toEqual(['decision A']);
51
+ });
52
+ });