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,133 @@
1
+ /**
2
+ * WssEndpoint — official XiaoZhi MCP endpoint (WSS)
3
+ * Alternative to MQTT-based tool serving
4
+ * Reference: https://github.com/78/mcp-calculator/blob/main/mcp_pipe.py
5
+ */
6
+ import WebSocket from 'ws';
7
+ import { log, debug } from '../core/logger.js';
8
+
9
+ const INITIAL_BACKOFF = 1000;
10
+ const MAX_BACKOFF = 60000;
11
+ const HEARTBEAT_INTERVAL = 25000;
12
+
13
+ export class WssEndpoint {
14
+ constructor(url, getTools, callTool) {
15
+ this.url = url;
16
+ this.getTools = getTools;
17
+ this.callTool = callTool;
18
+ this.ws = null;
19
+ this.backoff = INITIAL_BACKOFF;
20
+ this.closed = false;
21
+ this.nameMap = new Map();
22
+ this._heartbeat = null;
23
+ this._reconnectTimer = null;
24
+ }
25
+
26
+ start() {
27
+ if (!this.url) { debug('WSS endpoint: no URL'); return; }
28
+ this.closed = false;
29
+ this._connect();
30
+ }
31
+
32
+ _connect() {
33
+ const safe = this.url.replace(/token=[^&]+/i, 'token=***');
34
+ log('mcp', `WSS connecting: ${safe}`);
35
+
36
+ this.ws = new WebSocket(this.url);
37
+
38
+ this.ws.on('open', () => {
39
+ this.backoff = INITIAL_BACKOFF;
40
+ this.nameMap.clear();
41
+ log('ok', 'WSS connected');
42
+ this._startHeartbeat();
43
+ });
44
+
45
+ this.ws.on('message', (data) => this._onMessage(data));
46
+
47
+ this.ws.on('close', (code) => {
48
+ log('warn', `WSS closed (${code})`);
49
+ this._stopHeartbeat();
50
+ this.nameMap.clear();
51
+ if (!this.closed) this._reconnect();
52
+ });
53
+
54
+ this.ws.on('error', (e) => debug(`WSS error: ${e.message}`));
55
+ }
56
+
57
+ _startHeartbeat() {
58
+ this._stopHeartbeat();
59
+ this._heartbeat = setInterval(() => {
60
+ if (this.ws?.readyState === WebSocket.OPEN) {
61
+ try { this.ws.ping(); } catch {}
62
+ }
63
+ }, HEARTBEAT_INTERVAL);
64
+ }
65
+
66
+ _stopHeartbeat() {
67
+ if (this._heartbeat) { clearInterval(this._heartbeat); this._heartbeat = null; }
68
+ }
69
+
70
+ _reconnect() {
71
+ if (this.closed || this._reconnectTimer) return;
72
+ const delay = this.backoff;
73
+ this._reconnectTimer = setTimeout(() => {
74
+ this._reconnectTimer = null;
75
+ this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF);
76
+ this._connect();
77
+ }, delay);
78
+ }
79
+
80
+ _send(obj) {
81
+ if (this.ws?.readyState === WebSocket.OPEN) {
82
+ this.ws.send(JSON.stringify(obj));
83
+ }
84
+ }
85
+
86
+ async _onMessage(data) {
87
+ let msg;
88
+ try { msg = JSON.parse(data.toString()); } catch { return; }
89
+
90
+ const { id, method } = msg;
91
+
92
+ if (method === 'initialize') {
93
+ this._send({ jsonrpc: '2.0', id, result: {
94
+ protocolVersion: '2024-11-05', capabilities: { tools: {} },
95
+ serverInfo: { name: 'coding-agent', version: '1.0.0' },
96
+ }});
97
+ }
98
+
99
+ if (method === 'notifications/initialized') log('mcp', 'WSS initialized ✓');
100
+
101
+ if (method === 'tools/list') {
102
+ this.nameMap.clear();
103
+ const tools = this.getTools().map(t => {
104
+ const name = t.name.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
105
+ this.nameMap.set(name, t.name);
106
+ return { name, description: t.description, inputSchema: t.inputSchema || { type: 'object', properties: {} } };
107
+ });
108
+ this._send({ jsonrpc: '2.0', id, result: { tools } });
109
+ log('mcp', `WSS sent ${tools.length} tools`);
110
+ }
111
+
112
+ if (method === 'tools/call') {
113
+ const name = this.nameMap.get(msg.params?.name) || msg.params?.name;
114
+ try {
115
+ const result = await this.callTool(name, msg.params?.arguments || {});
116
+ this._send({ jsonrpc: '2.0', id, result: { content: result.content || [{ type: 'text', text: 'OK' }], isError: !!result.isError } });
117
+ } catch (e) {
118
+ this._send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: e.message }], isError: true } });
119
+ }
120
+ }
121
+
122
+ if (method === 'ping') this._send({ jsonrpc: '2.0', id, result: {} });
123
+ }
124
+
125
+ isConnected() { return this.ws?.readyState === WebSocket.OPEN; }
126
+
127
+ stop() {
128
+ this.closed = true;
129
+ this._stopHeartbeat();
130
+ if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; }
131
+ if (this.ws) { try { this.ws.close(); } catch {} this.ws = null; }
132
+ }
133
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * PluginManager — loads external plugins from plugins/ directory
3
+ *
4
+ * Plugin structure:
5
+ * export default { name, description, version, init?, tools?, shutdown? }
6
+ * tools: [{ name, description, inputSchema, handler }]
7
+ */
8
+ import { readdir } from 'fs/promises';
9
+ import { join, dirname } from 'path';
10
+ import { fileURLToPath } from 'url';
11
+ import { log, debug } from '../core/logger.js';
12
+ import { bus } from '../core/event-bus.js';
13
+ import { getConfig } from '../core/config.js';
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+ const PLUGINS_DIR = join(__dirname);
17
+
18
+ export class PluginManager {
19
+ #plugins = new Map();
20
+ #registry = null;
21
+
22
+ constructor(registry) {
23
+ this.#registry = registry;
24
+ }
25
+
26
+ async load() {
27
+ const config = getConfig();
28
+ const disabled = new Set(config.plugins?.disabled || []);
29
+
30
+ try {
31
+ const files = await readdir(PLUGINS_DIR);
32
+ for (const file of files.filter(f => f.endsWith('.js') && f !== 'manager.js')) {
33
+ const name = file.replace('.js', '');
34
+ if (disabled.has(name)) { debug(`Plugin "${name}" disabled`); continue; }
35
+
36
+ try {
37
+ const mod = await import(join(PLUGINS_DIR, file));
38
+ const plugin = mod.default || mod;
39
+ if (!plugin?.name) continue;
40
+
41
+ if (plugin.init) await plugin.init({ bus, config });
42
+ if (plugin.tools) {
43
+ for (const tool of plugin.tools) {
44
+ this.#registry.register(tool);
45
+ }
46
+ }
47
+ this.#plugins.set(plugin.name, plugin);
48
+ log('plugin', ` ${plugin.name} v${plugin.version || '?'} — ${plugin.description || ''}`);
49
+ } catch (e) {
50
+ log('warn', `Plugin "${name}" failed: ${e.message}`);
51
+ }
52
+ }
53
+ } catch (e) {
54
+ debug(`Plugins dir scan: ${e.message}`);
55
+ }
56
+
57
+ log('info', `${this.#plugins.size} plugins loaded`);
58
+ }
59
+
60
+ list() {
61
+ return [...this.#plugins.values()].map(p => ({
62
+ name: p.name, version: p.version || '-', description: p.description || '-',
63
+ }));
64
+ }
65
+
66
+ async shutdown() {
67
+ for (const [name, p] of this.#plugins) {
68
+ try { if (p.shutdown) await p.shutdown(); } catch (e) { debug(`Plugin "${name}" shutdown error: ${e.message}`); }
69
+ }
70
+ this.#plugins.clear();
71
+ }
72
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * REPL Commands — slash command handlers
3
+ * Extracted from repl.js to keep it under 200 lines
4
+ */
5
+ import { log, COLORS } from './core/logger.js';
6
+ import { getConfig } from './core/config.js';
7
+ import { detectWorkspace, formatWorkspace } from './core/workspace.js';
8
+ import { saveConversation, listConversations, loadConversation } from './core/conversation.js';
9
+ import { formatToolResult } from './core/formatter.js';
10
+ import { showToolHelp, showAllToolHelp } from './core/tool-help.js';
11
+ import { runDoctor } from './core/doctor.js';
12
+ import { formatModelInfo } from './core/model-info.js';
13
+
14
+ export async function handleCommand(cmd, arg, ctx) {
15
+ const { registry, mqtt, stats, session, fileTracker, history } = ctx;
16
+
17
+ switch (cmd) {
18
+ case '/quit': case '/exit':
19
+ console.log(stats.formatReport());
20
+ console.log(fileTracker.getSummary());
21
+ await registry.shutdown();
22
+ mqtt.disconnect();
23
+ process.exit(0);
24
+
25
+ case '/help':
26
+ if (arg) showToolHelp(arg);
27
+ else return 'help';
28
+ break;
29
+
30
+ case '/tools': return 'tools';
31
+ case '/status': return 'status';
32
+ case '/stats': console.log(`\n${stats.formatReport()}\n`); break;
33
+ case '/workspace':
34
+ try { console.log(`\n${formatWorkspace(await detectWorkspace())}\n`); }
35
+ catch (e) { log('err', e.message); }
36
+ break;
37
+
38
+ case '/files': console.log(`\n${fileTracker.getSummary()}\n`); break;
39
+
40
+ case '/call': {
41
+ if (!arg) { log('warn', 'Usage: /call <tool> <action> [params]'); break; }
42
+ const parts = arg.split(' ');
43
+ const toolName = parts[0];
44
+ let args = {};
45
+ if (parts.length > 1) {
46
+ try { args = JSON.parse(parts.slice(1).join(' ')); }
47
+ catch { args = { action: parts[1], path: parts.slice(2).join(' ') || undefined }; }
48
+ }
49
+ const result = await registry.call(toolName, args);
50
+ console.log(`\n${formatToolResult(toolName, args.action, result)}\n`);
51
+ break;
52
+ }
53
+
54
+ case '/conv': {
55
+ const [sub, ...rest] = (arg || '').split(' ');
56
+ const name = rest.join(' ');
57
+ try {
58
+ if (sub === 'save') { const p = await saveConversation(history, name || undefined); log('ok', `Saved: ${p}`); }
59
+ else if (sub === 'list') {
60
+ const convs = await listConversations();
61
+ if (!convs.length) { log('info', 'No saved conversations'); break; }
62
+ console.log(`\n${COLORS.bold}💾 Conversations:${COLORS.reset}\n`);
63
+ for (const c of convs) console.log(` ${c.name} (${c.messages} msgs)`);
64
+ console.log();
65
+ } else if (sub === 'load') {
66
+ if (!name) { log('warn', 'Usage: /conv load <name>'); break; }
67
+ const msgs = await loadConversation(name);
68
+ history.length = 0;
69
+ history.push(...msgs);
70
+ log('ok', `Loaded ${msgs.length} messages`);
71
+ } else { log('info', 'Usage: /conv save|list|load [name]'); }
72
+ } catch (e) { log('err', e.message); }
73
+ break;
74
+ }
75
+
76
+ case '/history': {
77
+ const recent = history.slice(-10);
78
+ if (!recent.length) { log('info', 'No history'); break; }
79
+ console.log(`\n${COLORS.bold}📜 Recent:${COLORS.reset}\n`);
80
+ for (const h of recent) console.log(` ${COLORS.dim}${new Date(h.ts).toLocaleTimeString()}${COLORS.reset} ${h.text.slice(0, 80)}`);
81
+ console.log();
82
+ break;
83
+ }
84
+
85
+ case '/doctor': await runDoctor(); break;
86
+ case '/model': console.log(`\n${formatModelInfo()}\n`); break;
87
+ case '/clear': console.clear(); break;
88
+ default: log('info', `Unknown: ${cmd}. /help`);
89
+ }
90
+ return null;
91
+ }
package/src/repl.js ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * REPL — interactive CLI with rich TUI
3
+ */
4
+ import { createInterface } from 'readline';
5
+ import chalk from 'chalk';
6
+ import { bus } from './core/event-bus.js';
7
+ import { getConfig } from './core/config.js';
8
+ import { formatToolResult } from './core/formatter.js';
9
+ import { parseIntent } from './core/intent.js';
10
+ import { FileTracker } from './core/file-tracker.js';
11
+ import { handleCommand } from './repl-commands.js';
12
+ import {
13
+ renderBanner, renderStatus, renderTools, renderHelp,
14
+ renderToolCall, renderToolResult, renderAiResponse,
15
+ renderUserInput, createPrompt,
16
+ } from './tui/renderer.js';
17
+
18
+ export class REPL {
19
+ constructor(registry, mqtt, stats, session) {
20
+ this.registry = registry;
21
+ this.mqtt = mqtt;
22
+ this.stats = stats;
23
+ this.session = session;
24
+ this.fileTracker = new FileTracker();
25
+ this.rl = null;
26
+ this.history = [];
27
+ }
28
+
29
+ start() {
30
+ const config = getConfig();
31
+ renderBanner(config, this.registry.all(), this.mqtt.connected);
32
+ this._events();
33
+
34
+ if (!process.stdin.isTTY) {
35
+ console.log(chalk.gray(' Non-interactive mode. Agent running in background.'));
36
+ process.stdin.resume();
37
+ return;
38
+ }
39
+
40
+ const dirName = process.cwd().split('/').pop() || process.cwd();
41
+ this.rl = createInterface({
42
+ input: process.stdin, output: process.stdout,
43
+ prompt: createPrompt(dirName), historySize: 200,
44
+ });
45
+ this.rl.prompt();
46
+ this.rl.on('line', (input) => this._line(input.trim()));
47
+ this.rl.on('close', () => { console.log(chalk.gray('\n Goodbye! 👋\n')); process.exit(0); });
48
+ }
49
+
50
+ _events() {
51
+ bus.on('llm:text', (text) => {
52
+ renderAiResponse(text);
53
+ if (this.rl) this.rl.prompt();
54
+ });
55
+
56
+ bus.on('tts:sentence', (text) => {
57
+ console.log(chalk.dim(` 🔊 ${text}`));
58
+ });
59
+
60
+ bus.on('tool:called', ({ name, result, args }) => {
61
+ renderToolCall(name, args?.action, args);
62
+ renderToolResult(!result.isError, result.content?.[0]?.text);
63
+ });
64
+
65
+ bus.on('ready', () => {
66
+ console.log(chalk.green(' ✅ XiaoZhi session ready\n'));
67
+ });
68
+ }
69
+
70
+ async _line(input) {
71
+ if (!input) return this.rl?.prompt();
72
+ if (input.startsWith('/')) return this._cmd(input);
73
+
74
+ // Try intent parsing
75
+ const intent = parseIntent(input);
76
+ if (intent.matched) {
77
+ renderUserInput(input);
78
+ const result = await this.registry.call(intent.tool, intent.params);
79
+ const formatted = formatToolResult(intent.tool, intent.action, result);
80
+ console.log(`\n${formatted}\n`);
81
+ this.history.push({ role: 'user', text: input, ts: Date.now() });
82
+ this.rl?.prompt();
83
+ return;
84
+ }
85
+
86
+ // Send to XiaoZhi
87
+ renderUserInput(input);
88
+ this.history.push({ role: 'user', text: input, ts: Date.now() });
89
+ this.mqtt.sendText(input);
90
+ this.rl?.prompt();
91
+ }
92
+
93
+ async _cmd(input) {
94
+ const [cmd, ...rest] = input.split(' ');
95
+ const arg = rest.join(' ');
96
+ const ctx = { registry: this.registry, mqtt: this.mqtt, stats: this.stats, session: this.session, fileTracker: this.fileTracker, history: this.history };
97
+ const special = await handleCommand(cmd, arg, ctx);
98
+
99
+ if (special === 'help') renderHelp();
100
+ if (special === 'tools') renderTools(this.registry.list());
101
+ if (special === 'status') {
102
+ const s = this.session.getStats();
103
+ renderStatus({
104
+ 'MQTT': this.mqtt.connected ? '✅ Connected' : '❌ Disconnected',
105
+ 'Session': this.mqtt.sessionId || 'none',
106
+ 'Tools': `${this.registry.all().length} (${this.registry.all().reduce((s, t) => s + (t.actions?.length || 0), 0)} actions)`,
107
+ 'Turns': s.turns,
108
+ 'Tool Calls': `${s.toolCalls} (${s.errors} errors)`,
109
+ 'Uptime': `${s.uptime}s`,
110
+ 'Workspace': process.cwd(),
111
+ });
112
+ }
113
+ this.rl?.prompt();
114
+ }
115
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * AI Tool — code analysis helpers (1 MCP tool, 5 actions)
3
+ * These are LOCAL helpers — the heavy lifting is done by XiaoZhi LLM via MQTT.
4
+ */
5
+ import { readFile } from 'fs/promises';
6
+ import { resolve } from 'path';
7
+ import { homedir } from 'os';
8
+
9
+ const HOME = homedir();
10
+
11
+ function safePath(p) {
12
+ return resolve(p.replace(/^~(?=$|[/\\])/g, HOME));
13
+ }
14
+
15
+ function detectLanguage(file) {
16
+ const ext = file.split('.').pop()?.toLowerCase();
17
+ const map = {
18
+ js: 'javascript', mjs: 'javascript', jsx: 'javascript', ts: 'typescript',
19
+ tsx: 'typescript', py: 'python', rb: 'ruby', go: 'go', rs: 'rust',
20
+ java: 'java', c: 'c', cpp: 'cpp', h: 'c', hpp: 'cpp',
21
+ sh: 'shell', bash: 'shell', zsh: 'shell',
22
+ json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml',
23
+ md: 'markdown', html: 'html', css: 'css', sql: 'sql',
24
+ };
25
+ return map[ext] || 'unknown';
26
+ }
27
+
28
+ function analyzeCode(content, lang) {
29
+ const lines = content.split('\n');
30
+ const stats = {
31
+ totalLines: lines.length,
32
+ blankLines: lines.filter(l => !l.trim()).length,
33
+ commentLines: lines.filter(l => {
34
+ const t = l.trim();
35
+ return t.startsWith('//') || t.startsWith('#') || t.startsWith('/*') || t.startsWith('*');
36
+ }).length,
37
+ language: lang,
38
+ };
39
+ stats.codeLines = stats.totalLines - stats.blankLines - stats.commentLines;
40
+
41
+ // Detect functions/classes
42
+ const functions = [];
43
+ const classes = [];
44
+ for (const line of lines) {
45
+ const fnMatch = line.match(/(?:function|def|fn|func|async\s+function)\s+(\w+)/);
46
+ if (fnMatch) functions.push(fnMatch[1]);
47
+ const clsMatch = line.match(/(?:class|struct|interface)\s+(\w+)/);
48
+ if (clsMatch) classes.push(clsMatch[1]);
49
+ }
50
+
51
+ return { ...stats, functions: functions.slice(0, 20), classes: classes.slice(0, 10) };
52
+ }
53
+
54
+ const ACTIONS = {
55
+ async explain({ path, code, language }) {
56
+ const lang = language || (path ? detectLanguage(path) : 'unknown');
57
+ return {
58
+ language: lang,
59
+ hint: 'To get a full explanation, send this code to XiaoZhi with: "Explain this code: <code>"',
60
+ metadata: path ? { file: path } : null,
61
+ };
62
+ },
63
+
64
+ async analyze({ path }) {
65
+ if (!path) throw new Error('path required');
66
+ const abs = safePath(path);
67
+ const content = await readFile(abs, 'utf-8');
68
+ const lang = detectLanguage(abs);
69
+ return { file: abs, ...analyzeCode(content, lang) };
70
+ },
71
+
72
+ async detectLanguage({ path }) {
73
+ if (!path) throw new Error('path required');
74
+ return { file: path, language: detectLanguage(path) };
75
+ },
76
+
77
+ async summarize({ path }) {
78
+ if (!path) throw new Error('path required');
79
+ const abs = safePath(path);
80
+ const content = await readFile(abs, 'utf-8');
81
+ const lang = detectLanguage(abs);
82
+ const analysis = analyzeCode(content, lang);
83
+ const firstLine = content.split('\n').find(l => l.trim() && !l.trim().startsWith('#!'));
84
+ return {
85
+ file: abs,
86
+ language: lang,
87
+ summary: `${analysis.totalLines} lines, ${analysis.functions.length} functions, ${analysis.classes.length} classes`,
88
+ firstLine: firstLine?.trim().slice(0, 100),
89
+ stats: analysis,
90
+ };
91
+ },
92
+
93
+ async diff({ file1, file2 }) {
94
+ if (!file1 || !file2) throw new Error('file1 and file2 required');
95
+ const c1 = (await readFile(safePath(file1), 'utf-8')).split('\n');
96
+ const c2 = (await readFile(safePath(file2), 'utf-8')).split('\n');
97
+ const changes = [];
98
+ const maxLen = Math.max(c1.length, c2.length);
99
+ for (let i = 0; i < maxLen; i++) {
100
+ if (c1[i] !== c2[i]) {
101
+ changes.push({ line: i + 1, file1: c1[i] || '(EOF)', file2: c2[i] || '(EOF)' });
102
+ }
103
+ }
104
+ return { file1, file2, differences: changes.length, changes: changes.slice(0, 50) };
105
+ },
106
+ };
107
+
108
+ export default {
109
+ name: 'ai',
110
+ description: 'Code analysis: explain, analyze, detectLanguage, summarize, diff (local helpers)',
111
+ actions: Object.keys(ACTIONS).map(name => ({ name })),
112
+ inputSchema: {
113
+ type: 'object',
114
+ properties: {
115
+ action: { type: 'string', enum: Object.keys(ACTIONS) },
116
+ path: { type: 'string', description: 'File path' },
117
+ code: { type: 'string', description: 'Code snippet' },
118
+ language: { type: 'string', description: 'Programming language' },
119
+ file1: { type: 'string', description: 'First file (for diff)' },
120
+ file2: { type: 'string', description: 'Second file (for diff)' },
121
+ },
122
+ required: ['action'],
123
+ },
124
+ async handler({ action, ...params }) {
125
+ if (!ACTIONS[action]) throw new Error(`Unknown action: ${action}. Use: ${Object.keys(ACTIONS).join(', ')}`);
126
+ return ACTIONS[action](params);
127
+ },
128
+ };
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Config Tool — runtime configuration management (1 MCP tool, 4 actions)
3
+ * Actions: get, set, list, reset
4
+ */
5
+ import { getConfig, saveConfig } from '../core/config.js';
6
+ import { log } from '../core/logger.js';
7
+
8
+ const EDITABLE = ['tools.shell_timeout', 'tools.max_output', 'tools.auto_format', 'agent.workspace'];
9
+
10
+ function getNested(obj, path) {
11
+ return path.split('.').reduce((o, k) => o?.[k], obj);
12
+ }
13
+
14
+ function setNested(obj, path, value) {
15
+ const keys = path.split('.');
16
+ const last = keys.pop();
17
+ const target = keys.reduce((o, k) => o[k] = o[k] || {}, obj);
18
+ target[last] = value;
19
+ }
20
+
21
+ const ACTIONS = {
22
+ get: {
23
+ required: ['key'],
24
+ handler: async ({ key }) => {
25
+ const config = getConfig();
26
+ const value = getNested(config, key);
27
+ if (value === undefined) throw new Error(`Config key not found: ${key}`);
28
+ return { key, value };
29
+ },
30
+ },
31
+
32
+ set: {
33
+ required: ['key', 'value'],
34
+ handler: async ({ key, value }) => {
35
+ if (!EDITABLE.includes(key)) throw new Error(`Cannot edit: ${key}. Editable: ${EDITABLE.join(', ')}`);
36
+ const config = getConfig();
37
+ setNested(config, key, value);
38
+ await saveConfig(config._root, config);
39
+ log('config', `⚙️ Set ${key} = ${value}`);
40
+ return { key, value, saved: true };
41
+ },
42
+ },
43
+
44
+ list: {
45
+ required: [],
46
+ handler: async () => {
47
+ const config = getConfig();
48
+ const { _root, _path, ...clean } = config;
49
+ return { config: clean, editable: EDITABLE };
50
+ },
51
+ },
52
+
53
+ reset: {
54
+ required: [],
55
+ handler: async () => {
56
+ const config = getConfig();
57
+ config.tools = { shell_timeout: 30000, max_output: 10000 };
58
+ await saveConfig(config._root, config);
59
+ return { reset: true };
60
+ },
61
+ },
62
+ };
63
+
64
+ const actionNames = Object.keys(ACTIONS);
65
+
66
+ export default {
67
+ name: 'config',
68
+ description: 'Runtime config: get, set, list, reset',
69
+ actions: actionNames.map(name => ({ name, required: ACTIONS[name].required })),
70
+ inputSchema: {
71
+ type: 'object',
72
+ properties: {
73
+ action: { type: 'string', enum: actionNames },
74
+ key: { type: 'string', description: 'Config key (dot notation)' },
75
+ value: { type: 'string', description: 'Value to set' },
76
+ },
77
+ required: ['action'],
78
+ },
79
+ async handler({ action, ...params }) {
80
+ const a = ACTIONS[action];
81
+ if (!a) throw new Error(`Unknown action: ${action}`);
82
+ for (const f of a.required) {
83
+ if (params[f] === undefined) throw new Error(`Missing required: ${f}`);
84
+ }
85
+ return a.handler(params);
86
+ },
87
+ };