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/README.md +193 -152
- package/dist/__tests__/integration.test.js +50 -0
- package/dist/config.js +1 -1
- package/dist/init.js +67 -5
- package/dist/skills/loader.js +65 -1
- package/dist/tavily/client.js +64 -0
- package/dist/tools/index.js +34 -1
- package/dist/tui/InputBar.js +89 -316
- package/dist/tui/components/InputArea.js +3 -0
- package/dist/tui/git-context.js +59 -0
- package/dist/tui/hooks/useModelPicker.js +63 -0
- package/dist/tui/hooks/useRunLoop.js +146 -0
- package/dist/tui/hooks/useSession.js +50 -0
- package/dist/tui/printer.js +43 -5
- package/dist/tui/thinking.js +53 -0
- package/package.json +5 -3
- package/dist/tui/App.js +0 -285
- package/dist/tui/components/MessageList.js +0 -127
- package/dist/workers/context.worker.js +0 -71
- package/dist/workers/spawn.js +0 -17
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
|
-
|
|
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
|
}
|
package/dist/skills/loader.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/dist/tools/index.js
CHANGED
|
@@ -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)
|
|
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
|
}
|