pelulu-cli 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.
Files changed (56) hide show
  1. package/README.md +273 -0
  2. package/config.example.json +24 -0
  3. package/package.json +44 -0
  4. package/src/core/auto-format.js +47 -0
  5. package/src/core/completion.js +65 -0
  6. package/src/core/config.js +68 -0
  7. package/src/core/confirm.js +51 -0
  8. package/src/core/context.js +69 -0
  9. package/src/core/conversation.js +57 -0
  10. package/src/core/diff.js +50 -0
  11. package/src/core/doctor.js +60 -0
  12. package/src/core/error-handler.js +39 -0
  13. package/src/core/event-bus.js +43 -0
  14. package/src/core/file-tracker.js +44 -0
  15. package/src/core/formatter.js +83 -0
  16. package/src/core/intent.js +81 -0
  17. package/src/core/keybindings.js +29 -0
  18. package/src/core/logger.js +67 -0
  19. package/src/core/model-info.js +26 -0
  20. package/src/core/retry.js +26 -0
  21. package/src/core/sandbox.js +54 -0
  22. package/src/core/session.js +46 -0
  23. package/src/core/spinner.js +60 -0
  24. package/src/core/stats.js +73 -0
  25. package/src/core/system-prompt.js +42 -0
  26. package/src/core/thinking.js +39 -0
  27. package/src/core/tool-help.js +117 -0
  28. package/src/core/tool-registry.js +82 -0
  29. package/src/core/wizard.js +55 -0
  30. package/src/core/workspace.js +96 -0
  31. package/src/index.js +150 -0
  32. package/src/mcp/activation.js +54 -0
  33. package/src/mcp/mcp-handler.js +92 -0
  34. package/src/mcp/message-sender.js +147 -0
  35. package/src/mcp/mqtt-client.js +160 -0
  36. package/src/mcp/wss-endpoint.js +133 -0
  37. package/src/plugins/manager.js +72 -0
  38. package/src/repl-commands.js +91 -0
  39. package/src/repl.js +115 -0
  40. package/src/tools/ai.js +128 -0
  41. package/src/tools/config.js +87 -0
  42. package/src/tools/diff.js +118 -0
  43. package/src/tools/env.js +58 -0
  44. package/src/tools/file.js +177 -0
  45. package/src/tools/git.js +166 -0
  46. package/src/tools/history.js +64 -0
  47. package/src/tools/network.js +87 -0
  48. package/src/tools/process.js +69 -0
  49. package/src/tools/project.js +152 -0
  50. package/src/tools/search.js +100 -0
  51. package/src/tools/shell.js +91 -0
  52. package/src/tools/snippet.js +95 -0
  53. package/src/tools/template.js +113 -0
  54. package/src/tools/watch.js +87 -0
  55. package/src/tui/renderer.js +119 -0
  56. package/src/tui/status-bar.js +34 -0
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Conversation — save/load conversation history
3
+ */
4
+ import { readFile, writeFile, mkdir, readdir } from 'fs/promises';
5
+ import { existsSync } from 'fs';
6
+ import { join } from 'path';
7
+ import { getConfig } from './config.js';
8
+
9
+ const CONV_DIR = 'conversations';
10
+
11
+ function getConvDir() {
12
+ const cfg = getConfig();
13
+ return join(cfg._root, CONV_DIR);
14
+ }
15
+
16
+ export async function saveConversation(messages, name) {
17
+ const dir = getConvDir();
18
+ await mkdir(dir, { recursive: true });
19
+ const filename = name || `conv-${Date.now()}.json`;
20
+ const path = join(dir, filename);
21
+ await writeFile(path, JSON.stringify({ messages, savedAt: Date.now() }, null, 2));
22
+ return path;
23
+ }
24
+
25
+ export async function loadConversation(name) {
26
+ const path = join(getConvDir(), name);
27
+ if (!existsSync(path)) throw new Error(`Conversation not found: ${name}`);
28
+ const data = JSON.parse(await readFile(path, 'utf-8'));
29
+ return data.messages;
30
+ }
31
+
32
+ export async function listConversations() {
33
+ const dir = getConvDir();
34
+ if (!existsSync(dir)) return [];
35
+ const files = await readdir(dir);
36
+ const convs = [];
37
+ for (const f of files.filter(f => f.endsWith('.json'))) {
38
+ try {
39
+ const data = JSON.parse(await readFile(join(dir, f), 'utf-8'));
40
+ convs.push({
41
+ name: f,
42
+ messages: data.messages?.length || 0,
43
+ savedAt: data.savedAt,
44
+ });
45
+ } catch {}
46
+ }
47
+ return convs.sort((a, b) => (b.savedAt || 0) - (a.savedAt || 0));
48
+ }
49
+
50
+ export function formatConversationSummary(messages) {
51
+ const turns = messages.filter(m => m.role === 'user').length;
52
+ const toolCalls = messages.filter(m => m.role === 'tool').length;
53
+ const duration = messages.length > 1
54
+ ? Math.round((messages[messages.length - 1].ts - messages[0].ts) / 1000)
55
+ : 0;
56
+ return `${turns} turns, ${toolCalls} tool calls, ${duration}s`;
57
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Diff — visual code change display
3
+ * Shows before/after with colors, like Claude Code's diff view
4
+ */
5
+ import { COLORS } from './logger.js';
6
+
7
+ export function showDiff(oldText, newText, filePath) {
8
+ const oldLines = oldText.split('\n');
9
+ const newLines = newText.split('\n');
10
+ const maxLen = Math.max(oldLines.length, newLines.length);
11
+
12
+ console.log(`\n${COLORS.bold}📝 Changes: ${filePath || ''}${COLORS.reset}\n`);
13
+
14
+ let changes = 0;
15
+ for (let i = 0; i < maxLen; i++) {
16
+ const old = oldLines[i];
17
+ const new_ = newLines[i];
18
+
19
+ if (old === new_) {
20
+ console.log(`${COLORS.gray} ${String(i + 1).padStart(4)} │ ${old || ''}${COLORS.reset}`);
21
+ } else {
22
+ changes++;
23
+ if (old !== undefined) console.log(`${COLORS.red}- ${String(i + 1).padStart(4)} │ ${old}${COLORS.reset}`);
24
+ if (new_ !== undefined) console.log(`${COLORS.green}+ ${String(i + 1).padStart(4)} │ ${new_}${COLORS.reset}`);
25
+ }
26
+ }
27
+
28
+ console.log(`\n${COLORS.dim} ${changes} line(s) changed${COLORS.reset}\n`);
29
+ return changes;
30
+ }
31
+
32
+ export function showPatch(filePath, hunks) {
33
+ console.log(`\n${COLORS.bold}📝 Patch: ${filePath}${COLORS.reset}\n`);
34
+ for (const hunk of hunks) {
35
+ console.log(`${COLORS.cyan}@@ ${hunk.header || ''} @@${COLORS.reset}`);
36
+ for (const line of hunk.lines) {
37
+ if (line.startsWith('+')) console.log(`${COLORS.green}${line}${COLORS.reset}`);
38
+ else if (line.startsWith('-')) console.log(`${COLORS.red}${line}${COLORS.reset}`);
39
+ else console.log(`${COLORS.gray}${line}${COLORS.reset}`);
40
+ }
41
+ }
42
+ console.log();
43
+ }
44
+
45
+ export function formatFileChange(action, path, details) {
46
+ const icons = { created: '✨', modified: '📝', deleted: '🗑️', renamed: '📎' };
47
+ const icon = icons[action] || '📄';
48
+ const detailStr = details ? ` ${COLORS.dim}(${details})${COLORS.reset}` : '';
49
+ return `${icon} ${action}: ${path}${detailStr}`;
50
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Doctor — diagnostic checks for the coding agent
3
+ * Like Claude Code's diagnostic/health check
4
+ */
5
+ import { exec } from 'child_process';
6
+ import { existsSync } from 'fs';
7
+ import { join } from 'path';
8
+ import { getConfig } from './config.js';
9
+ import { COLORS } from './logger.js';
10
+
11
+ function run(cmd) {
12
+ return new Promise((resolve) => {
13
+ exec(cmd, { timeout: 5000 }, (err, stdout) => resolve(!err && stdout?.trim()));
14
+ });
15
+ }
16
+
17
+ export async function runDoctor() {
18
+ const cfg = getConfig();
19
+ const cwd = cfg.agent?.workspace || process.cwd();
20
+ const checks = [];
21
+
22
+ // Node.js
23
+ const nodeVer = await run('node --version');
24
+ checks.push({ name: 'Node.js', ok: !!nodeVer, value: nodeVer || 'not found' });
25
+
26
+ // npm
27
+ const npmVer = await run('npm --version');
28
+ checks.push({ name: 'npm', ok: !!npmVer, value: npmVer || 'not found' });
29
+
30
+ // git
31
+ const gitVer = await run('git --version');
32
+ checks.push({ name: 'git', ok: !!gitVer, value: gitVer || 'not found' });
33
+
34
+ // Workspace
35
+ checks.push({ name: 'Workspace', ok: existsSync(cwd), value: cwd });
36
+
37
+ // Config
38
+ checks.push({ name: 'Config', ok: !!cfg, value: cfg._path });
39
+
40
+ // Device ID
41
+ checks.push({ name: 'Device ID', ok: !!cfg.mqtt?.device_id, value: cfg.mqtt?.device_id || 'not set' });
42
+
43
+ // MQTT
44
+ checks.push({ name: 'MQTT endpoint', ok: !!cfg.mqtt?.ota_url, value: cfg.mqtt?.ota_url || 'not set' });
45
+
46
+ // Git repo
47
+ const hasGit = existsSync(join(cwd, '.git'));
48
+ checks.push({ name: 'Git repo', ok: hasGit, value: hasGit ? 'yes' : 'no' });
49
+
50
+ // Print report
51
+ console.log(`\n${COLORS.bold}🩺 Doctor Report:${COLORS.reset}\n`);
52
+ let allOk = true;
53
+ for (const c of checks) {
54
+ const icon = c.ok ? `${COLORS.green}✅${COLORS.reset}` : `${COLORS.red}❌${COLORS.reset}`;
55
+ console.log(` ${icon} ${c.name.padEnd(18)} ${c.value}`);
56
+ if (!c.ok) allOk = false;
57
+ }
58
+ console.log(`\n${allOk ? `${COLORS.green}All checks passed!${COLORS.reset}` : `${COLORS.yellow}Some checks failed.${COLORS.reset}`}\n`);
59
+ return allOk;
60
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * ErrorHandler — smart error handling with suggestions
3
+ * Like Claude Code: shows what went wrong + how to fix it
4
+ */
5
+ import { COLORS } from './logger.js';
6
+
7
+ const ERROR_SUGGESTIONS = {
8
+ 'ENOENT': (e) => `File not found. Check the path: ${e.path || ''}`,
9
+ 'EACCES': () => 'Permission denied. Check file permissions.',
10
+ 'EISDIR': () => 'Is a directory, not a file. Use "list" action instead.',
11
+ 'ENOTDIR': () => 'Not a directory. Check the path.',
12
+ 'EEXIST': () => 'Already exists. Use "edit" to modify or delete first.',
13
+ 'ETIMEDOUT': () => 'Timed out. Try increasing timeout or check network.',
14
+ 'ECONNREFUSED': () => 'Connection refused. Is the server running?',
15
+ 'ENOTFOUND': () => 'Host not found. Check the URL or DNS.',
16
+ 'MODULE_NOT_FOUND': (e) => `Module not found. Run: npm install`,
17
+ 'SyntaxError': () => 'Syntax error in code. Check brackets, quotes, semicolons.',
18
+ };
19
+
20
+ export function handleError(error) {
21
+ const code = error.code || error.constructor?.name || 'UNKNOWN';
22
+ const suggestion = ERROR_SUGGESTIONS[code]?.(error) || null;
23
+
24
+ const lines = [`${COLORS.red}❌ ${error.message}${COLORS.reset}`];
25
+ if (suggestion) lines.push(`${COLORS.yellow}💡 ${suggestion}${COLORS.reset}`);
26
+
27
+ return lines.join('\n');
28
+ }
29
+
30
+ export function wrapToolError(toolName, action, fn) {
31
+ return async (...args) => {
32
+ try {
33
+ return await fn(...args);
34
+ } catch (e) {
35
+ const enhanced = handleError(e);
36
+ throw new Error(enhanced);
37
+ }
38
+ };
39
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * EventBus — lightweight pub/sub for decoupled communication
3
+ */
4
+ import { EventEmitter } from 'events';
5
+
6
+ class Bus extends EventEmitter {
7
+ constructor() {
8
+ super();
9
+ this.setMaxListeners(50);
10
+ }
11
+
12
+ emit(event, ...args) {
13
+ return super.emit(event, ...args);
14
+ }
15
+
16
+ on(event, fn) {
17
+ return super.on(event, fn);
18
+ }
19
+
20
+ once(event, fn) {
21
+ return super.once(event, fn);
22
+ }
23
+
24
+ off(event, fn) {
25
+ return super.off(event, fn);
26
+ }
27
+
28
+ waitFor(event, timeoutMs = 30000) {
29
+ return new Promise((resolve, reject) => {
30
+ const timer = setTimeout(() => {
31
+ this.off(event, handler);
32
+ reject(new Error(`Timeout waiting for "${event}"`));
33
+ }, timeoutMs);
34
+ const handler = (...args) => {
35
+ clearTimeout(timer);
36
+ resolve(args);
37
+ };
38
+ this.once(event, handler);
39
+ });
40
+ }
41
+ }
42
+
43
+ export const bus = new Bus();
@@ -0,0 +1,44 @@
1
+ /**
2
+ * FileTracker — track file changes during session
3
+ * Shows summary of what was modified, like Claude Code's change tracking
4
+ */
5
+ import { COLORS } from './logger.js';
6
+
7
+ export class FileTracker {
8
+ constructor() {
9
+ this.changes = new Map(); // path → { action, count, firstTs, lastTs }
10
+ }
11
+
12
+ track(path, action) {
13
+ const existing = this.changes.get(path);
14
+ if (existing) {
15
+ existing.count++;
16
+ existing.lastTs = Date.now();
17
+ existing.action = action; // latest action
18
+ } else {
19
+ this.changes.set(path, { action, count: 1, firstTs: Date.now(), lastTs: Date.now() });
20
+ }
21
+ }
22
+
23
+ getChanges() {
24
+ return [...this.changes.entries()].map(([path, info]) => ({ path, ...info }));
25
+ }
26
+
27
+ getSummary() {
28
+ const changes = this.getChanges();
29
+ if (!changes.length) return 'No file changes this session';
30
+
31
+ const created = changes.filter(c => c.action === 'created');
32
+ const modified = changes.filter(c => c.action === 'modified');
33
+ const deleted = changes.filter(c => c.action === 'deleted');
34
+
35
+ const lines = [`${COLORS.bold}📁 File Changes:${COLORS.reset}`];
36
+ if (created.length) lines.push(` ${COLORS.green}✨ Created:${COLORS.reset} ${created.map(c => c.path).join(', ')}`);
37
+ if (modified.length) lines.push(` ${COLORS.yellow}📝 Modified:${COLORS.reset} ${modified.map(c => `${c.path} (${c.count}x)`).join(', ')}`);
38
+ if (deleted.length) lines.push(` ${COLORS.red}🗑️ Deleted:${COLORS.reset} ${deleted.map(c => c.path).join(', ')}`);
39
+
40
+ return lines.join('\n');
41
+ }
42
+
43
+ reset() { this.changes.clear(); }
44
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Formatter — rich output formatting for tool results
3
+ * Like Claude Code's structured output display
4
+ */
5
+ import { COLORS } from './logger.js';
6
+
7
+ export function formatToolResult(toolName, action, result) {
8
+ if (result.isError) return formatError(result.content?.[0]?.text);
9
+ const data = tryParse(result.content?.[0]?.text);
10
+ if (!data) return result.content?.[0]?.text || 'OK';
11
+
12
+ switch (toolName) {
13
+ case 'file': return formatFileResult(action, data);
14
+ case 'git': return formatGitResult(action, data);
15
+ case 'shell': return formatShellResult(data);
16
+ case 'search': return formatSearchResult(action, data);
17
+ case 'project': return formatProjectResult(action, data);
18
+ case 'process': return formatProcessResult(data);
19
+ default: return JSON.stringify(data, null, 2);
20
+ }
21
+ }
22
+
23
+ function tryParse(text) {
24
+ try { return JSON.parse(text); } catch { return null; }
25
+ }
26
+
27
+ function formatError(text) {
28
+ return `${COLORS.red}❌ ${text}${COLORS.reset}`;
29
+ }
30
+
31
+ function formatFileResult(action, data) {
32
+ if (action === 'read') {
33
+ const lines = data.content?.split('\n').length || 0;
34
+ return `${COLORS.green}✅${COLORS.reset} ${data.path} (${lines} lines, ${data.totalSize} chars)`;
35
+ }
36
+ if (action === 'write') return `${COLORS.green}✅${COLORS.reset} Written: ${data.path} (${data.written} chars)`;
37
+ if (action === 'edit') return `${COLORS.green}✅${COLORS.reset} Edited: ${data.path} (${data.occurrences} occurrence(s))`;
38
+ if (action === 'list') {
39
+ const dirs = data.items?.filter(i => i.type === 'dir').length || 0;
40
+ const files = data.items?.filter(i => i.type === 'file').length || 0;
41
+ return `${COLORS.green}✅${COLORS.reset} ${data.path}: ${dirs} dirs, ${files} files`;
42
+ }
43
+ if (action === 'exists') return data.exists ? `✅ ${data.type}: ${data.path}` : `❌ Not found: ${data.path}`;
44
+ if (action === 'delete') return `${COLORS.yellow}🗑️${COLORS.reset} Deleted: ${data.path}`;
45
+ return `${COLORS.green}✅${COLORS.reset} ${action}: ${JSON.stringify(data)}`;
46
+ }
47
+
48
+ function formatGitResult(action, data) {
49
+ if (action === 'status') {
50
+ const dirty = data.dirty ? `${COLORS.yellow}dirty${COLORS.reset}` : `${COLORS.green}clean${COLORS.reset}`;
51
+ return `🌿 ${data.branch} (${dirty}) ${data.changes?.length || 0} changes`;
52
+ }
53
+ if (action === 'log') return `📜 ${data.commits?.length || 0} commits:\n ${data.commits?.join('\n ')}`;
54
+ if (action === 'commit') return data.committed ? `${COLORS.green}✅${COLORS.reset} Committed: ${data.message}` : `⚠️ ${data.reason}`;
55
+ if (action === 'branch') return data.branches ? `🌿 Branches:\n ${data.branches.join('\n ')}` : `✅ ${data.created || data.deleted}`;
56
+ return `${COLORS.green}✅${COLORS.reset} git ${action}: ${JSON.stringify(data)}`;
57
+ }
58
+
59
+ function formatShellResult(data) {
60
+ const exit = data.exitCode === 0 ? `${COLORS.green}0${COLORS.reset}` : `${COLORS.red}${data.exitCode}${COLORS.reset}`;
61
+ let out = `Exit: ${exit}`;
62
+ if (data.stdout) out += `\n${COLORS.dim}${data.stdout.slice(0, 500)}${COLORS.reset}`;
63
+ if (data.stderr) out += `\n${COLORS.red}${data.stderr.slice(0, 200)}${COLORS.reset}`;
64
+ return out;
65
+ }
66
+
67
+ function formatSearchResult(action, data) {
68
+ if (action === 'grep') return `🔍 ${data.matches} matches for "${data.pattern}":\n ${(data.results || []).join('\n ')}`;
69
+ if (action === 'find') return `🔍 ${data.matches} files matching "${data.pattern}":\n ${(data.files || []).join('\n ')}`;
70
+ if (action === 'web') return `🌐 ${data.url} (${data.status}):\n${data.body?.slice(0, 500)}`;
71
+ return JSON.stringify(data, null, 2);
72
+ }
73
+
74
+ function formatProjectResult(action, data) {
75
+ if (action === 'build') return data.success ? `${COLORS.green}✅ Build OK${COLORS.reset} (${data.type})` : `${COLORS.red}❌ Build failed${COLORS.reset} (exit ${data.exitCode})`;
76
+ if (action === 'test') return data.passed ? `${COLORS.green}✅ Tests passed${COLORS.reset}` : `${COLORS.red}❌ Tests failed${COLORS.reset} (exit ${data.exitCode})`;
77
+ if (action === 'info') return `📁 ${data.name || data.type}: ${data.scripts?.join(', ') || 'no scripts'}`;
78
+ return JSON.stringify(data, null, 2);
79
+ }
80
+
81
+ function formatProcessResult(data) {
82
+ return data.processes || data.top || JSON.stringify(data, null, 2);
83
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Intent — parse natural language commands for direct tool execution
3
+ * "read index.js" → file read {path: "index.js"}
4
+ * "run npm test" → shell exec {command: "npm test"}
5
+ * "git status" → git status
6
+ */
7
+ const PATTERNS = [
8
+ // File operations
9
+ { re: /^(read|cat|show|open)\s+(.+)/i, tool: 'file', action: 'read', extract: (m) => ({ path: m[2] }) },
10
+ { re: /^(write|create|make)\s+(?:file\s+)?(.+)/i, tool: 'file', action: 'write', extract: (m) => ({ path: m[2] }) },
11
+ { re: /^(edit|change|replace)\s+(.+)/i, tool: 'file', action: 'edit', extract: (m) => ({ path: m[2] }) },
12
+ { re: /^(delete|remove|rm)\s+(.+)/i, tool: 'file', action: 'delete', extract: (m) => ({ path: m[2] }) },
13
+ { re: /^(list|ls|dir)\s*(.*)/i, tool: 'file', action: 'list', extract: (m) => ({ path: m[2] || '.' }) },
14
+ { re: /^(mkdir)\s+(.+)/i, tool: 'file', action: 'mkdir', extract: (m) => ({ path: m[2] }) },
15
+ { re: /^(exists|exist|check)\s+(.+)/i, tool: 'file', action: 'exists', extract: (m) => ({ path: m[2] }) },
16
+ { re: /^(copy|cp)\s+(\S+)\s+(\S+)/i, tool: 'file', action: 'copy', extract: (m) => ({ from: m[2], to: m[3] }) },
17
+ { re: /^(move|mv)\s+(\S+)\s+(\S+)/i, tool: 'file', action: 'move', extract: (m) => ({ from: m[2], to: m[3] }) },
18
+ // Shell
19
+ { re: /^(run|exec|execute)\s+(.+)/i, tool: 'shell', action: 'exec', extract: (m) => ({ command: m[2] }) },
20
+ { re: /^\.\/(.+)/i, tool: 'shell', action: 'exec', extract: (m) => ({ command: `./${m[1]}` }) },
21
+ // Git
22
+ { re: /^(git)\s+(.+)/i, tool: 'git', action: null, extract: (m) => {
23
+ const parts = m[2].split(' ');
24
+ return { action: parts[0], message: parts.slice(1).join(' ') || undefined };
25
+ }},
26
+ { re: /^(commit)\s+(.+)/i, tool: 'git', action: 'commit', extract: (m) => ({ message: m[2] }) },
27
+ { re: /^(push|pull|clone|status|diff|log)\s*(.*)/i, tool: 'git', action: (m) => m[1].toLowerCase(), extract: (m) => ({}) },
28
+ // Search
29
+ { re: /^(search|grep|find)\s+(.+)/i, tool: 'search', action: 'grep', extract: (m) => ({ pattern: m[2] }) },
30
+ { re: /^(where|locate)\s+(.+)/i, tool: 'search', action: 'find', extract: (m) => ({ name: m[2] }) },
31
+ // Project
32
+ { re: /^(build|compile)\s*(.*)/i, tool: 'project', action: 'build', extract: () => ({}) },
33
+ { re: /^(test|check)\s*(.*)/i, tool: 'project', action: 'test', extract: () => ({}) },
34
+ { re: /^(lint|format|fmt)\s*(.*)/i, tool: 'project', action: 'lint', extract: () => ({}) },
35
+ { re: /^(install|deps)\s*(.*)/i, tool: 'project', action: 'deps', extract: () => ({ install: true }) },
36
+ // Process
37
+ { re: /^(kill)\s+(\d+)/i, tool: 'process', action: 'kill', extract: (m) => ({ pid: parseInt(m[2]) }) },
38
+ { re: /^(ps|processes|top)\s*(.*)/i, tool: 'process', action: 'list', extract: (m) => ({ filter: m[2] || undefined }) },
39
+ // Network
40
+ { re: /^(fetch|curl|http)\s+(.+)/i, tool: 'network', action: 'fetch', extract: (m) => ({ url: m[2] }) },
41
+ { re: /^(ping)\s+(.+)/i, tool: 'network', action: 'ping', extract: (m) => ({ host: m[2] }) },
42
+ { re: /^(download)\s+(.+)/i, tool: 'network', action: 'download', extract: (m) => ({ url: m[2] }) },
43
+ // AI
44
+ { re: /^(analyze|inspect)\s+(.+)/i, tool: 'ai', action: 'analyze', extract: (m) => ({ path: m[2] }) },
45
+ { re: /^(summarize|summary)\s+(.+)/i, tool: 'ai', action: 'summarize', extract: (m) => ({ path: m[2] }) },
46
+ { re: /^(diff)\s+(\S+)\s+(\S+)/i, tool: 'ai', action: 'diff', extract: (m) => ({ file1: m[2], file2: m[3] }) },
47
+ // Snippet
48
+ { re: /^(snippet|snip)\s+save\s+(\S+)\s+(.+)/i, tool: 'snippet', action: 'save', extract: (m) => ({ name: m[2], code: m[3] }) },
49
+ { re: /^(snippet|snip)\s+load\s+(\S+)/i, tool: 'snippet', action: 'load', extract: (m) => ({ name: m[2] }) },
50
+ { re: /^(snippet|snip)\s+list/i, tool: 'snippet', action: 'list', extract: () => ({}) },
51
+ // Env
52
+ { re: /^(env|envvar)\s+(\S+)/i, tool: 'env', action: 'get', extract: (m) => ({ name: m[2] }) },
53
+ // Template
54
+ { re: /^(scaffold|scaffold|new project|create project)\s+(\S+)\s+(\S+)/i, tool: 'template', action: 'create', extract: (m) => ({ template: m[2], name: m[3] }) },
55
+ { re: /^(templates|scaffolds)/i, tool: 'template', action: 'list', extract: () => ({}) },
56
+ // History
57
+ { re: /^(history|log|calls)\s*(.*)/i, tool: 'history', action: 'list', extract: (m) => ({ limit: parseInt(m[2]) || 20 }) },
58
+ // Config
59
+ { re: /^(config|conf|setting)\s+(get|set|list|reset)\s*(.*)/i, tool: 'config', action: null, extract: (m) => ({ action: m[2], key: m[3] || undefined }) },
60
+ // Diff
61
+ { re: /^(diff|compare)\s+(\S+)\s+(\S+)/i, tool: 'diff', action: 'compare', extract: (m) => ({ file1: m[2], file2: m[3] }) },
62
+ // Watch
63
+ { re: /^(watch|monitor)\s+(.+)/i, tool: 'watch', action: 'start', extract: (m) => ({ path: m[2] }) },
64
+ { re: /^(unwatch|stop watching)\s+(.+)/i, tool: 'watch', action: 'stop', extract: (m) => ({ path: m[2] }) },
65
+ ];
66
+
67
+ export function parseIntent(input) {
68
+ for (const { re, tool, action, extract } of PATTERNS) {
69
+ const match = input.match(re);
70
+ if (match) {
71
+ const params = extract(match);
72
+ return {
73
+ matched: true,
74
+ tool,
75
+ action: action || params.action,
76
+ params: { ...params, action: action || params.action },
77
+ };
78
+ }
79
+ }
80
+ return { matched: false };
81
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Keybindings — keyboard shortcuts for the REPL
3
+ * Inspired by Claude Code's keyboard shortcuts
4
+ */
5
+ export const KEYBINDINGS = {
6
+ 'Ctrl+C': 'Cancel current input / Exit',
7
+ 'Ctrl+D': 'Exit (EOF)',
8
+ 'Ctrl+L': 'Clear screen',
9
+ 'Ctrl+U': 'Clear line',
10
+ 'Ctrl+K': 'Delete to end of line',
11
+ 'Ctrl+A': 'Move to start of line',
12
+ 'Ctrl+E': 'Move to end of line',
13
+ 'Up/Down': 'Navigate history',
14
+ 'Tab': 'Auto-complete (planned)',
15
+ };
16
+
17
+ export function setupKeybindings(rl) {
18
+ // readline handles most of these automatically
19
+ // Custom bindings can be added here
20
+ return rl;
21
+ }
22
+
23
+ export function formatKeybindings() {
24
+ const lines = ['⌨️ Keyboard Shortcuts:'];
25
+ for (const [key, desc] of Object.entries(KEYBINDINGS)) {
26
+ lines.push(` ${key.padEnd(15)} ${desc}`);
27
+ }
28
+ return lines.join('\n');
29
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Logger — colored terminal output with levels
3
+ */
4
+ let _debug = false;
5
+
6
+ export const COLORS = {
7
+ reset: '\x1b[0m',
8
+ dim: '\x1b[2m',
9
+ bold: '\x1b[1m',
10
+ red: '\x1b[31m',
11
+ green: '\x1b[32m',
12
+ yellow: '\x1b[33m',
13
+ blue: '\x1b[34m',
14
+ magenta: '\x1b[35m',
15
+ cyan: '\x1b[36m',
16
+ gray: '\x1b[90m',
17
+ };
18
+
19
+ const ICONS = {
20
+ ok: '✅', err: '❌', warn: '⚠️', info: 'ℹ️',
21
+ tool: '🔧', mcp: '🔗', plugin: '🧩', system: '⚙️',
22
+ user: '👤', ai: '🤖', debug: '🔍', file: '📁',
23
+ };
24
+
25
+ export function setDebug(enabled) { _debug = enabled; }
26
+ export function isDebug() { return _debug; }
27
+ export function debug(msg, data) { log('debug', msg, data); }
28
+
29
+ export function log(level, msg, data) {
30
+ if (level === 'debug' && !_debug) return;
31
+ const icon = ICONS[level] || '';
32
+ const color = {
33
+ ok: COLORS.green, err: COLORS.red, warn: COLORS.yellow,
34
+ info: COLORS.blue, tool: COLORS.cyan, mcp: COLORS.magenta,
35
+ debug: COLORS.gray, user: COLORS.bold, ai: COLORS.green,
36
+ }[level] || '';
37
+
38
+ const prefix = `${color}${icon} ${COLORS.reset}`;
39
+ console.log(`${prefix}${msg}`);
40
+ if (data && _debug) console.log(`${COLORS.gray}${JSON.stringify(data, null, 2)}${COLORS.reset}`);
41
+ }
42
+
43
+ export function table(rows) {
44
+ if (!rows.length) return;
45
+ const keys = Object.keys(rows[0]);
46
+ const widths = keys.map(k => Math.max(k.length, ...rows.map(r => String(r[k] ?? '').length)));
47
+ const sep = widths.map(w => '─'.repeat(w + 2)).join('┼');
48
+ console.log(keys.map((k, i) => k.padEnd(widths[i])).join(' │ '));
49
+ console.log(sep);
50
+ for (const row of rows) {
51
+ console.log(keys.map((k, i) => String(row[k] ?? '').padEnd(widths[i])).join(' │ '));
52
+ }
53
+ }
54
+
55
+ export function banner(title, lines = []) {
56
+ const width = Math.max(title.length + 4, ...lines.map(l => l.length + 4), 40);
57
+ const pad = (s) => ` ${s}${' '.repeat(width - s.length - 2)} `;
58
+ console.log('');
59
+ console.log(`${COLORS.cyan}╔${'═'.repeat(width)}╗`);
60
+ console.log(`║${COLORS.bold}${pad(title)}${COLORS.cyan}║`);
61
+ if (lines.length) {
62
+ console.log(`╠${'═'.repeat(width)}╣`);
63
+ for (const line of lines) console.log(`║${COLORS.reset}${pad(line)}${COLORS.cyan}║`);
64
+ }
65
+ console.log(`╚${'═'.repeat(width)}╝${COLORS.reset}`);
66
+ console.log('');
67
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * ModelInfo — display and manage AI model information
3
+ */
4
+ import { COLORS } from './logger.js';
5
+
6
+ export function getModelInfo() {
7
+ return {
8
+ name: 'XiaoZhi',
9
+ provider: 'Tenclass',
10
+ protocol: 'MQTT + MCP',
11
+ features: ['text', 'voice', 'tool_use', 'mcp'],
12
+ maxTools: 32,
13
+ };
14
+ }
15
+
16
+ export function formatModelInfo() {
17
+ const info = getModelInfo();
18
+ return [
19
+ `${COLORS.bold}🧠 Model:${COLORS.reset}`,
20
+ ` Name: ${info.name}`,
21
+ ` Provider: ${info.provider}`,
22
+ ` Protocol: ${info.protocol}`,
23
+ ` Features: ${info.features.join(', ')}`,
24
+ ` Max Tools: ${info.maxTools}`,
25
+ ].join('\n');
26
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Retry — retry logic for transient failures
3
+ */
4
+ import { debug } from './logger.js';
5
+
6
+ export async function withRetry(fn, { maxRetries = 2, delay = 1000, backoff = 2 } = {}) {
7
+ let lastError;
8
+ for (let i = 0; i <= maxRetries; i++) {
9
+ try {
10
+ return await fn();
11
+ } catch (e) {
12
+ lastError = e;
13
+ if (i < maxRetries) {
14
+ const wait = delay * Math.pow(backoff, i);
15
+ debug(`Retry ${i + 1}/${maxRetries} after ${wait}ms: ${e.message}`);
16
+ await new Promise(r => setTimeout(r, wait));
17
+ }
18
+ }
19
+ }
20
+ throw lastError;
21
+ }
22
+
23
+ export function isRetryable(error) {
24
+ const retryable = ['ETIMEDOUT', 'ECONNRESET', 'ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN'];
25
+ return retryable.includes(error.code) || error.message?.includes('timeout');
26
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Sandbox — safety layer for tool execution
3
+ * Validates inputs, blocks dangerous operations, rate limits
4
+ */
5
+ import { resolve } from 'path';
6
+ import { homedir } from 'os';
7
+
8
+ const HOME = homedir();
9
+ const DANGEROUS_PATTERNS = [
10
+ /rm\s+-rf\s+\/$/i, /rm\s+-rf\s+\/[^a-z]/i, /rm\s+-rf\s+\/\s/i,
11
+ /mkfs/i, /dd\s+if=.*of=\/dev/i,
12
+ />\s*\/dev\/sd/i, /chmod\s+000/i, /shutdown/i, /reboot/i,
13
+ ];
14
+ const RATE_LIMIT_WINDOW = 1000; // 1 second
15
+ const MAX_CALLS_PER_WINDOW = 10;
16
+
17
+ export class Sandbox {
18
+ #callTimes = [];
19
+
20
+ validate(toolName, args) {
21
+ // Rate limiting
22
+ const now = Date.now();
23
+ this.#callTimes = this.#callTimes.filter(t => now - t < RATE_LIMIT_WINDOW);
24
+ if (this.#callTimes.length >= MAX_CALLS_PER_WINDOW) {
25
+ throw new Error('Rate limit exceeded. Slow down.');
26
+ }
27
+ this.#callTimes.push(now);
28
+
29
+ // Validate shell commands
30
+ if (toolName === 'shell' && args.command) {
31
+ for (const pattern of DANGEROUS_PATTERNS) {
32
+ if (pattern.test(args.command)) {
33
+ throw new Error(`Blocked dangerous command: ${args.command}`);
34
+ }
35
+ }
36
+ }
37
+
38
+ // Validate file paths
39
+ if (args.path) {
40
+ const abs = resolve(args.path.replace(/^~(?=$|[/\\])/g, HOME));
41
+ const blocked = ['/etc/shadow', '/etc/passwd', '/etc/sudoers', '/proc', '/sys'];
42
+ if (blocked.some(p => abs.startsWith(p))) {
43
+ throw new Error(`Access denied: ${abs}`);
44
+ }
45
+ }
46
+
47
+ // Validate git commit messages
48
+ if (toolName === 'git' && args.action === 'commit' && !args.message) {
49
+ throw new Error('Commit message required');
50
+ }
51
+
52
+ return true;
53
+ }
54
+ }