miii-cli 0.2.4 → 0.2.6

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/init.js CHANGED
@@ -1,11 +1,68 @@
1
1
  import { render } from 'ink';
2
2
  import React from 'react';
3
3
  import minimist from 'minimist';
4
+ import { createRequire } from 'module';
5
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
6
+ import { join } from 'path';
7
+ import { homedir } from 'os';
8
+ import { execSync } from 'child_process';
4
9
  import { loadConfig } from './config.js';
5
10
  import { SkillLoader } from './skills/loader.js';
6
11
  import { InputBar } from './tui/InputBar.js';
7
12
  import { welcome } from './tui/printer.js';
8
13
  import { ensureOllama } from './llm/ollama.js';
14
+ const require = createRequire(import.meta.url);
15
+ const UPDATE_CACHE = join(homedir(), '.config', 'miii', 'update-check.json');
16
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h
17
+ function semverGt(a, b) {
18
+ const pa = a.split('.').map(Number);
19
+ const pb = b.split('.').map(Number);
20
+ for (let i = 0; i < 3; i++) {
21
+ const x = pa[i] ?? 0, y = pb[i] ?? 0;
22
+ if (x > y)
23
+ return true;
24
+ if (x < y)
25
+ return false;
26
+ }
27
+ return false;
28
+ }
29
+ function isLinkedInstall() {
30
+ try {
31
+ const bin = execSync('which miii', { encoding: 'utf-8' }).trim();
32
+ const resolved = execSync(`readlink "${bin}"`, { encoding: 'utf-8' }).trim();
33
+ return resolved.includes('node_modules') && !resolved.includes('npm/lib');
34
+ }
35
+ catch {
36
+ return false;
37
+ }
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;
46
+ }
47
+ }
48
+ }
49
+ catch { }
50
+ try {
51
+ const res = await fetch('https://registry.npmjs.org/miii-cli/latest', { signal: AbortSignal.timeout(3000) });
52
+ if (!res.ok)
53
+ return undefined;
54
+ const data = await res.json();
55
+ const latest = data.version;
56
+ if (!latest)
57
+ return undefined;
58
+ // Cache result
59
+ mkdirSync(join(homedir(), '.config', 'miii'), { recursive: true });
60
+ writeFileSync(UPDATE_CACHE, JSON.stringify({ ts: Date.now(), latest }));
61
+ return semverGt(latest, current) ? latest : undefined;
62
+ }
63
+ catch { }
64
+ return undefined;
65
+ }
9
66
  export async function lazyInit() {
10
67
  const argv = minimist(process.argv.slice(2), {
11
68
  string: ['model', 'url', 'provider', 'session'],
@@ -18,16 +75,21 @@ export async function lazyInit() {
18
75
  config.baseUrl = argv.url;
19
76
  if (argv.provider)
20
77
  config.provider = argv.provider;
78
+ const pkg = require('../package.json');
79
+ const currentVersion = pkg.version;
21
80
  if (config.provider === 'ollama') {
22
81
  await ensureOllama(config.baseUrl);
23
82
  }
24
83
  const skills = new SkillLoader();
25
- await skills.loadAll();
84
+ // Run version check + skill load in parallel — don't block startup
85
+ const linked = isLinkedInstall();
86
+ const [, updateAvailable] = await Promise.all([
87
+ skills.loadAll(),
88
+ checkLatestVersion(currentVersion),
89
+ ]);
26
90
  // Print welcome banner to scrollback BEFORE Ink starts
27
- welcome(config.provider, config.model, process.cwd());
28
- // Ink renders ONLY the input bar (small footprint at bottom)
29
- // patchConsole: true (default) ensures console.log output appears above Ink
91
+ welcome(config.provider, config.model, process.cwd(), currentVersion, updateAvailable, linked);
30
92
  const sessionName = argv.session || 'default';
31
- const { waitUntilExit } = render(React.createElement(InputBar, { config, skills, cwd: process.cwd(), session: sessionName }), { exitOnCtrlC: false });
93
+ const { waitUntilExit } = render(React.createElement(InputBar, { config, skills, cwd: process.cwd(), session: sessionName, version: currentVersion }), { exitOnCtrlC: false });
32
94
  await waitUntilExit();
33
95
  }
@@ -2,6 +2,11 @@ import { readFileSync, existsSync, readdirSync } from 'fs';
2
2
  import { join, basename } from 'path';
3
3
  import { homedir } from 'os';
4
4
  import { createDir, moveFile, writeFile, guardPath } from '../files/ops.js';
5
+ import { exec } from 'child_process';
6
+ import { promisify } from 'util';
7
+ const run = promisify(exec);
8
+ export const MIII_HOME = join(homedir(), '.config', 'miii');
9
+ const NPM_SKILLS_DIR = MIII_HOME;
5
10
  const builtin = [
6
11
  {
7
12
  name: 'caveman',
@@ -47,7 +52,7 @@ const builtin = [
47
52
  name: 'models',
48
53
  ns: 'default',
49
54
  description: 'Choose or pull Ollama models',
50
- // execute handled specially in App.tsx before skill lookup
55
+ // execute handled specially in InputBar before skill lookup
51
56
  },
52
57
  {
53
58
  name: 'mkdir',
@@ -96,6 +101,7 @@ export class SkillLoader {
96
101
  }
97
102
  }
98
103
  async loadAll() {
104
+ // 1. Markdown skills from ~/.config/miii/skills/ and .miii/skills/
99
105
  const dirs = [
100
106
  join(homedir(), '.config', 'miii', 'skills'),
101
107
  join(process.cwd(), '.miii', 'skills'),
@@ -118,6 +124,64 @@ export class SkillLoader {
118
124
  this.map.set(`custom:${name}`, skill);
119
125
  }
120
126
  }
127
+ // 2. npm skill packages: miii-skill-* installed in ~/.config/miii/node_modules/
128
+ const nmDir = join(NPM_SKILLS_DIR, 'node_modules');
129
+ if (existsSync(nmDir)) {
130
+ for (const pkg of readdirSync(nmDir)) {
131
+ if (!pkg.startsWith('miii-skill-'))
132
+ continue;
133
+ const pkgDir = join(nmDir, pkg);
134
+ try {
135
+ const pkgJson = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf-8'));
136
+ const main = pkgJson.main ?? 'index.js';
137
+ const entry = join(pkgDir, main);
138
+ if (!existsSync(entry))
139
+ continue;
140
+ const mod = await import(entry);
141
+ const exported = mod.default ?? mod.skill ?? mod.skills;
142
+ const skillList = Array.isArray(exported) ? exported : [exported];
143
+ for (const s of skillList) {
144
+ if (!s?.name || !s?.description)
145
+ continue;
146
+ const ns = s.ns ?? 'npm';
147
+ const skill = { ...s, ns };
148
+ this.map.set(s.name, skill);
149
+ this.map.set(`${ns}:${s.name}`, skill);
150
+ }
151
+ }
152
+ catch { }
153
+ }
154
+ }
155
+ }
156
+ async installSkill(nameOrPkg) {
157
+ const pkg = nameOrPkg.includes('/') || nameOrPkg.startsWith('miii-skill-')
158
+ ? nameOrPkg
159
+ : `miii-skill-${nameOrPkg}`;
160
+ createDir(NPM_SKILLS_DIR);
161
+ const { stdout, stderr } = await run(`npm install --prefix ${JSON.stringify(NPM_SKILLS_DIR)} ${pkg}`);
162
+ const out = (stdout + stderr).trim();
163
+ // Reload newly installed skill
164
+ await this.loadAll();
165
+ return `installed ${pkg}\n${out}`;
166
+ }
167
+ async uninstallSkill(nameOrPkg) {
168
+ const pkg = nameOrPkg.includes('/') || nameOrPkg.startsWith('miii-skill-')
169
+ ? nameOrPkg
170
+ : `miii-skill-${nameOrPkg}`;
171
+ const { stdout, stderr } = await run(`npm uninstall --prefix ${JSON.stringify(NPM_SKILLS_DIR)} ${pkg}`);
172
+ const out = (stdout + stderr).trim();
173
+ // Remove from map
174
+ const shortName = pkg.replace(/^miii-skill-/, '');
175
+ this.map.delete(shortName);
176
+ this.map.delete(`npm:${shortName}`);
177
+ this.map.delete(pkg);
178
+ return `uninstalled ${pkg}\n${out}`;
179
+ }
180
+ listNpmSkills() {
181
+ const nmDir = join(NPM_SKILLS_DIR, 'node_modules');
182
+ if (!existsSync(nmDir))
183
+ return [];
184
+ return readdirSync(nmDir).filter(p => p.startsWith('miii-skill-'));
121
185
  }
122
186
  get(ref) {
123
187
  return this.map.get(ref);
@@ -0,0 +1,64 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+ const KEY_FILE = join(homedir(), '.config', 'miii', 'tavily.key');
5
+ export function getTavilyKey() {
6
+ if (existsSync(KEY_FILE)) {
7
+ const k = readFileSync(KEY_FILE, 'utf-8').trim();
8
+ if (k)
9
+ return k;
10
+ }
11
+ return undefined;
12
+ }
13
+ export function saveTavilyKey(key) {
14
+ mkdirSync(join(homedir(), '.config', 'miii'), { recursive: true });
15
+ writeFileSync(KEY_FILE, key.trim(), { encoding: 'utf-8', mode: 0o600 });
16
+ }
17
+ async function post(path, body) {
18
+ const res = await fetch(`https://api.tavily.com${path}`, {
19
+ method: 'POST',
20
+ headers: { 'Content-Type': 'application/json' },
21
+ body: JSON.stringify(body),
22
+ });
23
+ if (!res.ok) {
24
+ const text = await res.text().catch(() => '');
25
+ throw new Error(`Tavily API ${res.status}: ${text}`);
26
+ }
27
+ return res.json();
28
+ }
29
+ export async function tavilySearch(opts) {
30
+ const data = await post('/search', {
31
+ api_key: opts.apiKey,
32
+ query: opts.query,
33
+ search_depth: opts.searchDepth ?? 'basic',
34
+ max_results: Math.min(opts.maxResults ?? 5, 10),
35
+ include_answer: opts.includeAnswer ?? true,
36
+ include_raw_content: false,
37
+ include_domains: opts.includeDomains ?? [],
38
+ exclude_domains: opts.excludeDomains ?? [],
39
+ });
40
+ const parts = [];
41
+ if (data.answer)
42
+ parts.push(`Answer: ${data.answer}\n`);
43
+ for (const r of data.results) {
44
+ parts.push(`[${r.title}] ${r.url}\n${r.content}`);
45
+ }
46
+ return parts.join('\n\n').trim() || '(no results)';
47
+ }
48
+ export async function tavilyExtract(opts) {
49
+ const data = await post('/extract', {
50
+ api_key: opts.apiKey,
51
+ urls: opts.urls.slice(0, 20),
52
+ });
53
+ const parts = [];
54
+ for (const r of data.results) {
55
+ const truncated = r.raw_content.length > 8000
56
+ ? r.raw_content.slice(0, 8000) + '\n…[truncated at 8k]'
57
+ : r.raw_content;
58
+ parts.push(`[${r.url}]\n${truncated}`);
59
+ }
60
+ if (data.failed_results?.length) {
61
+ parts.push(`Failed URLs: ${data.failed_results.map(r => r.url).join(', ')}`);
62
+ }
63
+ return parts.join('\n\n---\n\n').trim() || '(no content extracted)';
64
+ }
@@ -3,6 +3,7 @@ import { existsSync } from 'fs';
3
3
  import { join } from 'path';
4
4
  import { exec } from 'child_process';
5
5
  import { promisify } from 'util';
6
+ import { getTavilyKey, tavilySearch, tavilyExtract } from '../tavily/client.js';
6
7
  const run = promisify(exec);
7
8
  const EXEC_TIMEOUT_MS = 30_000;
8
9
  export const tools = [
@@ -201,6 +202,36 @@ export const tools = [
201
202
  }
202
203
  },
203
204
  },
205
+ {
206
+ name: 'web_search',
207
+ description: 'Search the web using Tavily. Returns relevant results and a direct answer.',
208
+ params: '{"query": "string", "max_results": "number (optional, 1-10, default 5)", "search_depth": "string (optional: basic|advanced)", "include_domains": "string[] (optional)", "exclude_domains": "string[] (optional)"}',
209
+ execute: async ({ query, max_results, search_depth, include_domains, exclude_domains }) => {
210
+ const key = getTavilyKey();
211
+ if (!key)
212
+ throw new Error('Tavily API key not set — user must run /tavily-key <key> first');
213
+ return tavilySearch({
214
+ apiKey: key,
215
+ query: String(query),
216
+ maxResults: typeof max_results === 'number' ? max_results : undefined,
217
+ searchDepth: search_depth,
218
+ includeDomains: include_domains,
219
+ excludeDomains: exclude_domains,
220
+ });
221
+ },
222
+ },
223
+ {
224
+ name: 'web_extract',
225
+ description: 'Extract and scrape full content from one or more URLs using Tavily.',
226
+ params: '{"urls": "string[]"}',
227
+ execute: async ({ urls }) => {
228
+ const key = getTavilyKey();
229
+ if (!key)
230
+ throw new Error('Tavily API key not set — user must run /tavily-key <key> first');
231
+ const list = Array.isArray(urls) ? urls : [String(urls)];
232
+ return tavilyExtract({ apiKey: key, urls: list });
233
+ },
234
+ },
204
235
  ];
205
236
  export function getSystemPrompt(extra = '') {
206
237
  const toolDocs = tools.map(t => `- ${t.name}(${t.params}): ${t.description}`).join('\n');
@@ -251,5 +282,7 @@ Rules:
251
282
  - No fenced code blocks (no \`\`\`). If you find yourself about to write a code block, use a tool call instead
252
283
  - Use plain indentation and labels for structure. This is a terminal, not a chat UI
253
284
  - After editing files that have tests, call run_tests to verify nothing broke
254
- - If run_tests fails, read the failing test output and fix the code, then run_tests again (max 3 retries)${extra}`;
285
+ - If run_tests fails, read the failing test output and fix the code, then run_tests again (max 3 retries)
286
+ - 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
287
+ - NEVER say you cannot search the web — always call web_search instead${extra}`;
255
288
  }