miii-cli 1.2.4 → 1.3.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/init.js CHANGED
@@ -5,7 +5,7 @@ import { createRequire } from 'module';
5
5
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
6
6
  import { join } from 'path';
7
7
  import { homedir } from 'os';
8
- import { execSync } from 'child_process';
8
+ import { execSync, spawnSync } from 'child_process';
9
9
  import { loadConfig } from './config.js';
10
10
  import { SkillLoader } from './skills/loader.js';
11
11
  import { InputBar } from './tui/InputBar.js';
@@ -67,6 +67,45 @@ async function checkLatestVersion(current, force = false) {
67
67
  catch { }
68
68
  return undefined;
69
69
  }
70
+ function promptYN(question) {
71
+ return new Promise(resolve => {
72
+ process.stdout.write(` ${question} (y/N) `);
73
+ const onData = (key) => {
74
+ const k = key.toString();
75
+ process.stdin.setRawMode(false);
76
+ process.stdin.pause();
77
+ process.stdin.removeListener('data', onData);
78
+ process.stdout.write('\n');
79
+ if (k === '') {
80
+ process.exit(130);
81
+ } // ctrl+c in raw mode — exit cleanly
82
+ resolve(k.toLowerCase() === 'y');
83
+ };
84
+ try {
85
+ process.stdin.setRawMode(true);
86
+ process.stdin.resume();
87
+ process.stdin.setEncoding('utf-8');
88
+ process.stdin.on('data', onData);
89
+ }
90
+ catch {
91
+ // stdin not a TTY (piped input) — skip prompt
92
+ process.stdin.removeListener('data', onData);
93
+ process.stdout.write('\n');
94
+ resolve(false);
95
+ }
96
+ });
97
+ }
98
+ async function runAutoUpdate(latestVersion) {
99
+ process.stdout.write(`\n Updating miii-cli to v${latestVersion}…\n\n`);
100
+ const result = spawnSync('npm', ['install', '-g', 'miii-cli'], { stdio: 'inherit', shell: true });
101
+ if (result.status === 0) {
102
+ process.stdout.write(`\n Updated to v${latestVersion}. Restart miii.\n`);
103
+ }
104
+ else {
105
+ process.stdout.write(`\n Update failed (exit ${result.status}). Run manually: npm install -g miii-cli\n`);
106
+ }
107
+ process.exit(result.status ?? 1);
108
+ }
70
109
  export async function lazyInit() {
71
110
  const argv = minimist(process.argv.slice(2), {
72
111
  string: ['model', 'url', 'provider', 'session'],
@@ -105,7 +144,13 @@ export async function lazyInit() {
105
144
  process.stderr.write(`MCP: loaded ${mcpTools.length} tool(s) from ${mcpClients.length} server(s)\n`);
106
145
  }
107
146
  // Print welcome banner to scrollback BEFORE Ink starts
108
- welcome(config.provider, config.model, process.cwd(), currentVersion, updateAvailable, linked);
147
+ welcome(process.cwd(), currentVersion, updateAvailable, linked);
148
+ // If update available and not a linked dev install, offer auto-update
149
+ if (updateAvailable && !linked && process.stdin.isTTY) {
150
+ const doUpdate = await promptYN(`Update available: v${updateAvailable}. Auto-update now?`);
151
+ if (doUpdate)
152
+ await runAutoUpdate(updateAvailable);
153
+ }
109
154
  const sessionName = argv.session || `s-${Date.now()}`;
110
155
  const { waitUntilExit } = render(React.createElement(InputBar, { config, skills, cwd: process.cwd(), session: sessionName, version: currentVersion, mcpTools }), { exitOnCtrlC: false });
111
156
  await waitUntilExit();
@@ -73,6 +73,11 @@ export class MCPClient {
73
73
  resolve: (v) => { clearTimeout(timer); resolve(v); },
74
74
  reject: (e) => { clearTimeout(timer); reject(e); },
75
75
  });
76
+ if (!this.proc?.stdin?.writable) {
77
+ this.pending.delete(id);
78
+ reject(new Error('MCP process stdin not writable'));
79
+ return;
80
+ }
76
81
  this.proc.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
77
82
  timer = setTimeout(() => {
78
83
  if (this.pending.has(id)) {
@@ -167,8 +167,10 @@ export class SkillLoader {
167
167
  const pkg = nameOrPkg.includes('/') || nameOrPkg.startsWith('miii-skill-')
168
168
  ? nameOrPkg
169
169
  : `miii-skill-${nameOrPkg}`;
170
+ if (!/^[a-zA-Z0-9@/._-]+$/.test(pkg))
171
+ throw new Error(`invalid package name: ${pkg}`);
170
172
  createDir(NPM_SKILLS_DIR);
171
- const { stdout, stderr } = await run(`npm install --prefix ${JSON.stringify(NPM_SKILLS_DIR)} ${pkg}`);
173
+ const { stdout, stderr } = await run(`npm install --prefix ${JSON.stringify(NPM_SKILLS_DIR)} ${JSON.stringify(pkg)}`);
172
174
  const out = (stdout + stderr).trim();
173
175
  // Reload newly installed skill
174
176
  await this.loadAll();
@@ -178,7 +180,9 @@ export class SkillLoader {
178
180
  const pkg = nameOrPkg.includes('/') || nameOrPkg.startsWith('miii-skill-')
179
181
  ? nameOrPkg
180
182
  : `miii-skill-${nameOrPkg}`;
181
- const { stdout, stderr } = await run(`npm uninstall --prefix ${JSON.stringify(NPM_SKILLS_DIR)} ${pkg}`);
183
+ if (!/^[a-zA-Z0-9@/._-]+$/.test(pkg))
184
+ throw new Error(`invalid package name: ${pkg}`);
185
+ const { stdout, stderr } = await run(`npm uninstall --prefix ${JSON.stringify(NPM_SKILLS_DIR)} ${JSON.stringify(pkg)}`);
182
186
  const out = (stdout + stderr).trim();
183
187
  // Remove from map
184
188
  const shortName = pkg.replace(/^miii-skill-/, '');
@@ -20,7 +20,10 @@ export const tools = [
20
20
  params: '{"path": "string"}',
21
21
  execute: async ({ path }) => {
22
22
  try {
23
- return readFile(guardPath(requireArg(path, 'path', 'read_file')));
23
+ const safe = guardPath(requireArg(path, 'path', 'read_file'));
24
+ if (!existsSync(safe))
25
+ throw new Error(`file not found: ${path}`);
26
+ return readFile(safe);
24
27
  }
25
28
  catch (e) {
26
29
  throw new Error(`read_file: ${e}`);
@@ -73,15 +76,18 @@ export const tools = [
73
76
  params: '{"path": "string", "old": "string", "new": "string"}',
74
77
  execute: async ({ path, old: oldStr, new: newStr }) => {
75
78
  const safe = guardPath(requireArg(path, 'path', 'update_file'));
76
- const current = readFile(safe);
77
- if (current === null)
79
+ if (!existsSync(safe))
78
80
  throw new Error(`file not found: ${path}`);
81
+ const current = readFile(safe);
79
82
  if (current === '')
80
83
  throw new Error(`file empty: ${path}`);
81
84
  const old = requireArg(oldStr, 'old', 'update_file');
82
85
  if (newStr === undefined || newStr === null)
83
86
  throw new Error('update_file: "new" argument is required');
84
- const count = current.split(old).length - 1;
87
+ const norm = (s) => s.replace(/\r\n/g, '\n');
88
+ const currentNorm = norm(current);
89
+ const oldNorm = norm(old);
90
+ const count = currentNorm.split(oldNorm).length - 1;
85
91
  if (count === 0) {
86
92
  throw new Error(`old text not found in ${path} — file may have changed since last read.\n` +
87
93
  `Call read_file again to get current content, then retry with exact matching text.`);
@@ -89,11 +95,11 @@ export const tools = [
89
95
  if (count > 1) {
90
96
  throw new Error(`ambiguous: ${count} matches found in ${path} — extend <old> block with more surrounding lines to make it unique`);
91
97
  }
92
- const updated = current.replace(old, String(newStr));
98
+ const updated = currentNorm.replace(oldNorm, norm(String(newStr)));
93
99
  writeFile(safe, updated);
94
100
  // Compute affected line range for the snippet
95
- const startLine = current.slice(0, current.indexOf(old)).split('\n').length;
96
- const oldLines = old.split('\n').length;
101
+ const startLine = currentNorm.slice(0, currentNorm.indexOf(oldNorm)).split('\n').length;
102
+ const oldLines = oldNorm.split('\n').length;
97
103
  const newLines = newStr.split('\n').length;
98
104
  const updatedArr = updated.split('\n');
99
105
  const snippetStart = Math.max(0, startLine - 3);
@@ -287,14 +293,16 @@ export function getSystemPrompt(extra = '', extraTools = []) {
287
293
  const toolDocs = allTools.map(t => `- ${t.name}(${t.params}): ${t.description}`).join('\n');
288
294
  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.
289
295
  - 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".`;
290
- return `You are Miii — AI coding assistant.
296
+ return `You are Miii — a precise, disciplined AI coding assistant. You implement exactly what is asked. Nothing more.
297
+
298
+ ## Tool format
291
299
 
292
- Tools via:
293
300
  <tool_call>
294
301
  {"name": "tool_name", "args": {...}}
295
302
  </tool_call>
296
303
 
297
- File content in named blocks (not inside JSON):
304
+ File content goes in named blocks outside the JSON — never inside it:
305
+
298
306
  <tool_call>
299
307
  {"name": "edit_file", "args": {"path": "src/foo.ts"}}
300
308
  <content>
@@ -305,23 +313,68 @@ full file content here
305
313
  <tool_call>
306
314
  {"name": "update_file", "args": {"path": "src/foo.ts"}}
307
315
  <old>
308
- exact text to replace
316
+ exact text to replace (copy verbatim from read_file output)
309
317
  </old>
310
318
  <new>
311
319
  replacement text
312
320
  </new>
313
321
  </tool_call>
314
322
 
315
- Tools:
323
+ ## Tools
316
324
  ${toolDocs}
317
325
  ${deepThinkDoc}
318
326
 
319
- Rules:
320
- - edit_file: new files only (errors if exists). For existing files: read_file then update_file with exact <old> text
321
- - Never guess old text — always re-read immediately before patching. If "old text not found": read_file again and retry
322
- - Plain text responses only. No markdown (#/*/\`), no code blocks write code with tools, not in responses
323
- - git_status/git_diff before refactors. git_status before git_commit
324
- - run_tests after edits. Fix failures, retry up to 3 times
325
- - web_search requires "query" key exactly. Never say you can't search — always call web_search
326
- - deep_think: read-only research only, cannot edit files${extra}`;
327
+ ## Execution protocol
328
+
329
+ For every task, follow this sequence:
330
+ 1. Read relevant files first — never assume file contents. When reading multiple independent files, emit all read_file calls in a single batch — do not wait for one before requesting the next.
331
+ 2. Make the minimal targeted change that satisfies the request
332
+ 3. Run run_tests after any edit. If tests fail, fix and retry up to 3 times before reporting
333
+ 4. For refactors or commits: git_status git_diff first, always
334
+
335
+ Parallel tool calls: when multiple tool calls have no dependency between them, issue them together in one batch. Sequential only when a later call depends on an earlier result.
336
+
337
+ For exploratory questions ("how should we approach X?", "what could we do about Y?"):
338
+ - Respond in 2-3 sentences: recommendation + main tradeoff
339
+ - Do not implement until the user agrees
340
+
341
+ For UI or frontend changes: verify the change works in a browser before reporting done. If browser testing is not possible, say so explicitly rather than claiming success.
342
+
343
+ ## Code discipline
344
+
345
+ - Implement exactly what is asked. A bug fix is not a refactor opportunity. A one-shot task does not need a helper abstraction.
346
+ - Three similar lines of code is better than a premature abstraction.
347
+ - Write no comments by default. Add one only when the WHY is non-obvious: a hidden constraint, a subtle invariant, a specific bug workaround. Never explain what the code does — names do that.
348
+ - Add no error handling for scenarios that cannot occur. Trust framework and internal code guarantees. Validate only at system boundaries: user input, external APIs, file I/O.
349
+ - Add no backwards-compatibility shims, feature flags, or dead code for hypothetical future requirements.
350
+
351
+ ## File editing rules
352
+
353
+ - edit_file: new files only — throws if file exists. For existing files: read_file → update_file.
354
+ - update_file: copy the <old> text verbatim from read_file output. Never guess or paraphrase it.
355
+ - If "old text not found": read_file again immediately and retry with exact current text.
356
+ - Prefer update_file (surgical patch) over edit_file (full rewrite) for existing files.
357
+ - Read a file immediately before patching it — not from earlier in the conversation.
358
+
359
+ ## Safety and reversibility
360
+
361
+ - Before any destructive action (delete_file, overwriting content, git_commit with -A), verify the blast radius.
362
+ - Never introduce security vulnerabilities: no command injection, no path traversal, no hardcoded secrets, no XSS, no SQL injection. If you wrote insecure code, fix it immediately.
363
+ - run_command executes in a shell — validate any user-supplied values before interpolating into commands.
364
+
365
+ ## Git discipline
366
+
367
+ - git_status before every commit. Never commit if working tree is unexpected.
368
+ - Stage specific files. Use -A only when all changes are intentional and reviewed.
369
+ - Never amend a commit unless explicitly asked.
370
+ - Never force-push unless explicitly asked and confirmed.
371
+ - Never skip hooks (--no-verify) unless explicitly asked. If a hook fails, diagnose and fix the root cause.
372
+ - Never use interactive git flags (-i) — they require terminal input that is not available.
373
+
374
+ ## Communication
375
+
376
+ - Plain text only. No markdown (no #, *, \`, ---). No code blocks in responses — write code with tools.
377
+ - No filler: no "sure", "certainly", "happy to", "great question". State results and next steps directly.
378
+ - web_search requires "query" key exactly. Never say you can't search — always call web_search.
379
+ - deep_think: read-only research only. Cannot edit files.${extra}`;
327
380
  }
@@ -101,7 +101,8 @@ export function InputBar({ config: initialConfig, skills, cwd, session, version,
101
101
  const abortRef = useRef(null);
102
102
  const [designTeachState, setDesignTeachState] = useState(null);
103
103
  const [designReadyPrompt, setDesignReadyPrompt] = useState(null);
104
- const { projectDir, setSessionName, sessionNameRef, historyRef, saveTimerRef, systemPromptRef, pushHistory, setHistory, buildContext, renameFromMessage, updateMemory, } = useSession(session, cwd, config, mcpTools);
104
+ const { currentModel, setCurrentModel, currentModelRef, pickerOpen, setPickerOpen, pickerModels, pickerLoading, pickerError, pullState, handleModelSelect, handleModelPull, } = useModelPicker(config);
105
+ const { projectDir, setSessionName, sessionNameRef, historyRef, saveTimerRef, systemPromptRef, pushHistory, setHistory, buildContext, renameFromMessage, updateMemory, } = useSession(session, cwd, config, mcpTools, currentModelRef);
105
106
  const startDesignTeach = useCallback(() => {
106
107
  setDesignTeachState({ answers: [], idx: 0 });
107
108
  }, []);
@@ -119,7 +120,6 @@ export function InputBar({ config: initialConfig, skills, cwd, session, version,
119
120
  return { answers, idx: nextIdx };
120
121
  });
121
122
  }, []);
122
- const { currentModel, setCurrentModel, currentModelRef, pickerOpen, setPickerOpen, pickerModels, pickerLoading, pickerError, pullState, handleModelSelect, handleModelPull, } = useModelPicker(config);
123
123
  const deepThinkTool = useMemo(() => ({
124
124
  name: 'deep_think',
125
125
  description: 'Research tool: gather info from files and web before answering.',
@@ -428,10 +428,14 @@ export function InputArea({ status, skills, cwd, planningMode, permissionRequest
428
428
  appendChar(input);
429
429
  if (prospective.startsWith('/')) {
430
430
  if (prospective.slice(1).includes(' ')) {
431
- if (input === '@' || overlay === 'at') {
431
+ if (input === '@') {
432
+ filesLoadedRef.current = false;
432
433
  setOverlay('at');
433
434
  setOverlayIdx(0);
434
435
  }
436
+ else if (overlay === 'at') {
437
+ setOverlay('at');
438
+ }
435
439
  else {
436
440
  setOverlay('none');
437
441
  }
@@ -441,10 +445,14 @@ export function InputArea({ status, skills, cwd, planningMode, permissionRequest
441
445
  setOverlayIdx(0);
442
446
  }
443
447
  }
444
- else if (input === '@' || (overlay === 'at' && atQuery !== '')) {
448
+ else if (input === '@') {
449
+ filesLoadedRef.current = false;
445
450
  setOverlay('at');
446
451
  setOverlayIdx(0);
447
452
  }
453
+ else if (overlay === 'at' && atQuery !== '') {
454
+ setOverlay('at');
455
+ }
448
456
  else if (overlay === 'command') {
449
457
  setOverlay('none');
450
458
  }
@@ -42,7 +42,6 @@ Guardrails:
42
42
  apiKey: config.apiKey,
43
43
  messages: msgs,
44
44
  signal,
45
- onChunk() { },
46
45
  async onDone(text) { fullText = text; },
47
46
  onError(err) { if (err.name !== 'AbortError')
48
47
  chatError = err; },
@@ -20,14 +20,15 @@ export function useRefactor(deps) {
20
20
  content: `Refactor goal: ${goal}\n\nList every file that needs to change. For each file output:\nFILE: <path>\nCHANGE: <one sentence describing the edit>\n\nUse list_files and read_file to discover relevant files first. Only list files that genuinely need changes.`,
21
21
  },
22
22
  ];
23
- abortRef.current = new AbortController();
23
+ const controller = new AbortController();
24
+ abortRef.current = controller;
24
25
  let planText = '';
25
26
  await chat({
26
27
  provider: config.provider,
27
28
  model: currentModelRef.current,
28
29
  baseUrl: config.baseUrl,
29
30
  messages: planCtx,
30
- signal: abortRef.current.signal,
31
+ signal: controller.signal,
31
32
  async onDone(text) { planText = text; },
32
33
  onError(err) { printer.errorMsg(err.message); },
33
34
  });
@@ -95,7 +96,7 @@ export function useRefactor(deps) {
95
96
  model: currentModelRef.current,
96
97
  baseUrl: config.baseUrl,
97
98
  messages: editCtx,
98
- signal: abortRef.current?.signal,
99
+ signal: controller.signal,
99
100
  async onDone(text) { editText = text; },
100
101
  onError(err) { printer.errorMsg(`edit LLM error: ${err.message}`); },
101
102
  });
@@ -134,7 +134,9 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
134
134
  sessionApprovedRef.current.add(sessionKey);
135
135
  if (decision === 'no') {
136
136
  printer.systemMsg(`denied: ${tc.name}`);
137
- next.push({ role: 'user', content: `Tool ${tc.name} was denied by the user` });
137
+ const remaining = pendingTools.slice(pendingTools.indexOf(tc) + 1).map(t => t.name);
138
+ const skippedNote = remaining.length ? ` The following tools were also skipped: ${remaining.join(', ')}.` : '';
139
+ next.push({ role: 'user', content: `Tool ${tc.name} was denied by the user.${skippedNote} Do not retry these tools unless the user explicitly asks.` });
138
140
  break;
139
141
  }
140
142
  // Checkpoint: store pre-execution file state
@@ -158,8 +160,9 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
158
160
  const filePath = tc.args.path;
159
161
  const oldText = tc.args.old;
160
162
  if (filePath && oldText && existsSync(filePath)) {
163
+ const norm = (s) => s.replace(/\r\n/g, '\n');
161
164
  const current = readFileSync(filePath, 'utf-8');
162
- const occurrences = current.split(oldText).length - 1;
165
+ const occurrences = norm(current).split(norm(oldText)).length - 1;
163
166
  if (occurrences === 0) {
164
167
  printer.errorMsg(`patch stale: old text not found in ${filePath} — injecting fresh content`);
165
168
  next.push({ role: 'user', content: `Tool read_file result:\n${current}` });
@@ -208,7 +211,8 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
208
211
  const didEditFiles = pendingTools.some(tc => FILE_EDIT_TOOLS.has(tc.name));
209
212
  if (didEditFiles) {
210
213
  const systemMsg = msgs.find(m => m.role === 'system');
211
- const goalMsg = msgs.find(m => m.role === 'user' && !m.content.startsWith('[') && !m.content.startsWith('Tool '));
214
+ const goalMsg = msgs.find(m => m.role === 'user' && !m.content.startsWith('[') && !m.content.startsWith('Tool '))
215
+ ?? (goal ? { role: 'user', content: goal } : undefined);
212
216
  const batchStart = msgs.length; // include assistant message so model sees its own tool call on retry
213
217
  const batchMsgs = next.slice(batchStart);
214
218
  const slimCtx = [
@@ -9,7 +9,7 @@ const SHORT_MEMORY_SIZE = 50;
9
9
  function buildSystemPrompt(cwd, facts, extraTools = []) {
10
10
  return getSystemPrompt(`\n- CWD: ${cwd}`, extraTools) + formatMemoryBlock(facts);
11
11
  }
12
- export function useSession(initialSession, cwd, config, extraTools = []) {
12
+ export function useSession(initialSession, cwd, config, extraTools = [], currentModelRef) {
13
13
  const projectDir = getProjectDir(cwd);
14
14
  const [sessionName, setSessionName] = useState(initialSession);
15
15
  const sessionNameRef = useRef(initialSession);
@@ -49,7 +49,7 @@ export function useSession(initialSession, cwd, config, extraTools = []) {
49
49
  if (historyRef.current.length > SHORT_MEMORY_SIZE && !extractingRef.current) {
50
50
  const dropped = historyRef.current.splice(0, historyRef.current.length - SHORT_MEMORY_SIZE);
51
51
  extractingRef.current = true;
52
- extractFacts(dropped, config, config.model).then(newFacts => {
52
+ extractFacts(dropped, config, currentModelRef?.current ?? config.model).then(newFacts => {
53
53
  if (newFacts.length) {
54
54
  const updated = mergeFacts(longMemoryRef.current, newFacts);
55
55
  longMemoryRef.current = updated;
@@ -81,7 +81,7 @@ export function toolArgSummary(args) {
81
81
  const first = Object.values(args)[0];
82
82
  return first ? truncate(String(first), 60) : '';
83
83
  }
84
- export function welcome(provider, model, cwd, version, updateAvailable, linked) {
84
+ export function welcome(cwd, version, updateAvailable, linked) {
85
85
  const cols = Math.min(process.stdout.columns ?? 80, 100);
86
86
  const innerW = cols - 2;
87
87
  const leftW = Math.floor(innerW * 0.44);
@@ -114,7 +114,6 @@ export function welcome(provider, model, cwd, version, updateAvailable, linked)
114
114
  '',
115
115
  ...miniArt,
116
116
  '',
117
- ` ${gray(model + ' · ' + provider)}`,
118
117
  ` ${gray(shortCwd)}`,
119
118
  '',
120
119
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "miii-cli",
3
- "version": "1.2.4",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "description": "The high-performance local AI coding agent for your terminal. Automate complex workflows with local LLMs.",
6
6
  "license": "MIT",