miii-cli 0.3.4 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/config.js CHANGED
@@ -6,16 +6,18 @@ const defaults = {
6
6
  provider: 'ollama',
7
7
  baseUrl: 'http://localhost:11434',
8
8
  };
9
- const ALLOWED_KEYS = new Set(['model', 'provider', 'baseUrl', 'systemPrompt', 'apiKey', 'gitContext', 'tavilyApiKey']);
9
+ const ALLOWED_KEYS = new Set(['model', 'provider', 'baseUrl', 'systemPrompt', 'apiKey', 'gitContext', 'tavilyApiKey', 'embedModel']);
10
+ const PROJECT_CONFIG = join(process.cwd(), '.miii.json');
11
+ const GLOBAL_CONFIG = join(homedir(), '.config', 'miii', 'config.json');
10
12
  export function loadConfig() {
11
- const candidates = [
12
- join(process.cwd(), '.miii.json'),
13
- join(homedir(), '.config', 'miii', 'config.json'),
14
- ];
13
+ const candidates = [PROJECT_CONFIG, GLOBAL_CONFIG];
15
14
  for (const p of candidates) {
16
15
  if (existsSync(p)) {
17
16
  try {
18
17
  const raw = JSON.parse(readFileSync(p, 'utf-8'));
18
+ if (p === PROJECT_CONFIG && ('apiKey' in raw || 'tavilyApiKey' in raw)) {
19
+ process.stderr.write('Warning: API keys found in .miii.json — add .miii.json to .gitignore to avoid committing secrets\n');
20
+ }
19
21
  const safe = {};
20
22
  for (const key of ALLOWED_KEYS) {
21
23
  if (key in raw)
@@ -0,0 +1,28 @@
1
+ export async function embed(baseUrl, model, text) {
2
+ // Try newer /api/embed endpoint first, fall back to /api/embeddings
3
+ try {
4
+ const res = await fetch(`${baseUrl}/api/embed`, {
5
+ method: 'POST',
6
+ headers: { 'Content-Type': 'application/json' },
7
+ body: JSON.stringify({ model, input: text }),
8
+ });
9
+ if (res.ok) {
10
+ const obj = await res.json();
11
+ const vec = obj.embeddings?.[0] ?? obj.embedding;
12
+ if (vec?.length)
13
+ return vec;
14
+ }
15
+ }
16
+ catch { }
17
+ const res = await fetch(`${baseUrl}/api/embeddings`, {
18
+ method: 'POST',
19
+ headers: { 'Content-Type': 'application/json' },
20
+ body: JSON.stringify({ model, prompt: text }),
21
+ });
22
+ if (!res.ok)
23
+ throw new Error(`embed ${res.status}: ${await res.text()}`);
24
+ const obj = await res.json();
25
+ if (!obj.embedding?.length)
26
+ throw new Error('empty embedding response');
27
+ return obj.embedding;
28
+ }
@@ -0,0 +1,100 @@
1
+ import { readdirSync, readFileSync, statSync } from 'fs';
2
+ import { join, relative, extname } from 'path';
3
+ import { embed } from './embedder.js';
4
+ import { saveIndex } from './store.js';
5
+ const CHUNK_LINES = 40;
6
+ const MAX_FILE_BYTES = 80_000;
7
+ const SKIP_DIRS = new Set([
8
+ 'node_modules', 'dist', 'build', '.git', '.next', '.nuxt', '.svelte-kit',
9
+ 'out', '__pycache__', '.cache', 'coverage', 'vendor', 'target', '.turbo',
10
+ '.vercel', 'generated', '.expo', 'tmp', 'temp', 'logs',
11
+ ]);
12
+ const INDEX_EXTS = new Set([
13
+ '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
14
+ '.py', '.go', '.rs', '.java', '.rb', '.sh',
15
+ '.css', '.scss', '.html', '.vue', '.svelte',
16
+ '.json', '.yaml', '.yml', '.toml', '.md', '.sql', '.graphql',
17
+ ]);
18
+ const SKIP_NAMES = new Set([
19
+ 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'Cargo.lock',
20
+ 'poetry.lock', 'Gemfile.lock', '.DS_Store',
21
+ ]);
22
+ function collectFiles(dir, cwd) {
23
+ const out = [];
24
+ let entries;
25
+ try {
26
+ entries = readdirSync(dir);
27
+ }
28
+ catch {
29
+ return out;
30
+ }
31
+ for (const name of entries) {
32
+ if (SKIP_DIRS.has(name) || name.startsWith('.'))
33
+ continue;
34
+ const abs = join(dir, name);
35
+ let st;
36
+ try {
37
+ st = statSync(abs);
38
+ }
39
+ catch {
40
+ continue;
41
+ }
42
+ if (st.isDirectory()) {
43
+ out.push(...collectFiles(abs, cwd));
44
+ }
45
+ else if (st.isFile()) {
46
+ if (SKIP_NAMES.has(name))
47
+ continue;
48
+ if (!INDEX_EXTS.has(extname(name).toLowerCase()))
49
+ continue;
50
+ if (st.size > MAX_FILE_BYTES)
51
+ continue;
52
+ out.push(abs);
53
+ }
54
+ }
55
+ return out;
56
+ }
57
+ function chunkFile(absPath, cwd) {
58
+ let content;
59
+ try {
60
+ content = readFileSync(absPath, 'utf-8');
61
+ }
62
+ catch {
63
+ return [];
64
+ }
65
+ if (!content.trim())
66
+ return [];
67
+ const rel = relative(cwd, absPath);
68
+ const lines = content.split('\n');
69
+ const chunks = [];
70
+ for (let i = 0; i < lines.length; i += CHUNK_LINES) {
71
+ const end = Math.min(i + CHUNK_LINES, lines.length) - 1;
72
+ const body = lines.slice(i, end + 1).join('\n').trim();
73
+ if (!body)
74
+ continue;
75
+ chunks.push({ start: i, end, text: `// ${rel}\n${body}` });
76
+ }
77
+ return chunks;
78
+ }
79
+ export async function buildIndex(config, cwd, onProgress) {
80
+ const embedModel = config.embedModel ?? 'nomic-embed-text';
81
+ const files = collectFiles(cwd, cwd);
82
+ const chunks = [];
83
+ let skipped = 0;
84
+ for (let fi = 0; fi < files.length; fi++) {
85
+ const abs = files[fi];
86
+ const rel = relative(cwd, abs);
87
+ onProgress?.({ file: rel, done: fi, total: files.length });
88
+ for (const c of chunkFile(abs, cwd)) {
89
+ try {
90
+ const vec = await embed(config.baseUrl, embedModel, c.text);
91
+ chunks.push({ file: rel, start: c.start, end: c.end, text: c.text, vec });
92
+ }
93
+ catch {
94
+ skipped++;
95
+ }
96
+ }
97
+ }
98
+ saveIndex(cwd, chunks);
99
+ return { indexed: chunks.length, skipped, files: files.length };
100
+ }
@@ -0,0 +1,19 @@
1
+ function dot(a, b) {
2
+ let s = 0;
3
+ for (let i = 0; i < a.length; i++)
4
+ s += a[i] * b[i];
5
+ return s;
6
+ }
7
+ function norm(a) {
8
+ return Math.sqrt(dot(a, a));
9
+ }
10
+ export function cosineSim(a, b) {
11
+ const d = norm(a) * norm(b);
12
+ return d === 0 ? 0 : dot(a, b) / d;
13
+ }
14
+ export function topK(chunks, queryVec, k) {
15
+ return chunks
16
+ .map(c => ({ ...c, score: cosineSim(c.vec, queryVec) }))
17
+ .sort((a, b) => b.score - a.score)
18
+ .slice(0, k);
19
+ }
@@ -0,0 +1,47 @@
1
+ import { createHash } from 'crypto';
2
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync, unlinkSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { homedir } from 'os';
5
+ const INDEX_DIR = join(homedir(), '.config', 'miii', 'indexes');
6
+ function cwdKey(cwd) {
7
+ return createHash('sha1').update(cwd).digest('hex').slice(0, 12);
8
+ }
9
+ export function indexPath(cwd) {
10
+ return join(INDEX_DIR, `${cwdKey(cwd)}.jsonl`);
11
+ }
12
+ export function loadIndex(cwd) {
13
+ const p = indexPath(cwd);
14
+ if (!existsSync(p))
15
+ return [];
16
+ try {
17
+ return readFileSync(p, 'utf-8')
18
+ .split('\n')
19
+ .filter(Boolean)
20
+ .map(line => JSON.parse(line));
21
+ }
22
+ catch {
23
+ return [];
24
+ }
25
+ }
26
+ export function saveIndex(cwd, chunks) {
27
+ mkdirSync(INDEX_DIR, { recursive: true });
28
+ writeFileSync(indexPath(cwd), chunks.map(c => JSON.stringify(c)).join('\n'));
29
+ }
30
+ export function indexStats(cwd) {
31
+ const p = indexPath(cwd);
32
+ if (!existsSync(p))
33
+ return null;
34
+ try {
35
+ const st = statSync(p);
36
+ const lines = readFileSync(p, 'utf-8').split('\n').filter(Boolean);
37
+ return { count: lines.length, sizeKb: Math.round(st.size / 1024), mtime: st.mtimeMs };
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ export function clearIndex(cwd) {
44
+ const p = indexPath(cwd);
45
+ if (existsSync(p))
46
+ unlinkSync(p);
47
+ }
@@ -0,0 +1,29 @@
1
+ import { embed } from './embedder.js';
2
+ import { loadIndex } from './store.js';
3
+ import { topK } from './search.js';
4
+ export function createSearchCodebaseTool(config, cwd) {
5
+ return {
6
+ name: 'search_codebase',
7
+ description: 'Semantic search over the indexed codebase. Returns top relevant code snippets. Requires /index build to have been run first.',
8
+ params: '{"query": "string", "k": "number (optional, default 5)"}',
9
+ execute: async ({ query, k = 5 }) => {
10
+ const chunks = loadIndex(cwd);
11
+ if (!chunks.length)
12
+ return '(no index found — run /index build first)';
13
+ const embedModel = config.embedModel ?? 'nomic-embed-text';
14
+ let queryVec;
15
+ try {
16
+ queryVec = await embed(config.baseUrl, embedModel, String(query));
17
+ }
18
+ catch (e) {
19
+ return `embed error: ${e}`;
20
+ }
21
+ const results = topK(chunks, queryVec, Number(k));
22
+ if (!results.length)
23
+ return '(no results)';
24
+ return results
25
+ .map((r, i) => `[${i + 1}] ${r.file} (lines ${r.start + 1}–${r.end + 1}, score ${r.score.toFixed(3)})\n${r.text}`)
26
+ .join('\n\n---\n\n');
27
+ },
28
+ };
29
+ }
package/dist/init.js CHANGED
@@ -13,7 +13,7 @@ import { welcome } from './tui/printer.js';
13
13
  import { ensureOllama } from './llm/ollama.js';
14
14
  const require = createRequire(import.meta.url);
15
15
  const UPDATE_CACHE = join(homedir(), '.config', 'miii', 'update-check.json');
16
- const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h
16
+ const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h
17
17
  function semverGt(a, b) {
18
18
  const pa = a.split('.').map(Number);
19
19
  const pb = b.split('.').map(Number);
@@ -36,17 +36,19 @@ function isLinkedInstall() {
36
36
  return false;
37
37
  }
38
38
  }
39
- async function checkLatestVersion(current) {
40
- // Return cached result if checked within 24h
41
- try {
42
- if (existsSync(UPDATE_CACHE)) {
43
- const cache = JSON.parse(readFileSync(UPDATE_CACHE, 'utf-8'));
44
- if (Date.now() - cache.ts < CHECK_INTERVAL_MS) {
45
- return semverGt(cache.latest, current) ? cache.latest : undefined;
39
+ async function checkLatestVersion(current, force = false) {
40
+ if (!force) {
41
+ try {
42
+ if (existsSync(UPDATE_CACHE)) {
43
+ const cache = JSON.parse(readFileSync(UPDATE_CACHE, 'utf-8'));
44
+ const cacheValid = Date.now() - cache.ts < CHECK_INTERVAL_MS && cache.localVersion === current;
45
+ if (cacheValid) {
46
+ return semverGt(cache.latest, current) ? cache.latest : undefined;
47
+ }
46
48
  }
47
49
  }
50
+ catch { }
48
51
  }
49
- catch { }
50
52
  try {
51
53
  const res = await fetch('https://registry.npmjs.org/miii-cli/latest', { signal: AbortSignal.timeout(3000) });
52
54
  if (!res.ok)
@@ -57,7 +59,7 @@ async function checkLatestVersion(current) {
57
59
  return undefined;
58
60
  // Cache result
59
61
  mkdirSync(join(homedir(), '.config', 'miii'), { recursive: true });
60
- writeFileSync(UPDATE_CACHE, JSON.stringify({ ts: Date.now(), latest }));
62
+ writeFileSync(UPDATE_CACHE, JSON.stringify({ ts: Date.now(), latest, localVersion: current }));
61
63
  return semverGt(latest, current) ? latest : undefined;
62
64
  }
63
65
  catch { }
@@ -66,6 +68,7 @@ async function checkLatestVersion(current) {
66
68
  export async function lazyInit() {
67
69
  const argv = minimist(process.argv.slice(2), {
68
70
  string: ['model', 'url', 'provider', 'session'],
71
+ boolean: ['update'],
69
72
  alias: { m: 'model', u: 'url', p: 'provider', s: 'session' },
70
73
  });
71
74
  const config = loadConfig();
@@ -85,7 +88,7 @@ export async function lazyInit() {
85
88
  const linked = isLinkedInstall();
86
89
  const [, updateAvailable] = await Promise.all([
87
90
  skills.loadAll(),
88
- checkLatestVersion(currentVersion),
91
+ checkLatestVersion(currentVersion, !!argv.update),
89
92
  ]);
90
93
  // Print welcome banner to scrollback BEFORE Ink starts
91
94
  welcome(config.provider, config.model, process.cwd(), currentVersion, updateAvailable, linked);
package/dist/sessions.js CHANGED
@@ -1,9 +1,10 @@
1
- import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, existsSync, unlinkSync } from 'fs';
1
+ import { readFileSync, writeFileSync, mkdirSync, chmodSync, readdirSync, statSync, existsSync, unlinkSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { homedir } from 'os';
4
4
  const SESSIONS_DIR = join(homedir(), '.config', 'miii', 'sessions');
5
5
  function ensureDir() {
6
- mkdirSync(SESSIONS_DIR, { recursive: true });
6
+ mkdirSync(SESSIONS_DIR, { recursive: true, mode: 0o700 });
7
+ chmodSync(SESSIONS_DIR, 0o700);
7
8
  }
8
9
  function sanitizeName(name) {
9
10
  if (!/^[\w-]+$/.test(name))
@@ -45,7 +46,7 @@ export function loadSession(name) {
45
46
  export function saveSession(name, messages) {
46
47
  ensureDir();
47
48
  try {
48
- writeFileSync(join(SESSIONS_DIR, `${sanitizeName(name)}.json`), JSON.stringify(messages));
49
+ writeFileSync(join(SESSIONS_DIR, `${sanitizeName(name)}.json`), JSON.stringify(messages), { mode: 0o600 });
49
50
  }
50
51
  catch { }
51
52
  }
@@ -1,10 +1,11 @@
1
1
  import { readFile, writeFile, deleteFile, listFiles, createDir, moveFile, guardPath } from '../files/ops.js';
2
2
  import { existsSync } from 'fs';
3
3
  import { join } from 'path';
4
- import { exec } from 'child_process';
4
+ import { exec, execFile } from 'child_process';
5
5
  import { promisify } from 'util';
6
6
  import { getTavilyKey, tavilySearch, tavilyExtract } from '../tavily/client.js';
7
7
  const run = promisify(exec);
8
+ const runFile = promisify(execFile);
8
9
  const EXEC_TIMEOUT_MS = 30_000;
9
10
  export const tools = [
10
11
  {
@@ -126,10 +127,13 @@ export const tools = [
126
127
  description: 'Show git diff. staged=true for staged changes, path for specific file',
127
128
  params: '{"staged": "boolean (optional)", "path": "string (optional)"}',
128
129
  execute: async ({ staged = false, path = '' }) => {
129
- const args = staged ? '--staged' : '';
130
- const target = path ? `-- "${path}"` : '';
131
130
  try {
132
- const { stdout } = await run(`git diff ${args} ${target}`.trim(), { timeout: EXEC_TIMEOUT_MS });
131
+ const args = ['diff'];
132
+ if (staged)
133
+ args.push('--staged');
134
+ if (path)
135
+ args.push('--', String(path));
136
+ const { stdout } = await runFile('git', args, { timeout: EXEC_TIMEOUT_MS });
133
137
  const out = stdout.trim();
134
138
  if (!out)
135
139
  return '(no diff)';
@@ -146,7 +150,7 @@ export const tools = [
146
150
  params: '{"n": "number (optional, default 10)"}',
147
151
  execute: async ({ n = 10 }) => {
148
152
  try {
149
- const { stdout } = await run(`git log --oneline -${Math.min(Number(n), 50)}`, { timeout: EXEC_TIMEOUT_MS });
153
+ const { stdout } = await runFile('git', ['log', '--oneline', `-${Math.min(Number(n), 50)}`], { timeout: EXEC_TIMEOUT_MS });
150
154
  return stdout.trim() || '(no commits)';
151
155
  }
152
156
  catch (e) {
@@ -156,14 +160,18 @@ export const tools = [
156
160
  },
157
161
  {
158
162
  name: 'git_commit',
159
- description: 'Stage files and create a git commit. Use files="-A" to stage all.',
163
+ description: 'Stage files and create a git commit. Use files="-A" to stage all, or list specific paths.',
160
164
  params: '{"message": "string", "files": "string (optional, default -A)"}',
161
165
  execute: async ({ message, files = '-A' }) => {
162
166
  if (!message)
163
167
  throw new Error('git_commit: message required');
168
+ const fileStr = String(files);
169
+ if (/\.\./.test(fileStr) || !/^(-A|\.|[\w./\-\s]+)$/.test(fileStr))
170
+ throw new Error('git_commit: invalid files argument — use -A, ., or space-separated paths (no .. allowed)');
164
171
  try {
165
- await run(`git add ${files}`, { timeout: EXEC_TIMEOUT_MS });
166
- const { stdout } = await run(`git commit -m ${JSON.stringify(String(message))}`, { timeout: EXEC_TIMEOUT_MS });
172
+ const fileArgs = fileStr === '-A' ? ['-A'] : fileStr === '.' ? ['.'] : fileStr.split(/\s+/).filter(Boolean);
173
+ await runFile('git', ['add', ...fileArgs], { timeout: EXEC_TIMEOUT_MS });
174
+ const { stdout } = await runFile('git', ['commit', '-m', String(message)], { timeout: EXEC_TIMEOUT_MS });
167
175
  return stdout.trim();
168
176
  }
169
177
  catch (e) {
@@ -190,9 +198,9 @@ export const tools = [
190
198
  if (!testScript || testScript === 'echo "Error: no test specified" && exit 1') {
191
199
  return '(no test script configured in package.json)';
192
200
  }
193
- const cmd = path ? `npm test -- ${path}` : 'npm test';
201
+ const npmArgs = path ? ['test', '--', String(path)] : ['test'];
194
202
  try {
195
- const { stdout, stderr } = await run(cmd, { cwd: process.cwd(), timeout: 60_000 });
203
+ const { stdout, stderr } = await runFile('npm', npmArgs, { cwd: process.cwd(), timeout: 60_000 });
196
204
  const out = (stdout + (stderr ? '\nstderr: ' + stderr : '')).trim();
197
205
  return out.length > 4000 ? '…[truncated]\n' + out.slice(-4000) : out;
198
206
  }
@@ -210,9 +218,12 @@ export const tools = [
210
218
  const key = getTavilyKey();
211
219
  if (!key)
212
220
  throw new Error('Tavily API key not set — user must run /tavily-key <key> first');
221
+ const q = query != null ? String(query).trim() : '';
222
+ if (!q)
223
+ throw new Error('web_search: "query" argument is required and must be a non-empty string');
213
224
  return tavilySearch({
214
225
  apiKey: key,
215
- query: String(query),
226
+ query: q,
216
227
  maxResults: typeof max_results === 'number' ? max_results : undefined,
217
228
  searchDepth: search_depth,
218
229
  includeDomains: include_domains,
@@ -228,6 +239,8 @@ export const tools = [
228
239
  const key = getTavilyKey();
229
240
  if (!key)
230
241
  throw new Error('Tavily API key not set — user must run /tavily-key <key> first');
242
+ if (!urls)
243
+ throw new Error('web_extract: "urls" argument is required');
231
244
  const list = Array.isArray(urls) ? urls : [String(urls)];
232
245
  return tavilyExtract({ apiKey: key, urls: list });
233
246
  },
@@ -235,7 +248,8 @@ export const tools = [
235
248
  ];
236
249
  export function getSystemPrompt(extra = '') {
237
250
  const toolDocs = tools.map(t => `- ${t.name}(${t.params}): ${t.description}`).join('\n');
238
- const deepThinkDoc = `- deep_think({"query": "string", "needs_web": "boolean (optional)"}): Research tool — gathers information from files, git, and optionally the web before answering. Returns a compiled research summary. Guardrails: read-only tools only, max 6 tool calls, max 4 web calls inside. Use when a question requires reading multiple files or searching the web first.`;
251
+ const deepThinkDoc = `- deep_think({"query": "string", "needs_web": "boolean (optional)"}): Research tool — gathers information from files, git, and optionally the web before answering. Returns a compiled research summary. Guardrails: read-only tools only, max 6 tool calls, max 4 web calls inside. Use when a question requires reading multiple files or searching the web first.
252
+ - search_codebase({"query": "string", "k": "number (optional)"}): Semantic vector search over the indexed codebase. Returns top-k relevant code snippets by meaning. Requires the user to have run /index build. Use this when you need to find code by concept rather than exact string — e.g. "authentication logic", "error handling patterns", "database queries".`;
239
253
  return `You are Miii — a fast, local AI coding assistant.
240
254
 
241
255
  Use tools by emitting:
@@ -287,6 +301,7 @@ Rules:
287
301
  - If run_tests fails, read the failing test output and fix the code, then run_tests again (max 3 retries)
288
302
  - You have web_search and web_extract tools — use them whenever the user asks about anything requiring internet access, current information, documentation, library versions, news, or external URLs
289
303
  - NEVER say you cannot search the web — always call web_search instead
304
+ - web_search REQUIRES the "query" key exactly — never omit it, never use "q" or "search_query" or any other key name
290
305
  - Use deep_think when the question requires gathering from multiple files or sources before you can answer well — it runs a safe read-only research phase and returns a summary you can reason over
291
306
  - deep_think cannot edit files or run shell commands — it is purely for information gathering${extra}`;
292
307
  }