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.
- package/README.md +273 -0
- package/config.example.json +24 -0
- package/package.json +44 -0
- package/src/core/auto-format.js +47 -0
- package/src/core/completion.js +65 -0
- package/src/core/config.js +68 -0
- package/src/core/confirm.js +51 -0
- package/src/core/context.js +69 -0
- package/src/core/conversation.js +57 -0
- package/src/core/diff.js +50 -0
- package/src/core/doctor.js +60 -0
- package/src/core/error-handler.js +39 -0
- package/src/core/event-bus.js +43 -0
- package/src/core/file-tracker.js +44 -0
- package/src/core/formatter.js +83 -0
- package/src/core/intent.js +81 -0
- package/src/core/keybindings.js +29 -0
- package/src/core/logger.js +67 -0
- package/src/core/model-info.js +26 -0
- package/src/core/retry.js +26 -0
- package/src/core/sandbox.js +54 -0
- package/src/core/session.js +46 -0
- package/src/core/spinner.js +60 -0
- package/src/core/stats.js +73 -0
- package/src/core/system-prompt.js +42 -0
- package/src/core/thinking.js +39 -0
- package/src/core/tool-help.js +117 -0
- package/src/core/tool-registry.js +82 -0
- package/src/core/wizard.js +55 -0
- package/src/core/workspace.js +96 -0
- package/src/index.js +150 -0
- package/src/mcp/activation.js +54 -0
- package/src/mcp/mcp-handler.js +92 -0
- package/src/mcp/message-sender.js +147 -0
- package/src/mcp/mqtt-client.js +160 -0
- package/src/mcp/wss-endpoint.js +133 -0
- package/src/plugins/manager.js +72 -0
- package/src/repl-commands.js +91 -0
- package/src/repl.js +115 -0
- package/src/tools/ai.js +128 -0
- package/src/tools/config.js +87 -0
- package/src/tools/diff.js +118 -0
- package/src/tools/env.js +58 -0
- package/src/tools/file.js +177 -0
- package/src/tools/git.js +166 -0
- package/src/tools/history.js +64 -0
- package/src/tools/network.js +87 -0
- package/src/tools/process.js +69 -0
- package/src/tools/project.js +152 -0
- package/src/tools/search.js +100 -0
- package/src/tools/shell.js +91 -0
- package/src/tools/snippet.js +95 -0
- package/src/tools/template.js +113 -0
- package/src/tools/watch.js +87 -0
- package/src/tui/renderer.js +119 -0
- package/src/tui/status-bar.js +34 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diff Tool — file comparison (1 MCP tool, 3 actions)
|
|
3
|
+
* Actions: compare, stats, patch
|
|
4
|
+
*/
|
|
5
|
+
import { readFile } from 'fs/promises';
|
|
6
|
+
import { resolve } from 'path';
|
|
7
|
+
import { homedir } from 'os';
|
|
8
|
+
import { log } from '../core/logger.js';
|
|
9
|
+
|
|
10
|
+
const HOME = homedir();
|
|
11
|
+
|
|
12
|
+
function safe(p) {
|
|
13
|
+
if (!p) throw new Error('path required');
|
|
14
|
+
return resolve(p.replace(/^~(?=$|[/\\])/g, HOME));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function diffLines(oldText, newText) {
|
|
18
|
+
const oldLines = oldText.split('\n');
|
|
19
|
+
const newLines = newText.split('\n');
|
|
20
|
+
const changes = [];
|
|
21
|
+
const maxLen = Math.max(oldLines.length, newLines.length);
|
|
22
|
+
for (let i = 0; i < maxLen; i++) {
|
|
23
|
+
if (oldLines[i] !== newLines[i]) {
|
|
24
|
+
changes.push({ line: i + 1, old: oldLines[i] || null, new: newLines[i] || null });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return changes;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const ACTIONS = {
|
|
31
|
+
compare: {
|
|
32
|
+
required: ['file1', 'file2'],
|
|
33
|
+
handler: async ({ file1, file2, context }) => {
|
|
34
|
+
const abs1 = safe(file1);
|
|
35
|
+
const abs2 = safe(file2);
|
|
36
|
+
const c1 = await readFile(abs1, 'utf-8');
|
|
37
|
+
const c2 = await readFile(abs2, 'utf-8');
|
|
38
|
+
const changes = diffLines(c1, c2);
|
|
39
|
+
const ctx = context || 3;
|
|
40
|
+
|
|
41
|
+
// Build hunks with context
|
|
42
|
+
const hunks = [];
|
|
43
|
+
for (const change of changes) {
|
|
44
|
+
const start = Math.max(0, change.line - ctx - 1);
|
|
45
|
+
const end = Math.min(Math.max(c1.split('\n').length, c2.split('\n').length), change.line + ctx);
|
|
46
|
+
const oldSlice = c1.split('\n').slice(start, end);
|
|
47
|
+
const newSlice = c2.split('\n').slice(start, end);
|
|
48
|
+
hunks.push({ line: change.line, old: change.old, new: change.new, context: { old: oldSlice, new: newSlice } });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return { file1: abs1, file2: abs2, totalChanges: changes.length, hunks: hunks.slice(0, 20) };
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
stats: {
|
|
56
|
+
required: ['file1', 'file2'],
|
|
57
|
+
handler: async ({ file1, file2 }) => {
|
|
58
|
+
const abs1 = safe(file1);
|
|
59
|
+
const abs2 = safe(file2);
|
|
60
|
+
const c1 = await readFile(abs1, 'utf-8');
|
|
61
|
+
const c2 = await readFile(abs2, 'utf-8');
|
|
62
|
+
const changes = diffLines(c1, c2);
|
|
63
|
+
const l1 = c1.split('\n').length;
|
|
64
|
+
const l2 = c2.split('\n').length;
|
|
65
|
+
return {
|
|
66
|
+
file1: abs1, file2: abs2,
|
|
67
|
+
lines1: l1, lines2: l2,
|
|
68
|
+
changes: changes.length,
|
|
69
|
+
identical: changes.length === 0,
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
patch: {
|
|
75
|
+
required: ['file1', 'file2'],
|
|
76
|
+
handler: async ({ file1, file2 }) => {
|
|
77
|
+
const abs1 = safe(file1);
|
|
78
|
+
const abs2 = safe(file2);
|
|
79
|
+
const c1 = await readFile(abs1, 'utf-8');
|
|
80
|
+
const c2 = await readFile(abs2, 'utf-8');
|
|
81
|
+
const changes = diffLines(c1, c2);
|
|
82
|
+
|
|
83
|
+
const patch = [`--- ${abs1}`, `+++ ${abs2}`];
|
|
84
|
+
for (const change of changes.slice(0, 50)) {
|
|
85
|
+
if (change.old !== null) patch.push(`-${change.old}`);
|
|
86
|
+
if (change.new !== null) patch.push(`+${change.new}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { patch: patch.join('\n'), changes: changes.length };
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const actionNames = Object.keys(ACTIONS);
|
|
95
|
+
|
|
96
|
+
export default {
|
|
97
|
+
name: 'diff',
|
|
98
|
+
description: 'File comparison: compare, stats, patch',
|
|
99
|
+
actions: actionNames.map(name => ({ name, required: ACTIONS[name].required })),
|
|
100
|
+
inputSchema: {
|
|
101
|
+
type: 'object',
|
|
102
|
+
properties: {
|
|
103
|
+
action: { type: 'string', enum: actionNames },
|
|
104
|
+
file1: { type: 'string', description: 'First file path' },
|
|
105
|
+
file2: { type: 'string', description: 'Second file path' },
|
|
106
|
+
context: { type: 'number', description: 'Context lines (default 3)' },
|
|
107
|
+
},
|
|
108
|
+
required: ['action'],
|
|
109
|
+
},
|
|
110
|
+
async handler({ action, ...params }) {
|
|
111
|
+
const a = ACTIONS[action];
|
|
112
|
+
if (!a) throw new Error(`Unknown action: ${action}`);
|
|
113
|
+
for (const f of a.required) {
|
|
114
|
+
if (params[f] === undefined) throw new Error(`Missing required: ${f}`);
|
|
115
|
+
}
|
|
116
|
+
return a.handler(params);
|
|
117
|
+
},
|
|
118
|
+
};
|
package/src/tools/env.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Env Tool — environment variable operations (1 MCP tool, 3 actions)
|
|
3
|
+
*/
|
|
4
|
+
const SENSITIVE = ['KEY', 'TOKEN', 'SECRET', 'PASSWORD', 'CREDENTIAL', 'PRIVATE'];
|
|
5
|
+
|
|
6
|
+
function isSensitive(name) {
|
|
7
|
+
return SENSITIVE.some(s => name.toUpperCase().includes(s));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function mask(name, value) {
|
|
11
|
+
if (isSensitive(name)) return '***MASKED***';
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const ACTIONS = {
|
|
16
|
+
async get({ name }) {
|
|
17
|
+
if (!name) throw new Error('env name required');
|
|
18
|
+
const value = process.env[name];
|
|
19
|
+
if (value === undefined) return { name, exists: false };
|
|
20
|
+
return { name, value: mask(name, value), exists: true };
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
async set({ name, value }) {
|
|
24
|
+
if (!name) throw new Error('env name required');
|
|
25
|
+
if (isSensitive(name)) throw new Error('Cannot set sensitive env var via tool');
|
|
26
|
+
process.env[name] = value || '';
|
|
27
|
+
return { name, set: true };
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
async list({ filter }) {
|
|
31
|
+
const vars = {};
|
|
32
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
33
|
+
if (filter && !k.includes(filter.toUpperCase()) && !k.includes(filter)) continue;
|
|
34
|
+
vars[k] = mask(k, v);
|
|
35
|
+
}
|
|
36
|
+
return { count: Object.keys(vars).length, vars };
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export default {
|
|
41
|
+
name: 'env',
|
|
42
|
+
description: 'Environment variables: get, set, list',
|
|
43
|
+
actions: Object.keys(ACTIONS).map(name => ({ name })),
|
|
44
|
+
inputSchema: {
|
|
45
|
+
type: 'object',
|
|
46
|
+
properties: {
|
|
47
|
+
action: { type: 'string', enum: Object.keys(ACTIONS) },
|
|
48
|
+
name: { type: 'string', description: 'Variable name' },
|
|
49
|
+
value: { type: 'string', description: 'Value to set' },
|
|
50
|
+
filter: { type: 'string', description: 'Filter pattern (for list)' },
|
|
51
|
+
},
|
|
52
|
+
required: ['action'],
|
|
53
|
+
},
|
|
54
|
+
async handler({ action, ...params }) {
|
|
55
|
+
if (!ACTIONS[action]) throw new Error(`Unknown action: ${action}. Use: ${Object.keys(ACTIONS).join(', ')}`);
|
|
56
|
+
return ACTIONS[action](params);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File Tool — consolidated file operations (1 MCP tool, 9 actions)
|
|
3
|
+
* Actions: read, write, edit, list, delete, mkdir, copy, move, exists
|
|
4
|
+
*/
|
|
5
|
+
import { readFile, writeFile, readdir, stat, mkdir, unlink, rename, copyFile } from 'fs/promises';
|
|
6
|
+
import { existsSync } from 'fs';
|
|
7
|
+
import { resolve, join, dirname } from 'path';
|
|
8
|
+
import { homedir } from 'os';
|
|
9
|
+
import { log } from '../core/logger.js';
|
|
10
|
+
|
|
11
|
+
const HOME = homedir();
|
|
12
|
+
|
|
13
|
+
function safe(p) {
|
|
14
|
+
if (!p) throw new Error('path is required');
|
|
15
|
+
const abs = resolve(p.replace(/^~(?=$|[/\\])/g, HOME));
|
|
16
|
+
const allowed = [HOME, process.cwd()];
|
|
17
|
+
if (!allowed.some(prefix => abs.startsWith(prefix))) throw new Error(`Access denied: ${abs}`);
|
|
18
|
+
return abs;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function statOrNull(p) {
|
|
22
|
+
try { return await stat(p); } catch { return null; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const ACTIONS = {
|
|
26
|
+
read: {
|
|
27
|
+
required: ['path'],
|
|
28
|
+
handler: async ({ path, offset, limit }) => {
|
|
29
|
+
const abs = safe(path);
|
|
30
|
+
const content = await readFile(abs, 'utf-8');
|
|
31
|
+
const start = offset || 0;
|
|
32
|
+
const end = limit ? start + limit : content.length;
|
|
33
|
+
return { path: abs, content: content.slice(start, end), totalSize: content.length };
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
write: {
|
|
38
|
+
required: ['path', 'content'],
|
|
39
|
+
handler: async ({ path, content }) => {
|
|
40
|
+
const abs = safe(path);
|
|
41
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
42
|
+
await writeFile(abs, content, 'utf-8');
|
|
43
|
+
log('file', `📝 Written: ${abs} (${content.length} chars)`);
|
|
44
|
+
return { path: abs, written: content.length };
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
edit: {
|
|
49
|
+
required: ['path', 'old_text', 'new_text'],
|
|
50
|
+
handler: async ({ path, old_text, new_text }) => {
|
|
51
|
+
const abs = safe(path);
|
|
52
|
+
let content = await readFile(abs, 'utf-8');
|
|
53
|
+
if (!content.includes(old_text)) throw new Error(`old_text not found in ${abs}`);
|
|
54
|
+
const count = content.split(old_text).length - 1;
|
|
55
|
+
content = content.replace(old_text, new_text);
|
|
56
|
+
await writeFile(abs, content, 'utf-8');
|
|
57
|
+
log('file', `✏️ Edited: ${abs} (${count} occurrence(s))`);
|
|
58
|
+
return { path: abs, edited: true, occurrences: count };
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
list: {
|
|
63
|
+
required: [],
|
|
64
|
+
handler: async ({ path, recursive }) => {
|
|
65
|
+
const dir = safe(path || HOME);
|
|
66
|
+
if (recursive) return { path: dir, items: await _listRecursive(dir, dir) };
|
|
67
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
68
|
+
const items = await Promise.all(entries.map(async e => {
|
|
69
|
+
const fp = join(dir, e.name);
|
|
70
|
+
const s = await statOrNull(fp);
|
|
71
|
+
return { name: e.name, type: e.isDirectory() ? 'dir' : 'file', size: s?.size || 0 };
|
|
72
|
+
}));
|
|
73
|
+
return { path: dir, count: items.length, items };
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
delete: {
|
|
78
|
+
required: ['path'],
|
|
79
|
+
handler: async ({ path }) => {
|
|
80
|
+
const abs = safe(path);
|
|
81
|
+
if (!existsSync(abs)) throw new Error(`Not found: ${abs}`);
|
|
82
|
+
await unlink(abs);
|
|
83
|
+
log('file', `🗑️ Deleted: ${abs}`);
|
|
84
|
+
return { path: abs, deleted: true };
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
mkdir: {
|
|
89
|
+
required: ['path'],
|
|
90
|
+
handler: async ({ path }) => {
|
|
91
|
+
const abs = safe(path);
|
|
92
|
+
await mkdir(abs, { recursive: true });
|
|
93
|
+
return { path: abs, created: true };
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
copy: {
|
|
98
|
+
required: ['from', 'to'],
|
|
99
|
+
handler: async ({ from, to }) => {
|
|
100
|
+
const absFrom = safe(from);
|
|
101
|
+
const absTo = safe(to);
|
|
102
|
+
await mkdir(dirname(absTo), { recursive: true });
|
|
103
|
+
await copyFile(absFrom, absTo);
|
|
104
|
+
return { from: absFrom, to: absTo, copied: true };
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
move: {
|
|
109
|
+
required: ['from', 'to'],
|
|
110
|
+
handler: async ({ from, to }) => {
|
|
111
|
+
const absFrom = safe(from);
|
|
112
|
+
const absTo = safe(to);
|
|
113
|
+
await mkdir(dirname(absTo), { recursive: true });
|
|
114
|
+
await rename(absFrom, absTo);
|
|
115
|
+
return { from: absFrom, to: absTo, moved: true };
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
exists: {
|
|
120
|
+
required: ['path'],
|
|
121
|
+
handler: async ({ path }) => {
|
|
122
|
+
const abs = safe(path);
|
|
123
|
+
if (!existsSync(abs)) return { exists: false, path: abs };
|
|
124
|
+
const s = await stat(abs);
|
|
125
|
+
return { exists: true, path: abs, type: s.isDirectory() ? 'dir' : 'file', size: s.size };
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
async function _listRecursive(base, dir) {
|
|
131
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
132
|
+
const items = [];
|
|
133
|
+
for (const e of entries) {
|
|
134
|
+
const fp = join(dir, e.name);
|
|
135
|
+
const rel = fp.slice(base.length + 1);
|
|
136
|
+
if (e.isDirectory()) {
|
|
137
|
+
items.push({ name: rel, type: 'dir' });
|
|
138
|
+
items.push(...(await _listRecursive(base, fp)));
|
|
139
|
+
} else {
|
|
140
|
+
const s = await statOrNull(fp);
|
|
141
|
+
items.push({ name: rel, type: 'file', size: s?.size || 0 });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return items;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const actionNames = Object.keys(ACTIONS);
|
|
148
|
+
|
|
149
|
+
export default {
|
|
150
|
+
name: 'file',
|
|
151
|
+
description: 'File operations: read, write, edit, list, delete, mkdir, copy, move, exists',
|
|
152
|
+
actions: actionNames.map(name => ({ name, required: ACTIONS[name].required })),
|
|
153
|
+
inputSchema: {
|
|
154
|
+
type: 'object',
|
|
155
|
+
properties: {
|
|
156
|
+
action: { type: 'string', enum: actionNames, description: 'Action to perform' },
|
|
157
|
+
path: { type: 'string', description: 'File or directory path' },
|
|
158
|
+
content: { type: 'string', description: 'Content to write' },
|
|
159
|
+
old_text: { type: 'string', description: 'Exact text to find (for edit)' },
|
|
160
|
+
new_text: { type: 'string', description: 'Replacement text (for edit)' },
|
|
161
|
+
from: { type: 'string', description: 'Source path (for copy/move)' },
|
|
162
|
+
to: { type: 'string', description: 'Destination path (for copy/move)' },
|
|
163
|
+
offset: { type: 'number', description: 'Read start offset (chars)' },
|
|
164
|
+
limit: { type: 'number', description: 'Read max chars' },
|
|
165
|
+
recursive: { type: 'boolean', description: 'Recursive list' },
|
|
166
|
+
},
|
|
167
|
+
required: ['action'],
|
|
168
|
+
},
|
|
169
|
+
async handler({ action, ...params }) {
|
|
170
|
+
const a = ACTIONS[action];
|
|
171
|
+
if (!a) throw new Error(`Unknown action: ${action}. Use: ${actionNames.join(', ')}`);
|
|
172
|
+
for (const field of a.required) {
|
|
173
|
+
if (params[field] === undefined) throw new Error(`Missing required field: ${field}`);
|
|
174
|
+
}
|
|
175
|
+
return a.handler(params);
|
|
176
|
+
},
|
|
177
|
+
};
|
package/src/tools/git.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git Tool — consolidated git operations (1 MCP tool, 10 actions)
|
|
3
|
+
* Actions: init, clone, status, diff, log, add, commit, push, pull, branch
|
|
4
|
+
*/
|
|
5
|
+
import { exec } from 'child_process';
|
|
6
|
+
import { log } from '../core/logger.js';
|
|
7
|
+
import { getConfig } from '../core/config.js';
|
|
8
|
+
|
|
9
|
+
function git(cmd, cwd) {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
exec(`git ${cmd}`, { cwd, timeout: 30000, maxBuffer: 512 * 1024 }, (err, stdout, stderr) => {
|
|
12
|
+
if (err?.killed) return reject(new Error('Git timed out'));
|
|
13
|
+
resolve({ stdout: stdout?.trim() || '', stderr: stderr?.trim() || '', code: err?.code ?? 0 });
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function cwd(params) { return params?.cwd || getConfig().agent?.workspace || process.cwd(); }
|
|
19
|
+
|
|
20
|
+
const ACTIONS = {
|
|
21
|
+
init: {
|
|
22
|
+
required: [],
|
|
23
|
+
handler: async (params) => {
|
|
24
|
+
const dir = cwd(params);
|
|
25
|
+
await git('init', dir);
|
|
26
|
+
return { initialized: true, path: dir };
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
clone: {
|
|
31
|
+
required: ['url'],
|
|
32
|
+
handler: async ({ url, path, branch }) => {
|
|
33
|
+
const cmd = `clone${branch ? ` -b ${branch}` : ''} "${url}"${path ? ` "${path}"` : ''}`;
|
|
34
|
+
await git(cmd);
|
|
35
|
+
return { cloned: true, url, path: path || url.split('/').pop()?.replace('.git', '') };
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
status: {
|
|
40
|
+
required: [],
|
|
41
|
+
handler: async (params) => {
|
|
42
|
+
const dir = cwd(params);
|
|
43
|
+
const r = await git('status --porcelain', dir);
|
|
44
|
+
const branch = await git('rev-parse --abbrev-ref HEAD', dir);
|
|
45
|
+
return { branch: branch.stdout, dirty: !!r.stdout, changes: r.stdout.split('\n').filter(Boolean) };
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
diff: {
|
|
50
|
+
required: [],
|
|
51
|
+
handler: async (params) => {
|
|
52
|
+
const dir = cwd(params);
|
|
53
|
+
const staged = params.staged ? ' --staged' : '';
|
|
54
|
+
const file = params.file ? ` -- "${params.file}"` : '';
|
|
55
|
+
const r = await git(`diff${staged} --stat${file}`, dir);
|
|
56
|
+
const detail = await git(`diff${staged}${file}`, dir);
|
|
57
|
+
return { summary: r.stdout || 'No changes', detail: detail.stdout?.slice(0, 5000) || '' };
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
log: {
|
|
62
|
+
required: [],
|
|
63
|
+
handler: async (params) => {
|
|
64
|
+
const dir = cwd(params);
|
|
65
|
+
const n = params.limit || 10;
|
|
66
|
+
const r = await git(`log --oneline -${n}`, dir);
|
|
67
|
+
return { commits: r.stdout.split('\n').filter(Boolean) };
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
add: {
|
|
72
|
+
required: [],
|
|
73
|
+
handler: async (params) => {
|
|
74
|
+
const dir = cwd(params);
|
|
75
|
+
const files = params.files || '.';
|
|
76
|
+
await git(`add ${files}`, dir);
|
|
77
|
+
return { added: files };
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
commit: {
|
|
82
|
+
required: ['message'],
|
|
83
|
+
handler: async (params) => {
|
|
84
|
+
const dir = cwd(params);
|
|
85
|
+
const msg = params.message.replace(/"/g, '\\"');
|
|
86
|
+
const r = await git(`commit -m "${msg}"`, dir);
|
|
87
|
+
if (r.code !== 0 && r.stderr.includes('nothing to commit')) return { committed: false, reason: 'nothing to commit' };
|
|
88
|
+
log('git', `📝 Committed: ${params.message}`);
|
|
89
|
+
return { committed: true, message: params.message, output: r.stdout.slice(-300) };
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
push: {
|
|
94
|
+
required: [],
|
|
95
|
+
handler: async (params) => {
|
|
96
|
+
const dir = cwd(params);
|
|
97
|
+
const remote = params.remote || '';
|
|
98
|
+
const branch = params.branch || '';
|
|
99
|
+
const r = await git(`push ${remote} ${branch}`.trim(), dir);
|
|
100
|
+
return { pushed: true, output: r.stdout.slice(-300) || r.stderr.slice(-300) };
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
pull: {
|
|
105
|
+
required: [],
|
|
106
|
+
handler: async (params) => {
|
|
107
|
+
const dir = cwd(params);
|
|
108
|
+
const remote = params.remote || '';
|
|
109
|
+
const branch = params.branch || '';
|
|
110
|
+
const r = await git(`pull ${remote} ${branch}`.trim(), dir);
|
|
111
|
+
return { pulled: true, output: r.stdout.slice(-300) };
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
branch: {
|
|
116
|
+
required: [],
|
|
117
|
+
handler: async (params) => {
|
|
118
|
+
const dir = cwd(params);
|
|
119
|
+
if (params.delete && params.name) {
|
|
120
|
+
await git(`branch -d ${params.name}`, dir);
|
|
121
|
+
return { deleted: params.name };
|
|
122
|
+
}
|
|
123
|
+
if (params.name) {
|
|
124
|
+
await git(`branch ${params.name}`, dir);
|
|
125
|
+
return { created: params.name };
|
|
126
|
+
}
|
|
127
|
+
const r = await git('branch -a', dir);
|
|
128
|
+
return { branches: r.stdout.split('\n').filter(Boolean) };
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const actionNames = Object.keys(ACTIONS);
|
|
134
|
+
|
|
135
|
+
export default {
|
|
136
|
+
name: 'git',
|
|
137
|
+
description: 'Git operations: init, clone, status, diff, log, add, commit, push, pull, branch',
|
|
138
|
+
actions: actionNames.map(name => ({ name, required: ACTIONS[name].required })),
|
|
139
|
+
inputSchema: {
|
|
140
|
+
type: 'object',
|
|
141
|
+
properties: {
|
|
142
|
+
action: { type: 'string', enum: actionNames },
|
|
143
|
+
cwd: { type: 'string', description: 'Working directory' },
|
|
144
|
+
url: { type: 'string', description: 'Repo URL (for clone)' },
|
|
145
|
+
path: { type: 'string', description: 'Target path' },
|
|
146
|
+
branch: { type: 'string', description: 'Branch name' },
|
|
147
|
+
message: { type: 'string', description: 'Commit message' },
|
|
148
|
+
files: { type: 'string', description: 'Files to add' },
|
|
149
|
+
remote: { type: 'string', description: 'Remote name' },
|
|
150
|
+
limit: { type: 'number', description: 'Log limit' },
|
|
151
|
+
staged: { type: 'boolean', description: 'Staged diff' },
|
|
152
|
+
file: { type: 'string', description: 'Specific file for diff' },
|
|
153
|
+
name: { type: 'string', description: 'Branch name' },
|
|
154
|
+
delete: { type: 'boolean', description: 'Delete branch' },
|
|
155
|
+
},
|
|
156
|
+
required: ['action'],
|
|
157
|
+
},
|
|
158
|
+
async handler({ action, ...params }) {
|
|
159
|
+
const a = ACTIONS[action];
|
|
160
|
+
if (!a) throw new Error(`Unknown action: ${action}. Use: ${actionNames.join(', ')}`);
|
|
161
|
+
for (const field of a.required) {
|
|
162
|
+
if (params[field] === undefined) throw new Error(`Missing required: ${field}`);
|
|
163
|
+
}
|
|
164
|
+
return a.handler(params);
|
|
165
|
+
},
|
|
166
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* History Tool — tool call history and session info (1 MCP tool, 3 actions)
|
|
3
|
+
* Actions: list, clear, stats
|
|
4
|
+
*/
|
|
5
|
+
import { log } from '../core/logger.js';
|
|
6
|
+
|
|
7
|
+
// Shared history store (injected via init)
|
|
8
|
+
let _history = [];
|
|
9
|
+
|
|
10
|
+
export function setHistoryStore(history) { _history = history; }
|
|
11
|
+
|
|
12
|
+
const ACTIONS = {
|
|
13
|
+
list: {
|
|
14
|
+
required: [],
|
|
15
|
+
handler: async ({ limit }) => {
|
|
16
|
+
const n = limit || 20;
|
|
17
|
+
const recent = _history.slice(-n);
|
|
18
|
+
return { count: recent.length, total: _history.length, calls: recent };
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
clear: {
|
|
23
|
+
required: [],
|
|
24
|
+
handler: async () => {
|
|
25
|
+
const count = _history.length;
|
|
26
|
+
_history.length = 0;
|
|
27
|
+
return { cleared: count };
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
stats: {
|
|
32
|
+
required: [],
|
|
33
|
+
handler: async () => {
|
|
34
|
+
const freq = {};
|
|
35
|
+
for (const h of _history) {
|
|
36
|
+
const key = `${h.tool}.${h.action || '-'}`;
|
|
37
|
+
freq[key] = (freq[key] || 0) + 1;
|
|
38
|
+
}
|
|
39
|
+
const top = Object.entries(freq).sort((a, b) => b[1] - a[1]).slice(0, 10);
|
|
40
|
+
return { total: _history.length, topTools: top.map(([name, count]) => ({ name, count })) };
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const actionNames = Object.keys(ACTIONS);
|
|
46
|
+
|
|
47
|
+
export default {
|
|
48
|
+
name: 'history',
|
|
49
|
+
description: 'Tool call history: list, clear, stats',
|
|
50
|
+
actions: actionNames.map(name => ({ name, required: ACTIONS[name].required })),
|
|
51
|
+
inputSchema: {
|
|
52
|
+
type: 'object',
|
|
53
|
+
properties: {
|
|
54
|
+
action: { type: 'string', enum: actionNames },
|
|
55
|
+
limit: { type: 'number', description: 'Number of recent calls to show' },
|
|
56
|
+
},
|
|
57
|
+
required: ['action'],
|
|
58
|
+
},
|
|
59
|
+
async handler({ action, ...params }) {
|
|
60
|
+
const a = ACTIONS[action];
|
|
61
|
+
if (!a) throw new Error(`Unknown action: ${action}`);
|
|
62
|
+
return a.handler(params);
|
|
63
|
+
},
|
|
64
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Network Tool — network operations (1 MCP tool, 3 actions)
|
|
3
|
+
*/
|
|
4
|
+
import https from 'https';
|
|
5
|
+
import http from 'http';
|
|
6
|
+
import { exec } from 'child_process';
|
|
7
|
+
import { createWriteStream } from 'fs';
|
|
8
|
+
import { pipeline } from 'stream/promises';
|
|
9
|
+
import { log } from '../core/logger.js';
|
|
10
|
+
|
|
11
|
+
function httpGet(url, maxChars = 5000) {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const client = url.startsWith('https') ? https : http;
|
|
14
|
+
client.get(url, { timeout: 15000, headers: { 'User-Agent': 'coding-agent/1.0' } }, (res) => {
|
|
15
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
16
|
+
return httpGet(res.headers.location, maxChars).then(resolve, reject);
|
|
17
|
+
}
|
|
18
|
+
let d = '';
|
|
19
|
+
res.on('data', c => { if (d.length < maxChars) d += c; });
|
|
20
|
+
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: d }));
|
|
21
|
+
}).on('error', reject);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const ACTIONS = {
|
|
26
|
+
async fetch({ url, method, body, headers }) {
|
|
27
|
+
if (!url) throw new Error('url required');
|
|
28
|
+
log('network', `🌐 ${method || 'GET'} ${url}`);
|
|
29
|
+
const result = await httpGet(url);
|
|
30
|
+
return { url, status: result.status, body: result.body.slice(0, 5000) };
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
async download({ url, path }) {
|
|
34
|
+
if (!url || !path) throw new Error('url and path required');
|
|
35
|
+
log('network', `📥 Downloading: ${url}`);
|
|
36
|
+
const client = url.startsWith('https') ? https : http;
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
client.get(url, { timeout: 30000, headers: { 'User-Agent': 'coding-agent/1.0' } }, async (res) => {
|
|
39
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
40
|
+
try { resolve(await ACTIONS.download({ url: res.headers.location, path })); } catch (e) { reject(e); }
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`));
|
|
44
|
+
const ws = createWriteStream(path);
|
|
45
|
+
try {
|
|
46
|
+
await pipeline(res, ws);
|
|
47
|
+
resolve({ downloaded: true, path, url });
|
|
48
|
+
} catch (e) { reject(e); }
|
|
49
|
+
}).on('error', reject);
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
async ping({ host, count }) {
|
|
54
|
+
if (!host) throw new Error('host required');
|
|
55
|
+
const n = count || 3;
|
|
56
|
+
const r = await new Promise((resolve) => {
|
|
57
|
+
exec(`ping -c ${n} "${host}"`, { timeout: 15000 }, (_, stdout, stderr) => {
|
|
58
|
+
resolve({ stdout: stdout?.trim() || '', stderr: stderr?.trim() || '' });
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
return { host, result: r.stdout || r.stderr };
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export default {
|
|
66
|
+
name: 'network',
|
|
67
|
+
description: 'Network operations: fetch (HTTP request), download (save file), ping',
|
|
68
|
+
actions: Object.keys(ACTIONS).map(name => ({ name })),
|
|
69
|
+
inputSchema: {
|
|
70
|
+
type: 'object',
|
|
71
|
+
properties: {
|
|
72
|
+
action: { type: 'string', enum: Object.keys(ACTIONS) },
|
|
73
|
+
url: { type: 'string', description: 'URL' },
|
|
74
|
+
method: { type: 'string', description: 'HTTP method' },
|
|
75
|
+
body: { type: 'string', description: 'Request body' },
|
|
76
|
+
headers: { type: 'object', description: 'Request headers' },
|
|
77
|
+
path: { type: 'string', description: 'Save path (for download)' },
|
|
78
|
+
host: { type: 'string', description: 'Host to ping' },
|
|
79
|
+
count: { type: 'number', description: 'Ping count' },
|
|
80
|
+
},
|
|
81
|
+
required: ['action'],
|
|
82
|
+
},
|
|
83
|
+
async handler({ action, ...params }) {
|
|
84
|
+
if (!ACTIONS[action]) throw new Error(`Unknown action: ${action}. Use: ${Object.keys(ACTIONS).join(', ')}`);
|
|
85
|
+
return ACTIONS[action](params);
|
|
86
|
+
},
|
|
87
|
+
};
|