vexi-cli 0.5.5 → 0.8.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/README.md CHANGED
@@ -15,6 +15,7 @@ Bring your own key · Zero config · Multilingual · 100% local
15
15
  [![npm](https://img.shields.io/npm/v/vexi-cli?color=2979FF)](https://www.npmjs.com/package/vexi-cli)
16
16
  [![license](https://img.shields.io/badge/license-MIT-2979FF)](LICENSE)
17
17
  [![node](https://img.shields.io/badge/node-%E2%89%A518-2979FF)](package.json)
18
+ [![website](https://img.shields.io/badge/website-vexi.pro-2979FF)](https://vexi.pro)
18
19
 
19
20
  **English** · [العربية](#-العربية) · [Español](#-español) · [Português](#-português) · [Français](#-français)
20
21
 
@@ -31,19 +32,89 @@ vexi
31
32
 
32
33
  That's it. No login, no signup, no server, no database. Everything runs locally.
33
34
 
35
+ ## Update
36
+
37
+ ```bash
38
+ vexi update
39
+ # or directly:
40
+ npm install -g vexi-cli@latest
41
+ ```
42
+
43
+ Vexi checks once a day whether a newer version is published on npm and prints a
44
+ one-line notice at startup if one is available. To disable the check:
45
+
46
+ ```bash
47
+ vexi --no-update-check # skip for this run
48
+ VEXI_NO_UPDATE_CHECK=1 vexi # skip via environment variable
49
+ ```
50
+
51
+ ## Uninstall
52
+
53
+ ```bash
54
+ vexi uninstall # removes Vexi, keeps your ~/.vexi config
55
+ vexi uninstall --purge # also deletes ~/.vexi (config, keys, memory)
56
+ ```
57
+
58
+ Or directly, without running Vexi:
59
+
60
+ ```bash
61
+ npm uninstall -g vexi-cli
62
+ rm -rf ~/.vexi # optional -- removes config, keys, and memory
63
+ ```
64
+
65
+ On Windows:
66
+
67
+ ```cmd
68
+ npm uninstall -g vexi-cli
69
+ rmdir /s /q %USERPROFILE%\.vexi
70
+ ```
71
+
34
72
  ## BYOK — Bring Your Own Key
35
73
 
36
74
  On first run, paste any API key. Vexi **auto-detects the provider** from the key format:
37
75
 
38
- | Key prefix | Provider |
39
- | ------------------- | ----------------- |
40
- | `sk-ant-...` | Anthropic (Claude)|
41
- | `sk-or-...` | OpenRouter |
42
- | `gsk_...` | Groq |
43
- | `AIza...` | Google Gemini |
44
- | `sk-...` / `sk-proj-...` | OpenAI |
76
+ | Key prefix | Provider | Auto-detect? | Free tier? |
77
+ | ---------- | -------- | ------------ | ---------- |
78
+ | `sk-ant-...` | Anthropic (Claude) | ✅ | — |
79
+ | `sk-or-...` | OpenRouter | ✅ | ✅ free models |
80
+ | `gsk_...` | Groq | ✅ | ✅ free |
81
+ | `AIza...` | Google Gemini | ✅ | ✅ free |
82
+ | `csk-...` | Cerebras | | ✅ free |
83
+ | `<32-hex>.<secret>` | Zhipu AI — GLM | ✅ | ✅ free |
84
+ | `sk-proj-...` | OpenAI | ✅ | — |
85
+ | `sk-...` *(classic)* | OpenAI / DeepSeek / Kimi — pick manually | manual select | DeepSeek ✅ / Kimi ✅ |
86
+ | *(any)* | Qwen · Mistral · MiniMax | manual select | ✅ free |
45
87
 
46
- If detection is ambiguous, Vexi simply asks you to pick the provider. Your key is stored **locally** in `~/.vexi/config.json` with owner-only file permissions (`chmod 600`).
88
+ > **Tip:** If Vexi guesses the wrong provider, run `vexi config reset`, paste your key again, and pick from the list.
89
+
90
+ Your key is stored **locally** in `~/.vexi/config.json` with owner-only file permissions (`chmod 600`).
91
+
92
+ ### 🆓 Start for free — no credit card needed
93
+
94
+ **International:**
95
+
96
+ | Provider | Sign-up | Free model | Speed |
97
+ | -------- | ------- | ---------- | ----- |
98
+ | **Groq** | [console.groq.com](https://console.groq.com) | Llama 3.3 70B | ⚡⚡ very fast |
99
+ | **Google Gemini** | [aistudio.google.com](https://aistudio.google.com) | Gemini 2.5 Flash | ⚡ fast |
100
+ | **Cerebras** | [cloud.cerebras.ai](https://cloud.cerebras.ai) | Llama 3.3 70B | ⚡⚡⚡ fastest |
101
+ | **OpenRouter** | [openrouter.ai](https://openrouter.ai) | many free models | varies |
102
+
103
+ **Chinese AI (all free tier, great quality):**
104
+
105
+ | Provider | Sign-up | Free model | Notes |
106
+ | -------- | ------- | ---------- | ----- |
107
+ | **DeepSeek** | [platform.deepseek.com](https://platform.deepseek.com) | deepseek-chat (V3) | excellent coder |
108
+ | **Zhipu AI — GLM** | [bigmodel.cn](https://bigmodel.cn) | glm-4-flash | auto-detected |
109
+ | **Alibaba Qwen** | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | qwen-turbo | manual select |
110
+ | **Kimi (Moonshot)** | [platform.moonshot.cn](https://platform.moonshot.cn) | moonshot-v1-8k | manual select |
111
+ | **MiniMax** | [platform.minimaxi.com](https://platform.minimaxi.com) | MiniMax-Text-01 | manual select |
112
+
113
+ To switch provider at any time:
114
+ ```bash
115
+ vexi config reset # wipe the stored key
116
+ vexi # restart — prompts for a new key
117
+ ```
47
118
 
48
119
  ## Multilingual
49
120
 
@@ -78,16 +149,23 @@ vexi mcp list # manage external MCP servers
78
149
  vexi --mcp-server # expose Vexi as an MCP server (stdio)
79
150
  vexi learn # learn your coding style from past sessions
80
151
  vexi learn --apply # save it as a skill (injected in every session)
152
+ vexi undo # revert the last AI file edit
153
+ vexi redo # re-apply a reverted edit
154
+ vexi history # list recent AI file edits with timestamps
155
+ vexi clean # clear old snapshots (.vexi/snapshots/)
81
156
  ```
82
157
 
83
158
  Inside the chat:
84
159
 
85
160
  ```
86
- /help show available commands
87
- /model switch model (e.g. /model gpt-4o)
88
- /memory show compressed project memory
89
- /clear clear conversation history
90
- /exit quit
161
+ /help show available commands
162
+ /model switch model (e.g. /model gpt-4o)
163
+ /memory show compressed project memory
164
+ /clear clear conversation history
165
+ /undo revert last AI file edit
166
+ /redo re-apply last undone edit
167
+ /history list recent AI file edits
168
+ /exit quit
91
169
  ```
92
170
 
93
171
  ## ⚙️ Multi-language build support
@@ -241,6 +319,29 @@ session, so you stop repeating yourself. Everything stays local — the only
241
319
  network call is to your own model provider, and you always preview before
242
320
  saving.
243
321
 
322
+ ## ↩️ Undo / Redo — instant rescue from any AI edit
323
+
324
+ > Approve a change, see it break things, type `vexi undo`. Done.
325
+
326
+ ```bash
327
+ vexi undo # revert the last AI-applied file change
328
+ vexi redo # re-apply a reverted change
329
+ vexi history # list every file edit this session, with timestamps
330
+ vexi clean # remove old snapshot sessions to free disk space
331
+ ```
332
+
333
+ Or use the in-chat shortcuts without leaving the session: `/undo`, `/redo`, `/history`.
334
+
335
+ **How it works (lightweight snapshot design):**
336
+
337
+ - Before executing any confirmed shell command, Vexi inspects it for file-write patterns (`cat >`, `sed -i`, `mv`, `cp`, `tee`, `writeFileSync`, PowerShell `Set-Content`, and bare source-file tokens) and saves a copy of **only the affected files** into `.vexi/snapshots/<session>/`.
338
+ - `undo` restores the pre-command copy and saves the current state as a redo point — so you can bounce back and forth freely.
339
+ - Each session keeps a maximum of 50 snapshots; the oldest are pruned automatically.
340
+ - Snapshots are **per-file, not per-project** — no full-tree indexing, no slow startup, no huge disk usage (the approach OpenCode's whole-tree snapshot system suffers from).
341
+ - `vexi clean` wipes all previous sessions' snapshots while keeping the current one active.
342
+
343
+ This pairs naturally with the confirmation prompt: even if you approve a change that turns out to be wrong, one command gets you back.
344
+
244
345
  ## Roadmap
245
346
 
246
347
  | Phase | Feature | Status |
@@ -250,19 +351,23 @@ saving.
250
351
  | 3 | **Vexi Replay** (export sessions as animated HTML) · multilingual code explanation | ✅ done |
251
352
  | 4 | Visual code graph · MCP support (client **and** server mode) | ✅ done |
252
353
  | 5 | **Vexi Learn** — adapts to your personal coding style | ✅ done |
354
+ | 6 | **Multi-language builds** — auto-executes pip, gcc, javac, cargo, gradle from chat | ✅ done |
355
+ | 7 | **Undo / Redo** — instant one-command revert of any AI file edit, without touching git | ✅ done |
253
356
 
254
357
  ## Why Vexi?
255
358
 
256
359
  | | Vexi | OpenCode | Claude Code | Cursor |
257
360
  | --- | --- | --- | --- | --- |
258
361
  | Install | `npm i -g vexi-cli` | binary/script | `npm i -g` | desktop app |
259
- | BYOK (any provider) | ✅ 5 providers, auto-detect | ✅ | ❌ Anthropic only | partial |
362
+ | BYOK (any provider) | ✅ 12 providers incl. Chinese AI | ✅ | ❌ Anthropic only | partial |
260
363
  | Works fully offline/local | ✅ no server, no account | ✅ | ❌ account | ❌ account |
261
364
  | Native-language code explanations | ✅ ar/es/pt/fr | ❌ | ❌ | ❌ |
262
365
  | Session replay export | ✅ | ❌ | ❌ | ❌ |
263
366
  | Persistent project memory | ✅ | partial | partial | ✅ |
264
367
  | Learns your personal coding style | ✅ from your own sessions | ❌ | ❌ | partial |
265
368
  | MCP server mode (be a tool for other agents) | ✅ | ❌ | ❌ | ❌ |
369
+ | Builds any language (Python, Java, C, Rust, Go) | ✅ | ❌ | ✅ | ✅ |
370
+ | Instant undo/redo of AI edits (no git required) | ✅ per-file snapshots | ❌ | ❌ | ❌ |
266
371
  | License | MIT | MIT | proprietary | proprietary |
267
372
 
268
373
  Vexi **complements** tools like Claude Code instead of competing: its project memory and multilingual explanations will be exposed over MCP so any agent can use them.
@@ -292,6 +397,7 @@ src/
292
397
  ├── graph/ dependency graph + interactive d3 visualization
293
398
  ├── mcp/ MCP client (tools in chat) + server mode
294
399
  ├── learn/ Vexi Learn — style mining from your own sessions
400
+ ├── snapshots/ undo/redo engine — per-file backups before AI edits
295
401
  ├── i18n/ 5-language UI strings + RTL strategy
296
402
  ├── ui/ terminal branding (chalk, ora)
297
403
  └── utils/ atomic JSON writes, cross-platform open
package/dist/agent.js CHANGED
@@ -21,7 +21,8 @@ import { input, select, confirm } from '@inquirer/prompts';
21
21
  import * as nodeRl from 'node:readline';
22
22
  import ora from 'ora';
23
23
  import { loadConfig, saveConfig, CONFIG_PATH } from './config.js';
24
- import { createProvider, detectProvider, sanitizeKey, PROVIDER_INFO, ProviderError, } from './providers/index.js';
24
+ import { SnapshotManager } from './snapshots/index.js';
25
+ import { createProviderFromConfig, detectProvider, sanitizeKey, PROVIDER_INFO, ProviderError, } from './providers/index.js';
25
26
  import { scanProject, projectSummary } from './scanner/index.js';
26
27
  import { loadMemory, saveMemory, memoryBlock, compressIntoMemory, KEEP_RECENT, COMPRESS_INTERVAL, } from './memory/index.js';
27
28
  import { loadSkills, skillsBlock } from './skills/index.js';
@@ -29,6 +30,7 @@ import { SessionRecorder } from './replay/recorder.js';
29
30
  import { McpManager, parseToolCall } from './mcp/client.js';
30
31
  import { loadMcpConfig } from './mcp/config.js';
31
32
  import { ARABIC_RTL_NOTE, getStrings, t } from './i18n/index.js';
33
+ import { gitPush } from './git/index.js';
32
34
  import { accent, dim, err, ok, printBanner, printStatusLine, userPrompt, vexiLabel, warn } from './ui/index.js';
33
35
  /** Extract shell commands from fenced code blocks in an AI reply. */
34
36
  function extractShellBlocks(reply) {
@@ -116,7 +118,7 @@ export async function runAgent(opts) {
116
118
  config.lang = opts.lang;
117
119
  await saveConfig(config);
118
120
  }
119
- let provider = createProvider(config.provider, config.apiKey, config.model);
121
+ let provider = createProviderFromConfig(config);
120
122
  const root = process.cwd();
121
123
  // ── Full project understanding: scan + load memory + load skills ──────
122
124
  const scanSpinner = ora({ text: dim(s.scanning), spinner: 'dots' }).start();
@@ -143,10 +145,15 @@ export async function runAgent(opts) {
143
145
  for (const f of failed)
144
146
  console.log(warn(t(s.mcpFailed, { name: f.name })));
145
147
  }
148
+ // ── Snapshot engine: register this session for undo/redo ─────────────
149
+ const snapshotSessionId = Date.now().toString(36);
150
+ const snapshots = new SnapshotManager(root, snapshotSessionId);
151
+ await snapshots.registerAsCurrentSession().catch(() => { });
146
152
  // ── Session recording (Vexi Replay) — saved after every turn ─────────
153
+ const providerLabel = config.displayName ?? PROVIDER_INFO[config.provider]?.label ?? config.provider;
147
154
  const recorder = new SessionRecorder(root, {
148
155
  project: basename(root),
149
- provider: PROVIDER_INFO[config.provider].label,
156
+ provider: providerLabel,
150
157
  model: provider.model,
151
158
  lang: opts.lang,
152
159
  });
@@ -154,7 +161,7 @@ export async function runAgent(opts) {
154
161
  project: project?.stack.length
155
162
  ? `${basename(root)} (${project.stack.slice(0, 4).join(', ')})`
156
163
  : basename(root),
157
- provider: PROVIDER_INFO[config.provider].label,
164
+ provider: providerLabel,
158
165
  model: provider.model,
159
166
  lang: opts.lang,
160
167
  });
@@ -164,6 +171,16 @@ export async function runAgent(opts) {
164
171
  if (skills.length > 0) {
165
172
  console.log(dim(t(s.skillsLoaded, { names: skills.map((sk) => sk.name).join(', ') })));
166
173
  }
174
+ // Peek at the update check (only picks up already-resolved results; never blocks)
175
+ const latestVersion = opts.updateCheckPromise
176
+ ? await Promise.race([
177
+ opts.updateCheckPromise,
178
+ new Promise((r) => setTimeout(() => r(null), 0)),
179
+ ])
180
+ : null;
181
+ if (latestVersion) {
182
+ console.log(dim(`Update available: ${opts.version} -> ${latestVersion} run: npm install -g vexi-cli@latest`));
183
+ }
167
184
  console.log(dim(s.chatHint) + '\n');
168
185
  // ── Chat loop ────────────────────────────────────────────────────────
169
186
  const history = [];
@@ -186,14 +203,18 @@ export async function runAgent(opts) {
186
203
  const maybeCompress = () => {
187
204
  if (compressing || history.length < KEEP_RECENT + COMPRESS_INTERVAL)
188
205
  return;
189
- const archived = history.splice(0, history.length - KEEP_RECENT);
206
+ // Slice (not splice) — do NOT mutate history before we know compression succeeded.
207
+ const archiveCount = history.length - KEEP_RECENT;
208
+ const archived = history.slice(0, archiveCount);
190
209
  compressing = true;
191
210
  compressIntoMemory(provider, memory, archived)
192
211
  .then(async (updated) => {
212
+ // Only remove from history after a successful archive write.
213
+ history.splice(0, archiveCount);
193
214
  memory = updated;
194
215
  await saveMemory(root, updated);
195
216
  })
196
- .catch(() => { }) // compression must never break the chat
217
+ .catch(() => { }) // history intact on failure retries next turn
197
218
  .finally(() => {
198
219
  compressing = false;
199
220
  });
@@ -245,7 +266,7 @@ export async function runAgent(opts) {
245
266
  if (model) {
246
267
  config.model = model;
247
268
  await saveConfig(config);
248
- provider = createProvider(config.provider, config.apiKey, model);
269
+ provider = createProviderFromConfig(config);
249
270
  console.log(ok(t(s.modelSwitched, { model })) + '\n');
250
271
  }
251
272
  else {
@@ -253,6 +274,56 @@ export async function runAgent(opts) {
253
274
  }
254
275
  continue;
255
276
  }
277
+ case '/undo': {
278
+ const entry = await snapshots.undo().catch(() => null);
279
+ if (!entry) {
280
+ console.log(dim(s.undoNone) + '\n');
281
+ }
282
+ else {
283
+ console.log(ok(t(s.undoDone, { files: entry.files.join(', ') })) + '\n');
284
+ }
285
+ continue;
286
+ }
287
+ case '/redo': {
288
+ const entry = await snapshots.redo().catch(() => null);
289
+ if (!entry) {
290
+ console.log(dim(s.redoNone) + '\n');
291
+ }
292
+ else {
293
+ console.log(ok(t(s.redoDone, { files: entry.files.join(', ') })) + '\n');
294
+ }
295
+ continue;
296
+ }
297
+ case '/history': {
298
+ const entries = await snapshots.list().catch(() => []);
299
+ if (entries.length === 0) {
300
+ console.log(dim(s.historyNone) + '\n');
301
+ }
302
+ else {
303
+ console.log(dim(s.historyHeader));
304
+ for (const e of entries) {
305
+ const time = new Date(e.at).toLocaleTimeString();
306
+ console.log(accent(` ${time}`) + dim(` ${e.files.join(', ')}`) + dim(` — ${e.label.slice(0, 60)}`));
307
+ }
308
+ console.log();
309
+ }
310
+ continue;
311
+ }
312
+ case '/push': {
313
+ const firstArg = rest[0] ?? '';
314
+ const pushOnly = firstArg === '--only';
315
+ const message = pushOnly || rest.length === 0 ? undefined : rest.join(' ').trim() || undefined;
316
+ await gitPush({
317
+ cwd: root,
318
+ run: runCommand,
319
+ confirm: (m) => confirm({ message: m, default: true }).catch(() => false),
320
+ provider,
321
+ message,
322
+ pushOnly,
323
+ });
324
+ console.log();
325
+ continue;
326
+ }
256
327
  default:
257
328
  console.log(warn(`Unknown command: ${cmd}`) + ' ' + dim('(/help)') + '\n');
258
329
  continue;
@@ -288,6 +359,11 @@ export async function runAgent(opts) {
288
359
  history.push({ role: 'user', content: `COMMAND SKIPPED: ${cmd}` });
289
360
  continue;
290
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
+ }
291
367
  const runSpinner = ora({ text: dim('running…'), spinner: 'dots' }).start();
292
368
  const { stdout, stderr, code } = await runCommand(cmd, root);
293
369
  runSpinner.stop();
@@ -338,7 +414,7 @@ export async function runAgent(opts) {
338
414
  const retry = await confirm({ message: s.reenterKey, default: true }).catch(() => false);
339
415
  if (retry) {
340
416
  config = await firstRunSetup(s, opts.lang);
341
- provider = createProvider(config.provider, config.apiKey, config.model);
417
+ provider = createProviderFromConfig(config);
342
418
  }
343
419
  }
344
420
  else {
package/dist/cli.js CHANGED
@@ -17,7 +17,9 @@
17
17
  * vexi learn [--apply] learn your coding style from past sessions
18
18
  */
19
19
  import { Command } from 'commander';
20
+ import { VERSION } from './version.js';
20
21
  import ora from 'ora';
22
+ import { checkForUpdate, runUpdate, runUninstall } from './update/index.js';
21
23
  import { runAgent } from './agent.js';
22
24
  import { loadConfig, resetConfig, CONFIG_PATH } from './config.js';
23
25
  import { loadSkills, addSkill, removeSkill } from './skills/index.js';
@@ -28,11 +30,13 @@ import { buildGraph } from './graph/index.js';
28
30
  import { exportGraphHtml } from './graph/html.js';
29
31
  import { loadMcpConfig, saveMcpConfig, MCP_CONFIG_PATH } from './mcp/config.js';
30
32
  import { learn, applyLearned, DEFAULT_MAX_SESSIONS } from './learn/index.js';
31
- import { createProvider, PROVIDER_INFO } from './providers/index.js';
33
+ import { createProviderFromConfig, PROVIDER_INFO } from './providers/index.js';
34
+ import { runOnboarding } from './onboarding.js';
35
+ import * as nodeRlPromises from 'node:readline/promises';
32
36
  import { openInDefaultApp } from './utils/open.js';
37
+ import { SnapshotManager } from './snapshots/index.js';
33
38
  import { detectSystemLang, getStrings, normalizeLang, t, SUPPORTED_LANGS } from './i18n/index.js';
34
39
  import { accent, dim, err, ok } from './ui/index.js';
35
- export const VERSION = '0.5.5';
36
40
  /** Resolve the active language: --lang flag > saved config > system locale. */
37
41
  async function resolveLang(flag) {
38
42
  if (flag) {
@@ -54,15 +58,18 @@ export function buildCli() {
54
58
  .version(VERSION, '-v, --version')
55
59
  .option('-l, --lang <lang>', `UI language (${SUPPORTED_LANGS.join('/')})`)
56
60
  .option('--mcp-server', 'run Vexi as an MCP server over stdio (for Claude Desktop, Cursor, etc.)')
61
+ .option('--no-update-check', 'skip the daily background update check')
57
62
  .action(async (options) => {
58
63
  if (options.mcpServer) {
59
- // stdout becomes the JSON-RPC channel no banner, no prompts.
64
+ // stdout becomes the JSON-RPC channel -- no banner, no prompts.
60
65
  const { runMcpServer } = await import('./mcp/server.js');
61
66
  await runMcpServer();
62
67
  return;
63
68
  }
64
69
  const lang = await resolveLang(options.lang);
65
- await runAgent({ lang, version: VERSION });
70
+ const noUpdateCheck = !options.updateCheck || !!process.env['VEXI_NO_UPDATE_CHECK'];
71
+ const updateCheckPromise = noUpdateCheck ? Promise.resolve(null) : checkForUpdate();
72
+ await runAgent({ lang, version: VERSION, updateCheckPromise });
66
73
  });
67
74
  const config = program.command('config').description('Manage Vexi configuration');
68
75
  config
@@ -72,12 +79,16 @@ export function buildCli() {
72
79
  const cfg = await loadConfig();
73
80
  console.log(dim('config: ') + accent(CONFIG_PATH));
74
81
  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));
82
+ const providerLabel = cfg.displayName ?? PROVIDER_INFO[cfg.provider]?.label ?? cfg.provider;
83
+ console.log(dim('provider: ') + accent(providerLabel));
84
+ const defaultModel = PROVIDER_INFO[cfg.provider]?.defaultModel ?? 'unknown';
85
+ console.log(dim('model: ') + accent(cfg.model ?? defaultModel));
86
+ if (cfg.baseUrl)
87
+ console.log(dim('baseUrl: ') + accent(cfg.baseUrl));
77
88
  console.log(dim('lang: ') + accent(cfg.lang ?? 'auto'));
78
89
  }
79
90
  else {
80
- console.log(dim('No configuration yet — run `vexi` to set up.'));
91
+ console.log(dim('No configuration yet — run `vexi` or `vexi setup` to configure.'));
81
92
  }
82
93
  });
83
94
  config
@@ -184,7 +195,7 @@ export function buildCli() {
184
195
  process.exitCode = 1;
185
196
  return;
186
197
  }
187
- const provider = createProvider(cfg.provider, cfg.apiKey, cfg.model);
198
+ const provider = createProviderFromConfig(cfg);
188
199
  const spinner = ora({ text: dim(s.explaining), spinner: 'dots' }).start();
189
200
  let started = false;
190
201
  try {
@@ -296,7 +307,7 @@ export function buildCli() {
296
307
  process.exitCode = 1;
297
308
  return;
298
309
  }
299
- const provider = createProvider(cfg.provider, cfg.apiKey, cfg.model);
310
+ const provider = createProviderFromConfig(cfg);
300
311
  const maxSessions = Math.max(1, parseInt(options.sessions ?? '', 10) || DEFAULT_MAX_SESSIONS);
301
312
  const spinner = ora({ text: dim(s.learnAnalyzing), spinner: 'dots' }).start();
302
313
  try {
@@ -329,5 +340,115 @@ export function buildCli() {
329
340
  process.exitCode = 1;
330
341
  }
331
342
  });
343
+ // ── Undo / Redo / History / Clean (Feature 8) ────────────────────────
344
+ program
345
+ .command('undo')
346
+ .description('Revert the last AI file edit in the current session')
347
+ .action(async () => {
348
+ const s = getStrings(await resolveLang());
349
+ const mgr = await SnapshotManager.forCurrentSession(process.cwd());
350
+ if (!mgr) {
351
+ console.log(dim(s.snapshotNoSession));
352
+ return;
353
+ }
354
+ const entry = await mgr.undo().catch(() => null);
355
+ if (!entry) {
356
+ console.log(dim(s.undoNone));
357
+ }
358
+ else {
359
+ console.log(ok(t(s.undoDone, { files: entry.files.join(', ') })));
360
+ }
361
+ });
362
+ program
363
+ .command('redo')
364
+ .description('Re-apply the last undone AI file edit')
365
+ .action(async () => {
366
+ const s = getStrings(await resolveLang());
367
+ const mgr = await SnapshotManager.forCurrentSession(process.cwd());
368
+ if (!mgr) {
369
+ console.log(dim(s.snapshotNoSession));
370
+ return;
371
+ }
372
+ const entry = await mgr.redo().catch(() => null);
373
+ if (!entry) {
374
+ console.log(dim(s.redoNone));
375
+ }
376
+ else {
377
+ console.log(ok(t(s.redoDone, { files: entry.files.join(', ') })));
378
+ }
379
+ });
380
+ program
381
+ .command('history')
382
+ .description('List recent AI file edits in this session (with timestamps)')
383
+ .action(async () => {
384
+ const s = getStrings(await resolveLang());
385
+ const mgr = await SnapshotManager.forCurrentSession(process.cwd());
386
+ if (!mgr) {
387
+ console.log(dim(s.snapshotNoSession));
388
+ return;
389
+ }
390
+ const entries = await mgr.list().catch(() => []);
391
+ if (entries.length === 0) {
392
+ console.log(dim(s.historyNone));
393
+ return;
394
+ }
395
+ console.log(dim(s.historyHeader));
396
+ for (const e of entries) {
397
+ const time = new Date(e.at).toLocaleTimeString();
398
+ console.log(accent(` ${time}`) + dim(` ${e.files.join(', ')}`) + dim(` — ${e.label.slice(0, 60)}`));
399
+ }
400
+ });
401
+ program
402
+ .command('clean')
403
+ .description('Clear old snapshot sessions to free disk space (.vexi/snapshots/)')
404
+ .action(async () => {
405
+ const s = getStrings(await resolveLang());
406
+ const mgr = await SnapshotManager.forCurrentSession(process.cwd());
407
+ const count = await SnapshotManager.cleanAll(process.cwd(), mgr?.sessionId);
408
+ console.log(ok(t(s.cleanDone, { count: String(count) })));
409
+ });
410
+ // ── URL-based provider setup (vexi setup) ────────────────────────────
411
+ program
412
+ .command('setup')
413
+ .description('Configure your API provider by pasting an endpoint URL')
414
+ .action(async () => {
415
+ const io = {
416
+ prompt: async (question) => {
417
+ const iface = nodeRlPromises.createInterface({
418
+ input: process.stdin,
419
+ output: process.stdout,
420
+ });
421
+ try {
422
+ return await iface.question(question);
423
+ }
424
+ finally {
425
+ iface.close();
426
+ }
427
+ },
428
+ write: (line) => console.log(line),
429
+ };
430
+ try {
431
+ await runOnboarding(io);
432
+ }
433
+ catch (e) {
434
+ console.error(err(e instanceof Error ? e.message : String(e)));
435
+ process.exitCode = 1;
436
+ }
437
+ });
438
+ // ── Self-update (vexi update) ─────────────────────────────────────────
439
+ program
440
+ .command('update')
441
+ .description('Update Vexi to the latest version (npm install -g vexi-cli@latest)')
442
+ .action(async () => {
443
+ await runUpdate();
444
+ });
445
+ // ── Self-uninstall (vexi uninstall) ──────────────────────────────────
446
+ program
447
+ .command('uninstall')
448
+ .description('Uninstall Vexi. Keeps ~/.vexi unless --purge is passed.')
449
+ .option('--purge', 'also delete ~/.vexi (config, keys, memory, sessions)')
450
+ .action(async (options) => {
451
+ await runUninstall(!!options.purge);
452
+ });
332
453
  return program;
333
454
  }
package/dist/config.js CHANGED
@@ -10,15 +10,29 @@
10
10
  import { promises as fs } from 'node:fs';
11
11
  import { homedir } from 'node:os';
12
12
  import { join } from 'node:path';
13
+ import { z } from 'zod';
13
14
  import { readJson, writeJsonAtomic } from './utils/fs-atomic.js';
15
+ const VexiConfigSchema = z.object({
16
+ provider: z.string().min(1),
17
+ displayName: z.string().optional(),
18
+ apiKey: z.string().min(1),
19
+ baseUrl: z.string().optional(),
20
+ model: z.string().optional(),
21
+ lang: z.string().optional(),
22
+ });
14
23
  export const VEXI_DIR = join(homedir(), '.vexi');
15
24
  export const CONFIG_PATH = join(VEXI_DIR, 'config.json');
16
- /** Load the config, or `null` if it doesn't exist / is unreadable. */
25
+ /** Load the config, or `null` if it doesn't exist / is unreadable / fails validation. */
17
26
  export async function loadConfig() {
18
- const config = await readJson(CONFIG_PATH);
19
- if (!config?.apiKey || !config.provider)
27
+ const raw = await readJson(CONFIG_PATH);
28
+ if (!raw)
20
29
  return null;
21
- return config;
30
+ const result = VexiConfigSchema.safeParse(raw);
31
+ if (!result.success) {
32
+ process.stderr.write(`[vexi] config validation failed: ${result.error.message}\n`);
33
+ return null;
34
+ }
35
+ return result.data;
22
36
  }
23
37
  /** Save the config atomically with owner-only permissions. */
24
38
  export async function saveConfig(config) {