miii-cli 0.2.3 → 0.2.5
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 +37 -2
- package/dist/tui/InputBar.js +89 -299
- 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 +137 -0
- package/dist/tui/hooks/useSession.js +50 -0
- package/dist/tui/printer.js +10 -3
- package/dist/tui/thinking.js +28 -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');
|
|
@@ -246,8 +277,12 @@ Rules:
|
|
|
246
277
|
- Be concise
|
|
247
278
|
- Output plain text only — never use markdown formatting in your responses
|
|
248
279
|
- No headers (no #, ##), no bold (**text**), no italic (*text*), no bullet points with *, no horizontal rules (---)
|
|
249
|
-
-
|
|
280
|
+
- NEVER show file content or code in your text response — always use edit_file, patch_file, or create_file tools to write code to files
|
|
281
|
+
- If you want to show the user code, write it to the file with a tool call instead
|
|
282
|
+
- No fenced code blocks (no \`\`\`). If you find yourself about to write a code block, use a tool call instead
|
|
250
283
|
- Use plain indentation and labels for structure. This is a terminal, not a chat UI
|
|
251
284
|
- After editing files that have tests, call run_tests to verify nothing broke
|
|
252
|
-
- 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}`;
|
|
253
288
|
}
|