vexi-cli 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Project scanner — Feature 2: Full Project Understanding.
3
+ *
4
+ * On startup Vexi walks the project tree and builds a compact map of the
5
+ * codebase: languages, frameworks, architecture layers (frontend / backend /
6
+ * database / auth / devops) and the file tree. The result is cached in
7
+ * .vexi/project.json and a short text summary is injected into every AI
8
+ * prompt so the model understands the whole project, not just one file.
9
+ *
10
+ * Safeguards (prevent context flooding):
11
+ * - Respects .gitignore rules by default
12
+ * - Always excludes node_modules, .git, dist, build, coverage, etc.
13
+ * - Ignores files larger than MAX_FILE_SIZE (500KB)
14
+ * - Caps the number of files included in the summary
15
+ */
16
+ import { promises as fs } from 'node:fs';
17
+ import { basename, join, relative } from 'node:path';
18
+ import { readJson, writeJsonAtomic } from '../utils/fs-atomic.js';
19
+ import { GitignoreMatcher } from './gitignore.js';
20
+ /** Hard-excluded directories — never scanned regardless of .gitignore. */
21
+ const ALWAYS_EXCLUDE = new Set([
22
+ 'node_modules',
23
+ '.git',
24
+ 'dist',
25
+ 'build',
26
+ 'out',
27
+ 'coverage',
28
+ '.vexi',
29
+ '.next',
30
+ '.nuxt',
31
+ '.svelte-kit',
32
+ '.turbo',
33
+ '.cache',
34
+ 'vendor',
35
+ '__pycache__',
36
+ '.venv',
37
+ 'venv',
38
+ '.idea',
39
+ '.vscode',
40
+ ]);
41
+ /** Ignore files larger than this (bytes) — configurable ceiling. */
42
+ export const MAX_FILE_SIZE = 500 * 1024;
43
+ /** Cap on files recorded in project.json (keeps prompts small). */
44
+ const MAX_FILES = 400;
45
+ /** Max directory depth to walk. */
46
+ const MAX_DEPTH = 8;
47
+ const EXT_LANGUAGES = {
48
+ '.ts': 'TypeScript',
49
+ '.tsx': 'TypeScript (React)',
50
+ '.js': 'JavaScript',
51
+ '.jsx': 'JavaScript (React)',
52
+ '.py': 'Python',
53
+ '.go': 'Go',
54
+ '.rs': 'Rust',
55
+ '.java': 'Java',
56
+ '.kt': 'Kotlin',
57
+ '.rb': 'Ruby',
58
+ '.php': 'PHP',
59
+ '.cs': 'C#',
60
+ '.cpp': 'C++',
61
+ '.c': 'C',
62
+ '.swift': 'Swift',
63
+ '.vue': 'Vue',
64
+ '.svelte': 'Svelte',
65
+ '.sql': 'SQL',
66
+ '.sh': 'Shell',
67
+ '.css': 'CSS',
68
+ '.scss': 'SCSS',
69
+ '.html': 'HTML',
70
+ };
71
+ /** npm dependency → human-readable stack entry. */
72
+ const DEP_STACK = {
73
+ next: 'Next.js',
74
+ react: 'React',
75
+ vue: 'Vue',
76
+ svelte: 'Svelte',
77
+ '@angular/core': 'Angular',
78
+ express: 'Express',
79
+ fastify: 'Fastify',
80
+ hono: 'Hono',
81
+ nestjs: 'NestJS',
82
+ '@nestjs/core': 'NestJS',
83
+ prisma: 'Prisma',
84
+ '@prisma/client': 'Prisma',
85
+ mongoose: 'MongoDB (Mongoose)',
86
+ pg: 'PostgreSQL',
87
+ mysql2: 'MySQL',
88
+ sqlite3: 'SQLite',
89
+ 'better-sqlite3': 'SQLite',
90
+ drizzle_orm: 'Drizzle ORM',
91
+ 'drizzle-orm': 'Drizzle ORM',
92
+ tailwindcss: 'Tailwind CSS',
93
+ '@supabase/supabase-js': 'Supabase',
94
+ firebase: 'Firebase',
95
+ 'next-auth': 'NextAuth',
96
+ '@auth/core': 'Auth.js',
97
+ passport: 'Passport',
98
+ jsonwebtoken: 'JWT',
99
+ stripe: 'Stripe',
100
+ vite: 'Vite',
101
+ webpack: 'Webpack',
102
+ electron: 'Electron',
103
+ 'react-native': 'React Native',
104
+ jest: 'Jest',
105
+ vitest: 'Vitest',
106
+ typescript: 'TypeScript',
107
+ commander: 'CLI (commander)',
108
+ };
109
+ /** Filename/dependency evidence for architecture layers. */
110
+ const LAYER_HINTS = [
111
+ { layer: 'frontend', test: (f) => /\.(tsx|jsx|vue|svelte|css|scss|html)$/.test(f) || /^(src\/)?(components|pages|app|views|public)\//.test(f) },
112
+ { layer: 'backend', test: (f) => /^(src\/)?(api|server|routes|controllers|services|middleware)\//.test(f) || /server\.(ts|js|py|go)$/.test(f) },
113
+ { layer: 'database', test: (f) => /(schema\.(prisma|sql)|migrations?\/|models?\/|\.sql$)/.test(f) },
114
+ { layer: 'auth', test: (f) => /(auth|login|session|jwt|oauth)/i.test(f) },
115
+ { layer: 'devops', test: (f) => /(dockerfile|docker-compose|\.github\/workflows|\.gitlab-ci|terraform|k8s|helm)/i.test(f) },
116
+ { layer: 'tests', test: (f) => /(\.(test|spec)\.|__tests__\/|^tests?\/)/.test(f) },
117
+ ];
118
+ /** Scan the project rooted at `root` and cache the result. */
119
+ export async function scanProject(root) {
120
+ const matcher = new GitignoreMatcher();
121
+ const gitignore = await fs.readFile(join(root, '.gitignore'), 'utf8').catch(() => '');
122
+ if (gitignore)
123
+ matcher.add(gitignore);
124
+ const files = [];
125
+ let truncated = false;
126
+ async function walk(dir, depth) {
127
+ if (depth > MAX_DEPTH || files.length >= MAX_FILES)
128
+ return;
129
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
130
+ for (const entry of entries) {
131
+ if (files.length >= MAX_FILES) {
132
+ truncated = true;
133
+ return;
134
+ }
135
+ const abs = join(dir, entry.name);
136
+ const rel = relative(root, abs).replaceAll('\\', '/');
137
+ if (entry.isDirectory()) {
138
+ if (ALWAYS_EXCLUDE.has(entry.name) || entry.name.startsWith('.'))
139
+ continue;
140
+ if (matcher.ignores(rel, true))
141
+ continue;
142
+ await walk(abs, depth + 1);
143
+ }
144
+ else if (entry.isFile()) {
145
+ if (matcher.ignores(rel, false))
146
+ continue;
147
+ const stat = await fs.stat(abs).catch(() => null);
148
+ if (!stat || stat.size > MAX_FILE_SIZE)
149
+ continue;
150
+ files.push(rel);
151
+ }
152
+ }
153
+ }
154
+ await walk(root, 0);
155
+ files.sort();
156
+ // ── Languages ──
157
+ const languages = {};
158
+ for (const file of files) {
159
+ const ext = file.slice(file.lastIndexOf('.'));
160
+ const lang = EXT_LANGUAGES[ext];
161
+ if (lang)
162
+ languages[lang] = (languages[lang] ?? 0) + 1;
163
+ }
164
+ // ── Stack (from package.json dependencies) ──
165
+ const stack = new Set();
166
+ const pkg = await readJson(join(root, 'package.json'));
167
+ if (pkg) {
168
+ for (const dep of Object.keys({ ...pkg.dependencies, ...pkg.devDependencies })) {
169
+ if (DEP_STACK[dep])
170
+ stack.add(DEP_STACK[dep]);
171
+ }
172
+ }
173
+ // Non-npm ecosystems
174
+ if (files.includes('requirements.txt') || files.includes('pyproject.toml'))
175
+ stack.add('Python');
176
+ if (files.includes('go.mod'))
177
+ stack.add('Go');
178
+ if (files.includes('Cargo.toml'))
179
+ stack.add('Rust');
180
+ if (files.includes('composer.json'))
181
+ stack.add('PHP');
182
+ // ── Architecture layers ──
183
+ const layers = {};
184
+ for (const { layer, test } of LAYER_HINTS) {
185
+ const evidence = files.filter(test).slice(0, 5);
186
+ if (evidence.length > 0)
187
+ layers[layer] = evidence;
188
+ }
189
+ const map = {
190
+ name: basename(root),
191
+ scannedAt: new Date().toISOString(),
192
+ fileCount: files.length,
193
+ truncated,
194
+ languages,
195
+ stack: [...stack],
196
+ layers,
197
+ files,
198
+ };
199
+ // Cache for other features (and future MCP resource) — best effort.
200
+ await writeJsonAtomic(join(root, '.vexi', 'project.json'), map).catch(() => { });
201
+ return map;
202
+ }
203
+ /**
204
+ * Render the project map as a compact text block for the system prompt.
205
+ * Kept deliberately small (~1-2KB) so it never floods the context window.
206
+ */
207
+ export function projectSummary(map) {
208
+ const lines = [`Project: ${map.name} (${map.fileCount}${map.truncated ? '+' : ''} files)`];
209
+ const langs = Object.entries(map.languages)
210
+ .sort((a, b) => b[1] - a[1])
211
+ .slice(0, 5)
212
+ .map(([lang, n]) => `${lang} (${n})`);
213
+ if (langs.length)
214
+ lines.push(`Languages: ${langs.join(', ')}`);
215
+ if (map.stack.length)
216
+ lines.push(`Stack: ${map.stack.join(', ')}`);
217
+ for (const [layer, evidence] of Object.entries(map.layers)) {
218
+ lines.push(`${layer[0].toUpperCase() + layer.slice(1)}: ${evidence.join(', ')}`);
219
+ }
220
+ // Top-level structure only (max ~40 entries) — enough for orientation.
221
+ const topLevel = new Set();
222
+ for (const file of map.files) {
223
+ const slash = file.indexOf('/');
224
+ topLevel.add(slash === -1 ? file : file.slice(0, slash) + '/');
225
+ if (topLevel.size >= 40)
226
+ break;
227
+ }
228
+ lines.push(`Structure: ${[...topLevel].join(' ')}`);
229
+ return lines.join('\n');
230
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Custom Skills system — Feature 2.5.
3
+ *
4
+ * Skills are plain markdown files in `.vexi/skills/` (per project). On
5
+ * session start every skill is read and injected into the system prompt,
6
+ * so generated code follows the user's own conventions, e.g.:
7
+ *
8
+ * .vexi/skills/api-style.md "All API endpoints follow REST + Zod validation"
9
+ * .vexi/skills/arabic-docs.md "All documentation written in Arabic"
10
+ *
11
+ * Skills can be added from local files or shared via GitHub URLs:
12
+ * vexi skill add https://github.com/user/react-best-practices
13
+ */
14
+ import { promises as fs } from 'node:fs';
15
+ import { basename, extname, join } from 'node:path';
16
+ /** Max size of a single skill file (keeps the system prompt sane). */
17
+ const MAX_SKILL_SIZE = 64 * 1024;
18
+ function skillsDir(root) {
19
+ return join(root, '.vexi', 'skills');
20
+ }
21
+ /** Load all skills for a project (empty array when none exist). */
22
+ export async function loadSkills(root) {
23
+ const dir = skillsDir(root);
24
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
25
+ const skills = [];
26
+ for (const entry of entries) {
27
+ if (!entry.isFile() || !entry.name.endsWith('.md'))
28
+ continue;
29
+ const content = await fs.readFile(join(dir, entry.name), 'utf8').catch(() => '');
30
+ if (content.trim()) {
31
+ skills.push({
32
+ name: entry.name.slice(0, -3),
33
+ content: content.slice(0, MAX_SKILL_SIZE).trim(),
34
+ });
35
+ }
36
+ }
37
+ return skills.sort((a, b) => a.name.localeCompare(b.name));
38
+ }
39
+ /** Render skills as a system-prompt block ('' when there are none). */
40
+ export function skillsBlock(skills) {
41
+ if (skills.length === 0)
42
+ return '';
43
+ const parts = ['## User skills (project conventions you MUST follow)'];
44
+ for (const skill of skills) {
45
+ parts.push(`### ${skill.name}\n${skill.content}`);
46
+ }
47
+ return parts.join('\n\n');
48
+ }
49
+ /**
50
+ * Add a skill from a local file path or a URL (GitHub URLs are converted
51
+ * to raw content automatically). Returns the saved skill name.
52
+ */
53
+ export async function addSkill(root, source) {
54
+ let content;
55
+ let name;
56
+ if (/^https?:\/\//i.test(source)) {
57
+ const url = toRawUrl(source);
58
+ const res = await fetch(url);
59
+ if (!res.ok) {
60
+ throw new Error(`Could not fetch ${url} (HTTP ${res.status})`);
61
+ }
62
+ content = await res.text();
63
+ name = skillNameFromUrl(source);
64
+ }
65
+ else {
66
+ content = await fs.readFile(source, 'utf8');
67
+ name = basename(source, extname(source));
68
+ }
69
+ content = content.slice(0, MAX_SKILL_SIZE).trim();
70
+ if (!content)
71
+ throw new Error('Skill source is empty.');
72
+ name = sanitizeName(name);
73
+ const dir = skillsDir(root);
74
+ await fs.mkdir(dir, { recursive: true });
75
+ await fs.writeFile(join(dir, `${name}.md`), content + '\n', 'utf8');
76
+ return name;
77
+ }
78
+ /** Remove a skill by name. Returns true when a file was deleted. */
79
+ export async function removeSkill(root, name) {
80
+ const file = join(skillsDir(root), `${sanitizeName(name)}.md`);
81
+ try {
82
+ await fs.unlink(file);
83
+ return true;
84
+ }
85
+ catch {
86
+ return false;
87
+ }
88
+ }
89
+ /**
90
+ * Convert GitHub web URLs to raw-content URLs:
91
+ * github.com/user/repo → raw .../main/README.md
92
+ * github.com/user/repo/blob/branch/x.md → raw .../branch/x.md
93
+ * Other URLs pass through unchanged.
94
+ */
95
+ export function toRawUrl(url) {
96
+ const blob = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/blob\/(.+)$/i);
97
+ if (blob) {
98
+ return `https://raw.githubusercontent.com/${blob[1]}/${blob[2]}/${blob[3]}`;
99
+ }
100
+ const repo = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/i);
101
+ if (repo) {
102
+ return `https://raw.githubusercontent.com/${repo[1]}/${repo[2]}/HEAD/README.md`;
103
+ }
104
+ return url;
105
+ }
106
+ /** Derive a skill name from a URL (last meaningful path segment). */
107
+ function skillNameFromUrl(url) {
108
+ const segments = new URL(url).pathname.split('/').filter(Boolean);
109
+ let last = segments[segments.length - 1] ?? 'skill';
110
+ last = last.replace(/\.(md|markdown)$/i, '');
111
+ // For repo root URLs use the repo name, not "README"
112
+ if (/^readme$/i.test(last) && segments.length >= 2) {
113
+ last = segments[1].replace(/\.git$/i, '');
114
+ }
115
+ return last;
116
+ }
117
+ /** Keep skill file names safe and predictable. */
118
+ function sanitizeName(name) {
119
+ const safe = name.replace(/[^a-z0-9_-]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase();
120
+ return safe || 'skill';
121
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Terminal UI helpers: Vexi branding, colors and status output.
3
+ * Palette: deep black background + electric blue (#2979FF) accent.
4
+ */
5
+ import chalk from 'chalk';
6
+ /** Vexi electric blue accent. */
7
+ export const accent = chalk.hex('#2979FF');
8
+ export const accentBold = chalk.hex('#2979FF').bold;
9
+ export const dim = chalk.gray;
10
+ export const ok = chalk.green;
11
+ export const warn = chalk.yellow;
12
+ export const err = chalk.red;
13
+ const LOGO = `
14
+ ██╗ ██╗███████╗██╗ ██╗██╗
15
+ ██║ ██║██╔════╝╚██╗██╔╝██║
16
+ ██║ ██║█████╗ ╚███╔╝ ██║
17
+ ╚██╗ ██╔╝██╔══╝ ██╔██╗ ██║
18
+ ╚████╔╝ ███████╗██╔╝ ██╗██║
19
+ ╚═══╝ ╚══════╝╚═╝ ╚═╝╚═╝
20
+ `;
21
+ /** Print the Vexi ASCII banner. */
22
+ export function printBanner(version) {
23
+ console.log(accentBold(LOGO));
24
+ console.log(dim(` open-source AI coding agent · v${version}\n`));
25
+ }
26
+ /** Print the session status line: project, provider, model, language. */
27
+ export function printStatusLine(info) {
28
+ const sep = dim(' · ');
29
+ console.log([
30
+ `${dim('project')} ${accent(info.project)}`,
31
+ `${dim('provider')} ${accent(info.provider)}`,
32
+ `${dim('model')} ${accent(info.model)}`,
33
+ `${dim('lang')} ${accent(info.lang)}`,
34
+ ].join(sep) + '\n');
35
+ }
36
+ /** Prompt label for user input. */
37
+ export const userPrompt = accentBold('you ›');
38
+ /** Label printed before the assistant's streamed reply. */
39
+ export const vexiLabel = accentBold('vexi ›');
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Shared async file helpers.
3
+ *
4
+ * All Vexi state files (.vexi/memory.json, .vexi/project.json,
5
+ * ~/.vexi/config.json) are written atomically: write to a temp file in the
6
+ * same directory, then rename. Rename is atomic on POSIX and effectively
7
+ * atomic on NTFS, which prevents corruption when the agent writes memory
8
+ * and session data concurrently.
9
+ */
10
+ import { promises as fs } from 'node:fs';
11
+ import { dirname, join } from 'node:path';
12
+ import { randomBytes } from 'node:crypto';
13
+ /** Read + parse a JSON file, returning `null` on any error. */
14
+ export async function readJson(path) {
15
+ try {
16
+ return JSON.parse(await fs.readFile(path, 'utf8'));
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ /**
23
+ * Atomically write a JSON file (temp file + rename).
24
+ * Creates parent directories as needed. `mode` applies to the file
25
+ * (e.g. 0o600 for secrets); it is a no-op on Windows.
26
+ */
27
+ export async function writeJsonAtomic(path, data, options = {}) {
28
+ const dir = dirname(path);
29
+ await fs.mkdir(dir, { recursive: true, mode: options.dirMode ?? 0o755 });
30
+ const tmpPath = join(dir, `.${randomBytes(6).toString('hex')}.tmp`);
31
+ await fs.writeFile(tmpPath, JSON.stringify(data, null, 2) + '\n', {
32
+ encoding: 'utf8',
33
+ mode: options.mode ?? 0o644,
34
+ });
35
+ try {
36
+ await fs.rename(tmpPath, path);
37
+ }
38
+ catch (err) {
39
+ await fs.unlink(tmpPath).catch(() => { });
40
+ throw err;
41
+ }
42
+ if (options.mode !== undefined) {
43
+ // Re-assert permissions in case the file already existed (no-op on Windows).
44
+ await fs.chmod(path, options.mode).catch(() => { });
45
+ }
46
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Open a file or URL with the OS default application (cross-platform).
3
+ */
4
+ import { exec } from 'node:child_process';
5
+ import { platform } from 'node:os';
6
+ export function openInDefaultApp(path) {
7
+ const os = platform();
8
+ // Quote the path; on Windows `start` needs an empty title argument.
9
+ const cmd = os === 'win32'
10
+ ? `start "" "${path}"`
11
+ : os === 'darwin'
12
+ ? `open "${path}"`
13
+ : `xdg-open "${path}"`;
14
+ exec(cmd, () => {
15
+ // Best effort — if it fails the user still has the file path printed.
16
+ });
17
+ }
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "vexi-cli",
3
+ "version": "0.5.1",
4
+ "description": "Open-source AI coding agent for your terminal. Bring your own key, zero config, multilingual.",
5
+ "type": "module",
6
+ "bin": {
7
+ "vexi": "dist/index.js"
8
+ },
9
+ "main": "dist/index.js",
10
+ "files": [
11
+ "dist",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18.0.0"
17
+ },
18
+ "scripts": {
19
+ "build": "tsc",
20
+ "dev": "tsc --watch",
21
+ "start": "node dist/index.js",
22
+ "prepublishOnly": "npm run build"
23
+ },
24
+ "keywords": [
25
+ "ai",
26
+ "cli",
27
+ "coding-agent",
28
+ "terminal",
29
+ "assistant",
30
+ "byok",
31
+ "anthropic",
32
+ "openai",
33
+ "gemini",
34
+ "groq",
35
+ "openrouter",
36
+ "multilingual"
37
+ ],
38
+ "author": "Elomami1976",
39
+ "license": "MIT",
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/Elomami1976/vexi.git"
43
+ },
44
+ "bugs": {
45
+ "url": "https://github.com/Elomami1976/vexi/issues"
46
+ },
47
+ "homepage": "https://github.com/Elomami1976/vexi#readme",
48
+ "dependencies": {
49
+ "@inquirer/prompts": "^7.2.0",
50
+ "@modelcontextprotocol/sdk": "^1.29.0",
51
+ "chalk": "^5.4.0",
52
+ "commander": "^13.0.0",
53
+ "ora": "^8.1.1",
54
+ "zod": "^4.4.3"
55
+ },
56
+ "devDependencies": {
57
+ "@types/node": "^22.10.0",
58
+ "typescript": "^5.7.0"
59
+ }
60
+ }