vexi-cli 0.5.4 → 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.
@@ -16,9 +16,18 @@ export class ProviderError extends Error {
16
16
  }
17
17
  /** Human-friendly provider metadata. */
18
18
  export const PROVIDER_INFO = {
19
+ // ── International providers ───────────────────────────────────────────
19
20
  anthropic: { label: 'Anthropic (Claude)', defaultModel: 'claude-sonnet-4-5' },
20
21
  openai: { label: 'OpenAI (GPT)', defaultModel: 'gpt-4o-mini' },
21
22
  openrouter: { label: 'OpenRouter', defaultModel: 'openrouter/auto' },
22
- groq: { label: 'Groq', defaultModel: 'llama-3.3-70b-versatile' },
23
- gemini: { label: 'Google Gemini', defaultModel: 'gemini-2.5-flash' },
23
+ groq: { label: 'Groq (free tier)', defaultModel: 'llama-3.3-70b-versatile', free: true },
24
+ gemini: { label: 'Google Gemini (free tier)', defaultModel: 'gemini-2.5-flash', free: true },
25
+ mistral: { label: 'Mistral AI', defaultModel: 'mistral-small-latest' },
26
+ cerebras: { label: 'Cerebras (free tier)', defaultModel: 'llama-3.3-70b', free: true },
27
+ // ── Chinese AI providers ──────────────────────────────────────────────
28
+ glm: { label: 'Zhipu AI — GLM (free tier)', defaultModel: 'glm-4-flash', free: true },
29
+ deepseek: { label: 'DeepSeek (free tier)', defaultModel: 'deepseek-chat', free: true },
30
+ qwen: { label: 'Alibaba Qwen (free tier)', defaultModel: 'qwen-turbo', free: true },
31
+ moonshot: { label: 'Kimi — Moonshot AI (free tier)', defaultModel: 'moonshot-v1-8k', free: true },
32
+ minimax: { label: 'MiniMax (free tier)', defaultModel: 'MiniMax-Text-01', free: true },
24
33
  };
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Snapshot engine for undo/redo of AI file edits.
3
+ *
4
+ * Before executing any shell command that looks like a file write, Vexi
5
+ * saves a copy of only the affected files into:
6
+ *
7
+ * .vexi/snapshots/<session-id>/<entry-id>/files/<encoded-path>
8
+ *
9
+ * State (undo/redo stacks) is persisted in:
10
+ * .vexi/snapshots/<session-id>/state.json
11
+ *
12
+ * The active session is tracked in:
13
+ * .vexi/snapshots/current-session
14
+ *
15
+ * Design choices:
16
+ * - Only snapshots files it is about to change (NOT the whole project tree).
17
+ * - Max 50 snapshots per session; oldest pruned automatically.
18
+ * - Undo restores the pre-command file state; redo re-applies the reverted state.
19
+ * - New files created by a command are not deleted on undo (limitation v1).
20
+ */
21
+ import { promises as fs } from 'node:fs';
22
+ import { existsSync } from 'node:fs';
23
+ import { join, isAbsolute, relative, dirname } from 'node:path';
24
+ import { writeJsonAtomic, readJson } from '../utils/fs-atomic.js';
25
+ const MAX_SNAPSHOTS = 50;
26
+ function makeId() {
27
+ return Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
28
+ }
29
+ function encodeRelPath(relPath) {
30
+ return relPath.replace(/[\\/]/g, '__').replace(/:/g, '_C_');
31
+ }
32
+ export class SnapshotManager {
33
+ root;
34
+ sessionId;
35
+ sessionDir;
36
+ statePath;
37
+ _state = null;
38
+ constructor(root, sessionId) {
39
+ this.root = root;
40
+ this.sessionId = sessionId;
41
+ this.sessionDir = join(root, '.vexi', 'snapshots', sessionId);
42
+ this.statePath = join(this.sessionDir, 'state.json');
43
+ }
44
+ /** Create a manager pointing at the most-recently registered session. */
45
+ static async forCurrentSession(root) {
46
+ const markerPath = join(root, '.vexi', 'snapshots', 'current-session');
47
+ try {
48
+ const id = (await fs.readFile(markerPath, 'utf-8')).trim();
49
+ if (id)
50
+ return new SnapshotManager(root, id);
51
+ }
52
+ catch { }
53
+ return null;
54
+ }
55
+ /** Write the "current-session" marker so CLI commands can find this session. */
56
+ async registerAsCurrentSession() {
57
+ const markerPath = join(this.root, '.vexi', 'snapshots', 'current-session');
58
+ await fs.mkdir(dirname(markerPath), { recursive: true });
59
+ await fs.writeFile(markerPath, this.sessionId, 'utf-8');
60
+ }
61
+ async getState() {
62
+ if (this._state)
63
+ return this._state;
64
+ const loaded = await readJson(this.statePath);
65
+ this._state = loaded ?? {
66
+ sessionId: this.sessionId,
67
+ undoStack: [],
68
+ redoStack: [],
69
+ entries: {},
70
+ };
71
+ return this._state;
72
+ }
73
+ async persistState() {
74
+ if (this._state)
75
+ await writeJsonAtomic(this.statePath, this._state);
76
+ }
77
+ async saveFiles(id, relPaths) {
78
+ const dir = join(this.sessionDir, id, 'files');
79
+ await fs.mkdir(dir, { recursive: true });
80
+ const saved = [];
81
+ for (const rel of relPaths) {
82
+ const abs = isAbsolute(rel) ? rel : join(this.root, rel);
83
+ if (!existsSync(abs))
84
+ continue;
85
+ await fs.copyFile(abs, join(dir, encodeRelPath(rel)));
86
+ saved.push(rel);
87
+ }
88
+ return saved;
89
+ }
90
+ async restoreFiles(id, relPaths) {
91
+ const dir = join(this.sessionDir, id, 'files');
92
+ for (const rel of relPaths) {
93
+ const dest = isAbsolute(rel) ? rel : join(this.root, rel);
94
+ try {
95
+ await fs.copyFile(join(dir, encodeRelPath(rel)), dest);
96
+ }
97
+ catch { }
98
+ }
99
+ }
100
+ async deleteEntry(state, id) {
101
+ delete state.entries[id];
102
+ await fs.rm(join(this.sessionDir, id), { recursive: true, force: true }).catch(() => { });
103
+ }
104
+ /**
105
+ * Save the current state of `files` before applying an edit.
106
+ * Call this right before running the shell command.
107
+ * Returns true if at least one file was snapshotted.
108
+ */
109
+ async takeSnapshot(files, label) {
110
+ if (files.length === 0)
111
+ return false;
112
+ const state = await this.getState();
113
+ const id = makeId();
114
+ const saved = await this.saveFiles(id, files);
115
+ if (saved.length === 0)
116
+ return false;
117
+ state.entries[id] = { id, at: Date.now(), label, files: saved };
118
+ state.undoStack.push(id);
119
+ state.redoStack = []; // new edit invalidates redo history
120
+ while (state.undoStack.length > MAX_SNAPSHOTS) {
121
+ await this.deleteEntry(state, state.undoStack.shift());
122
+ }
123
+ await this.persistState();
124
+ return true;
125
+ }
126
+ /** Restore the last snapshotted file state. Returns the reverted entry, or null if nothing to undo. */
127
+ async undo() {
128
+ const state = await this.getState();
129
+ if (state.undoStack.length === 0)
130
+ return null;
131
+ const id = state.undoStack[state.undoStack.length - 1];
132
+ const entry = state.entries[id];
133
+ if (!entry)
134
+ return null;
135
+ // Save current (post-edit) state as a redo point covering the same files
136
+ const redoId = makeId();
137
+ const redoSaved = await this.saveFiles(redoId, entry.files);
138
+ if (redoSaved.length > 0) {
139
+ state.entries[redoId] = { id: redoId, at: Date.now(), label: entry.label, files: redoSaved };
140
+ state.redoStack.push(redoId);
141
+ }
142
+ await this.restoreFiles(id, entry.files);
143
+ state.undoStack.pop();
144
+ await this.deleteEntry(state, id);
145
+ await this.persistState();
146
+ return entry;
147
+ }
148
+ /** Re-apply the last undone change. Returns the re-applied entry, or null if nothing to redo. */
149
+ async redo() {
150
+ const state = await this.getState();
151
+ if (state.redoStack.length === 0)
152
+ return null;
153
+ const id = state.redoStack[state.redoStack.length - 1];
154
+ const entry = state.entries[id];
155
+ if (!entry)
156
+ return null;
157
+ // Save current (post-undo) state back onto the undo stack
158
+ const undoId = makeId();
159
+ const undoSaved = await this.saveFiles(undoId, entry.files);
160
+ if (undoSaved.length > 0) {
161
+ state.entries[undoId] = { id: undoId, at: Date.now(), label: entry.label, files: undoSaved };
162
+ state.undoStack.push(undoId);
163
+ }
164
+ await this.restoreFiles(id, entry.files);
165
+ state.redoStack.pop();
166
+ await this.deleteEntry(state, id);
167
+ await this.persistState();
168
+ return entry;
169
+ }
170
+ /** List all available undo entries, newest first. */
171
+ async list() {
172
+ const state = await this.getState();
173
+ return [...state.undoStack]
174
+ .reverse()
175
+ .map((id) => state.entries[id])
176
+ .filter(Boolean);
177
+ }
178
+ /** Remove all snapshot sessions except this one (and the current-session marker). */
179
+ async clean() {
180
+ return SnapshotManager.cleanAll(this.root, this.sessionId);
181
+ }
182
+ /** Remove all snapshot session directories, optionally keeping one by ID. */
183
+ static async cleanAll(root, keepSessionId) {
184
+ const snapshotsDir = join(root, '.vexi', 'snapshots');
185
+ let count = 0;
186
+ try {
187
+ const entries = await fs.readdir(snapshotsDir, { withFileTypes: true });
188
+ for (const entry of entries) {
189
+ if (!entry.isDirectory())
190
+ continue;
191
+ if (keepSessionId && entry.name === keepSessionId)
192
+ continue;
193
+ try {
194
+ await fs.rm(join(snapshotsDir, entry.name), { recursive: true, force: true });
195
+ count++;
196
+ }
197
+ catch { }
198
+ }
199
+ }
200
+ catch { }
201
+ return count;
202
+ }
203
+ /**
204
+ * Extract file paths that a shell command is likely to write.
205
+ * Returns relative paths (relative to cwd) for files that currently exist on disk.
206
+ * This is best-effort: covers common patterns (cat >, sed -i, mv, cp, tee, etc.)
207
+ * and files with known extensions that appear as bare tokens in the command.
208
+ */
209
+ static extractFilePaths(command, cwd) {
210
+ const found = new Set();
211
+ const tryAdd = (raw) => {
212
+ if (!raw)
213
+ return;
214
+ const p = raw.trim().replace(/^["']|["']$/g, '');
215
+ if (!p || p.startsWith('-') || p.startsWith('http') || p === '/dev/null')
216
+ return;
217
+ const abs = isAbsolute(p) ? p : join(cwd, p);
218
+ if (existsSync(abs)) {
219
+ found.add(relative(cwd, abs).replace(/\\/g, '/'));
220
+ }
221
+ };
222
+ // cat > file / cat >> file
223
+ for (const m of command.matchAll(/\bcat\s+>>?\s+(\S+)/gi))
224
+ tryAdd(m[1]);
225
+ // echo ... > file / >> file
226
+ for (const m of command.matchAll(/\becho\b.*?>>?\s+(\S+)/gi))
227
+ tryAdd(m[1]);
228
+ // sed -i[suffix] 'expr' file
229
+ for (const m of command.matchAll(/\bsed\b.*?-i\S*\s+(?:'[^']*'|"[^"]*"|\S+)\s+(\S+)/gi))
230
+ tryAdd(m[1]);
231
+ // patch [options] file
232
+ for (const m of command.matchAll(/\bpatch\b(?:\s+-[a-zA-Z0-9]+)*\s+(\S+)/gi))
233
+ tryAdd(m[1]);
234
+ // tee file
235
+ for (const m of command.matchAll(/\btee\s+(\S+)/gi))
236
+ tryAdd(m[1]);
237
+ // cp src dst
238
+ for (const m of command.matchAll(/\bcp\b(?:\s+-[a-zA-Z]+)*\s+(\S+)\s+(\S+)/gi)) {
239
+ tryAdd(m[1]);
240
+ tryAdd(m[2]);
241
+ }
242
+ // mv src dst
243
+ for (const m of command.matchAll(/\bmv\b(?:\s+-[a-zA-Z]+)*\s+(\S+)\s+(\S+)/gi)) {
244
+ tryAdd(m[1]);
245
+ tryAdd(m[2]);
246
+ }
247
+ // node/deno: fs.writeFileSync('file', ...)
248
+ for (const m of command.matchAll(/writeFileSync\s*\(\s*["']([^"']+)["']/gi))
249
+ tryAdd(m[1]);
250
+ // python: open('file', 'w') or open('file', 'a')
251
+ for (const m of command.matchAll(/\bopen\s*\(\s*["']([^"']+)["']\s*,\s*["'][wa]/gi))
252
+ tryAdd(m[1]);
253
+ // PowerShell: Set-Content / Out-File / Add-Content
254
+ for (const m of command.matchAll(/\b(?:Set-Content|Out-File|Add-Content)\b.*?-(?:Path|Value|FilePath)\s+["']?(\S+?)["']?(?:\s|$)/gi))
255
+ tryAdd(m[1]);
256
+ // Generic: bare token with a known source-file extension
257
+ for (const m of command.matchAll(/(?:^|[\s;|&>])["']?([\w./\\-]+\.(?:ts|tsx|js|jsx|mjs|cjs|json|md|py|rs|go|java|c|cpp|h|hpp|css|scss|html|yaml|yml|toml|sh|env|txt|xml|sql|graphql|vue|svelte|rb|php|swift|kt|cs))["']?(?=\s|$|[;|&])/gim))
258
+ tryAdd(m[1]);
259
+ return [...found];
260
+ }
261
+ }
@@ -0,0 +1,105 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { promises as fs } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { SnapshotManager } from './index.js';
6
+ async function mkTmp() {
7
+ return fs.mkdtemp(join(tmpdir(), 'vexi-snap-test-'));
8
+ }
9
+ async function writeFile(dir, name, content) {
10
+ const p = join(dir, name);
11
+ await fs.writeFile(p, content, 'utf8');
12
+ return p;
13
+ }
14
+ describe('SnapshotManager', () => {
15
+ let root;
16
+ let manager;
17
+ beforeEach(async () => {
18
+ root = await mkTmp();
19
+ manager = new SnapshotManager(root, 'test-session');
20
+ });
21
+ afterEach(async () => {
22
+ await fs.rm(root, { recursive: true, force: true });
23
+ });
24
+ it('round-trip: takeSnapshot → undo restores file content', async () => {
25
+ const filePath = await writeFile(root, 'hello.txt', 'original');
26
+ const snapped = await manager.takeSnapshot([filePath], 'initial write');
27
+ expect(snapped).toBe(true);
28
+ await fs.writeFile(filePath, 'modified', 'utf8');
29
+ const entry = await manager.undo();
30
+ expect(entry).not.toBeNull();
31
+ expect(entry.files.some((f) => f.endsWith('hello.txt') || f === filePath)).toBe(true);
32
+ const restored = await fs.readFile(filePath, 'utf8');
33
+ expect(restored).toBe('original');
34
+ });
35
+ it('round-trip: undo → redo restores modified content', async () => {
36
+ const filePath = await writeFile(root, 'hello.txt', 'original');
37
+ await manager.takeSnapshot([filePath], 'initial write');
38
+ await fs.writeFile(filePath, 'modified', 'utf8');
39
+ await manager.undo();
40
+ const afterUndo = await fs.readFile(filePath, 'utf8');
41
+ expect(afterUndo).toBe('original');
42
+ const redoEntry = await manager.redo();
43
+ expect(redoEntry).not.toBeNull();
44
+ const afterRedo = await fs.readFile(filePath, 'utf8');
45
+ expect(afterRedo).toBe('modified');
46
+ });
47
+ it('list() returns newest snapshot first', async () => {
48
+ const filePath = await writeFile(root, 'a.txt', 'v1');
49
+ await manager.takeSnapshot([filePath], 'first');
50
+ await fs.writeFile(filePath, 'v2', 'utf8');
51
+ await manager.takeSnapshot([filePath], 'second');
52
+ const list = await manager.list();
53
+ expect(list.length).toBe(2);
54
+ expect(list[0].label).toBe('second');
55
+ expect(list[1].label).toBe('first');
56
+ });
57
+ it('undo() returns null when stack is empty', async () => {
58
+ const result = await manager.undo();
59
+ expect(result).toBeNull();
60
+ });
61
+ it('redo() returns null when stack is empty', async () => {
62
+ const result = await manager.redo();
63
+ expect(result).toBeNull();
64
+ });
65
+ it('takeSnapshot clears redo stack', async () => {
66
+ const filePath = await writeFile(root, 'b.txt', 'v1');
67
+ await manager.takeSnapshot([filePath], 'snap1');
68
+ await fs.writeFile(filePath, 'v2', 'utf8');
69
+ await manager.undo();
70
+ // new snapshot after undo — redo stack should be cleared
71
+ await manager.takeSnapshot([filePath], 'snap2');
72
+ const redo = await manager.redo();
73
+ expect(redo).toBeNull();
74
+ });
75
+ it('takeSnapshot returns false when no files exist', async () => {
76
+ const result = await manager.takeSnapshot([join(root, 'ghost.txt')], 'missing');
77
+ expect(result).toBe(false);
78
+ });
79
+ });
80
+ describe('SnapshotManager.extractFilePaths', () => {
81
+ let cwd;
82
+ beforeEach(async () => {
83
+ cwd = await fs.mkdtemp(join(tmpdir(), 'vexi-ext-test-'));
84
+ // create the files so existsSync passes
85
+ await fs.mkdir(join(cwd, 'src'), { recursive: true });
86
+ await fs.writeFile(join(cwd, 'src', 'app.ts'), '', 'utf8');
87
+ await fs.writeFile(join(cwd, 'src', 'utils.js'), '', 'utf8');
88
+ });
89
+ afterEach(async () => {
90
+ await fs.rm(cwd, { recursive: true, force: true });
91
+ });
92
+ it('extracts path from cat > redirect', () => {
93
+ const paths = SnapshotManager.extractFilePaths('cat > src/app.ts', cwd);
94
+ expect(paths.length).toBeGreaterThan(0);
95
+ expect(paths.some((p) => p.endsWith('app.ts'))).toBe(true);
96
+ });
97
+ it('extracts path from sed -i command', () => {
98
+ const paths = SnapshotManager.extractFilePaths("sed -i 's/foo/bar/' src/utils.js", cwd);
99
+ expect(paths.some((p) => p.endsWith('utils.js'))).toBe(true);
100
+ });
101
+ it('returns empty array for non-file commands', () => {
102
+ const paths = SnapshotManager.extractFilePaths('npm install', cwd);
103
+ expect(paths).toEqual([]);
104
+ });
105
+ });
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Update management: check, install, and uninstall.
3
+ *
4
+ * checkForUpdate - non-blocking; returns cached result fast, fires network
5
+ * check in the background and caches it for the next run.
6
+ * runUpdate - npm install -g vexi-cli@latest with streamed output.
7
+ * runUninstall - npm uninstall -g vexi-cli with optional ~/.vexi purge.
8
+ */
9
+ import { promises as fs } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { spawn } from 'node:child_process';
12
+ import { confirm } from '@inquirer/prompts';
13
+ import { VEXI_DIR } from '../config.js';
14
+ import { VERSION } from '../version.js';
15
+ import { dim, err, ok, warn, accent } from '../ui/index.js';
16
+ const UPDATE_CACHE_PATH = join(VEXI_DIR, 'update-check.json');
17
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
18
+ const FETCH_TIMEOUT_MS = 1500;
19
+ const NPM_REGISTRY = 'https://registry.npmjs.org/vexi-cli/latest';
20
+ /** Returns true if version string `a` is strictly greater than `b`. */
21
+ function semverGt(a, b) {
22
+ const parts = (v) => v.replace(/[^0-9.]/g, '').split('.').map((n) => parseInt(n, 10) || 0);
23
+ const [aMaj = 0, aMin = 0, aPat = 0] = parts(a);
24
+ const [bMaj = 0, bMin = 0, bPat = 0] = parts(b);
25
+ if (aMaj !== bMaj)
26
+ return aMaj > bMaj;
27
+ if (aMin !== bMin)
28
+ return aMin > bMin;
29
+ return aPat > bPat;
30
+ }
31
+ /**
32
+ * Check whether a newer version of vexi-cli is published on npm.
33
+ *
34
+ * - Returns the newer version string, or null (no update / check failed).
35
+ * - Reads a local cache first; only hits the network when the cache is
36
+ * older than 24 hours.
37
+ * - When the cache is stale, fires a background fetch (max 1500 ms) and
38
+ * updates the cache when done. Returns null for this run so startup is
39
+ * never blocked by the network.
40
+ * - All errors are swallowed silently.
41
+ */
42
+ export async function checkForUpdate() {
43
+ try {
44
+ const cacheRaw = await fs.readFile(UPDATE_CACHE_PATH, 'utf8').catch(() => null);
45
+ if (cacheRaw) {
46
+ const cache = JSON.parse(cacheRaw);
47
+ const age = Date.now() - new Date(cache.lastCheck).getTime();
48
+ if (age < CHECK_INTERVAL_MS) {
49
+ // Cache is fresh: return immediately
50
+ return semverGt(cache.latest, VERSION) ? cache.latest : null;
51
+ }
52
+ }
53
+ // Cache is stale or missing: fire a background fetch and return null now
54
+ void (async () => {
55
+ try {
56
+ const controller = new AbortController();
57
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
58
+ const res = await fetch(NPM_REGISTRY, { signal: controller.signal });
59
+ clearTimeout(timer);
60
+ if (!res.ok)
61
+ return;
62
+ const json = (await res.json());
63
+ const latest = json.version;
64
+ if (!latest)
65
+ return;
66
+ await fs.mkdir(VEXI_DIR, { recursive: true }).catch(() => { });
67
+ await fs.writeFile(UPDATE_CACHE_PATH, JSON.stringify({ lastCheck: new Date().toISOString(), latest }), 'utf8').catch(() => { });
68
+ }
69
+ catch {
70
+ // network error or timeout -- silently ignored
71
+ }
72
+ })();
73
+ return null;
74
+ }
75
+ catch {
76
+ return null;
77
+ }
78
+ }
79
+ /** Spawn npm with inherited stdio and return whether it exited successfully. */
80
+ async function runNpm(args) {
81
+ return new Promise((resolve) => {
82
+ const cmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
83
+ const child = spawn(cmd, args, { stdio: 'inherit' });
84
+ child.on('error', (e) => {
85
+ if (e.code === 'ENOENT') {
86
+ console.error(err('\nnpm not found. Install Node.js (which includes npm) from https://nodejs.org'));
87
+ }
88
+ else {
89
+ console.error(err(`\nFailed to run npm: ${e.message}`));
90
+ }
91
+ resolve(false);
92
+ });
93
+ child.on('close', (code) => resolve(code === 0));
94
+ });
95
+ }
96
+ /**
97
+ * Run `npm install -g vexi-cli@latest`, streaming npm output to the terminal.
98
+ * Prints the manual command and exits non-zero on any failure.
99
+ */
100
+ export async function runUpdate() {
101
+ console.log(dim('Running: npm install -g vexi-cli@latest\n'));
102
+ const ok_ = await runNpm(['install', '-g', 'vexi-cli@latest']);
103
+ if (ok_) {
104
+ console.log('\n' + ok('Vexi updated successfully.'));
105
+ console.log(dim('Restart your terminal or run `vexi --version` to confirm.'));
106
+ }
107
+ else {
108
+ console.log('\n' + warn('If the error above is a permissions problem, try one of:'));
109
+ console.log(dim(' sudo npm install -g vexi-cli@latest'));
110
+ console.log(dim(' # Windows: run your terminal as Administrator'));
111
+ console.log(dim(' # or use a Node version manager (nvm, fnm, volta)'));
112
+ console.log(dim('\nManual command: npm install -g vexi-cli@latest'));
113
+ process.exitCode = 1;
114
+ }
115
+ }
116
+ /**
117
+ * Uninstall vexi-cli.
118
+ *
119
+ * 1. Describes what will happen and asks for confirmation.
120
+ * 2. Runs `npm uninstall -g vexi-cli`, streaming output.
121
+ * 3. If --purge: asks for a SECOND confirmation, then deletes ~/.vexi.
122
+ *
123
+ * Always prints the manual command, because on some platforms (Windows,
124
+ * certain npm prefixes) a running binary cannot delete itself in-process.
125
+ */
126
+ export async function runUninstall(purge) {
127
+ // Step 1: Describe what will happen
128
+ console.log();
129
+ console.log(accent('What will happen:'));
130
+ console.log(dim(' - Remove: vexi-cli global npm package'));
131
+ if (purge) {
132
+ console.log(warn(` - Delete: ${VEXI_DIR}`));
133
+ console.log(warn(' (config, API keys, memory, snapshots -- permanent!)'));
134
+ }
135
+ else {
136
+ console.log(dim(` - Keep: ${VEXI_DIR} (your config, keys, and memory are preserved)`));
137
+ console.log(dim(' Use --purge to also delete this directory.'));
138
+ }
139
+ console.log();
140
+ // Step 2: First confirmation
141
+ const proceed = await confirm({ message: 'Remove vexi-cli?', default: false }).catch(() => false);
142
+ if (!proceed) {
143
+ console.log(dim('\nCancelled. Nothing was changed.'));
144
+ return;
145
+ }
146
+ console.log(dim('\nRunning: npm uninstall -g vexi-cli\n'));
147
+ const uninstallOk = await runNpm(['uninstall', '-g', 'vexi-cli']);
148
+ if (!uninstallOk) {
149
+ console.log('\n' + warn('Uninstall may have failed. Run this manually if needed:'));
150
+ console.log(dim(' npm uninstall -g vexi-cli'));
151
+ if (purge) {
152
+ console.log(dim(' rm -rf ~/.vexi # macOS / Linux'));
153
+ console.log(dim(' rmdir /s /q %USERPROFILE%\\.vexi # Windows'));
154
+ }
155
+ process.exitCode = 1;
156
+ return;
157
+ }
158
+ console.log('\n' + ok('vexi-cli uninstalled.'));
159
+ console.log(dim('Manual command for reference: npm uninstall -g vexi-cli'));
160
+ console.log(dim('To reinstall: npm install -g vexi-cli'));
161
+ // Step 3: Purge ~/.vexi if requested
162
+ if (purge) {
163
+ console.log();
164
+ console.log(warn(`About to permanently delete ${VEXI_DIR}`));
165
+ console.log(warn('This removes config, API keys, memory, and sessions. This cannot be undone.'));
166
+ console.log();
167
+ const confirmPurge = await confirm({
168
+ message: `Delete ${VEXI_DIR} permanently?`,
169
+ default: false,
170
+ }).catch(() => false);
171
+ if (!confirmPurge) {
172
+ console.log(dim(`\n${VEXI_DIR} was not deleted.`));
173
+ console.log(dim(`To remove it manually:`));
174
+ console.log(dim(` rm -rf ~/.vexi # macOS / Linux`));
175
+ console.log(dim(` rmdir /s /q %USERPROFILE%\\.vexi # Windows`));
176
+ return;
177
+ }
178
+ try {
179
+ await fs.rm(VEXI_DIR, { recursive: true, force: true });
180
+ console.log(ok(`Deleted ${VEXI_DIR}`));
181
+ }
182
+ catch (e) {
183
+ console.error(err(`Could not delete ${VEXI_DIR}: ${e instanceof Error ? e.message : String(e)}`));
184
+ console.log(dim('Remove it manually:'));
185
+ console.log(dim(` rm -rf ~/.vexi # macOS / Linux`));
186
+ console.log(dim(` rmdir /s /q %USERPROFILE%\\.vexi # Windows`));
187
+ process.exitCode = 1;
188
+ }
189
+ }
190
+ }
@@ -0,0 +1,4 @@
1
+ import { createRequire } from 'node:module';
2
+ const require = createRequire(import.meta.url);
3
+ const pkg = require('../package.json');
4
+ export const VERSION = pkg.version;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vexi-cli",
3
- "version": "0.5.4",
3
+ "version": "0.8.0",
4
4
  "description": "Open-source AI coding agent for your terminal. Bring your own key, zero config, multilingual.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,6 +19,8 @@
19
19
  "build": "tsc",
20
20
  "dev": "tsc --watch",
21
21
  "start": "node dist/index.js",
22
+ "test": "vitest run",
23
+ "lint": "eslint src",
22
24
  "prepublishOnly": "npm run build"
23
25
  },
24
26
  "keywords": [
@@ -33,6 +35,9 @@
33
35
  "gemini",
34
36
  "groq",
35
37
  "openrouter",
38
+ "glm",
39
+ "mistral",
40
+ "cerebras",
36
41
  "multilingual"
37
42
  ],
38
43
  "author": "Elomami1976",
@@ -55,6 +60,10 @@
55
60
  },
56
61
  "devDependencies": {
57
62
  "@types/node": "^22.10.0",
58
- "typescript": "^5.7.0"
63
+ "@typescript-eslint/eslint-plugin": "^8.61.1",
64
+ "@typescript-eslint/parser": "^8.61.1",
65
+ "eslint": "^9.39.4",
66
+ "typescript": "^5.7.0",
67
+ "vitest": "^4.1.9"
59
68
  }
60
69
  }