macha-ai 0.1.0 → 0.1.1
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/ai/tools.js +68 -10
- package/dist/mcp/client.js +95 -24
- package/package.json +2 -2
package/dist/ai/tools.js
CHANGED
|
@@ -147,20 +147,68 @@ export const TOOL_DEFINITIONS = [
|
|
|
147
147
|
},
|
|
148
148
|
},
|
|
149
149
|
];
|
|
150
|
-
function
|
|
150
|
+
function safePathLexical(workingDir, filePath) {
|
|
151
151
|
const base = path.resolve(workingDir);
|
|
152
152
|
const resolved = path.resolve(base, filePath);
|
|
153
153
|
const rel = path.relative(base, resolved);
|
|
154
154
|
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
155
|
-
throw new Error(`Path traversal denied: ${filePath} escapes working directory`);
|
|
155
|
+
throw new Error(`Path traversal denied: "${filePath}" escapes working directory`);
|
|
156
156
|
}
|
|
157
157
|
return resolved;
|
|
158
158
|
}
|
|
159
|
+
async function safeExistingPath(workingDir, filePath) {
|
|
160
|
+
const lexical = safePathLexical(workingDir, filePath);
|
|
161
|
+
const realBase = await fs.realpath(path.resolve(workingDir));
|
|
162
|
+
let real;
|
|
163
|
+
try {
|
|
164
|
+
real = await fs.realpath(lexical);
|
|
165
|
+
}
|
|
166
|
+
catch (e) {
|
|
167
|
+
const err = e;
|
|
168
|
+
if (err.code === "ENOENT") {
|
|
169
|
+
throw new Error(`File not found: "${filePath}"`);
|
|
170
|
+
}
|
|
171
|
+
throw e;
|
|
172
|
+
}
|
|
173
|
+
const rel = path.relative(realBase, real);
|
|
174
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
175
|
+
throw new Error(`Symlink escape denied: "${filePath}" resolves outside working directory`);
|
|
176
|
+
}
|
|
177
|
+
return real;
|
|
178
|
+
}
|
|
179
|
+
async function safeNewPath(workingDir, filePath) {
|
|
180
|
+
const lexical = safePathLexical(workingDir, filePath);
|
|
181
|
+
const realBase = await fs.realpath(path.resolve(workingDir));
|
|
182
|
+
let ancestor = path.dirname(lexical);
|
|
183
|
+
while (true) {
|
|
184
|
+
try {
|
|
185
|
+
const realAncestor = await fs.realpath(ancestor);
|
|
186
|
+
const rel = path.relative(realBase, realAncestor);
|
|
187
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
188
|
+
throw new Error(`Symlink escape denied: parent of "${filePath}" resolves outside working directory`);
|
|
189
|
+
}
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
catch (e) {
|
|
193
|
+
const err = e;
|
|
194
|
+
if (err.code === "ENOENT") {
|
|
195
|
+
const parent = path.dirname(ancestor);
|
|
196
|
+
if (parent === ancestor)
|
|
197
|
+
break;
|
|
198
|
+
ancestor = parent;
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
throw e;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return lexical;
|
|
206
|
+
}
|
|
159
207
|
export async function executeTool(name, args, workingDir) {
|
|
160
208
|
try {
|
|
161
209
|
switch (name) {
|
|
162
210
|
case "read_file": {
|
|
163
|
-
const filePath =
|
|
211
|
+
const filePath = await safeExistingPath(workingDir, args.path);
|
|
164
212
|
const content = await fs.readFile(filePath, "utf-8");
|
|
165
213
|
const lines = content.split("\n");
|
|
166
214
|
const preview = lines.length > 500
|
|
@@ -170,7 +218,7 @@ export async function executeTool(name, args, workingDir) {
|
|
|
170
218
|
return { success: true, output: preview };
|
|
171
219
|
}
|
|
172
220
|
case "write_file": {
|
|
173
|
-
const filePath =
|
|
221
|
+
const filePath = await safeNewPath(workingDir, args.path);
|
|
174
222
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
175
223
|
await fs.writeFile(filePath, args.content, "utf-8");
|
|
176
224
|
return {
|
|
@@ -180,7 +228,7 @@ export async function executeTool(name, args, workingDir) {
|
|
|
180
228
|
}
|
|
181
229
|
case "list_files": {
|
|
182
230
|
const dirPath = args.path
|
|
183
|
-
?
|
|
231
|
+
? await safeExistingPath(workingDir, args.path)
|
|
184
232
|
: workingDir;
|
|
185
233
|
const maxDepth = Math.min(args.depth || 2, 5);
|
|
186
234
|
const patterns = Array.from({ length: maxDepth }, (_, i) => `${"*/".repeat(i + 1)}*`);
|
|
@@ -237,7 +285,11 @@ export async function executeTool(name, args, workingDir) {
|
|
|
237
285
|
const globPat = args.glob || "**/*";
|
|
238
286
|
const caseSensitive = args.case_sensitive || false;
|
|
239
287
|
if (globPat.includes("..")) {
|
|
240
|
-
return {
|
|
288
|
+
return {
|
|
289
|
+
success: false,
|
|
290
|
+
output: "",
|
|
291
|
+
error: "Glob pattern must not contain '..'",
|
|
292
|
+
};
|
|
241
293
|
}
|
|
242
294
|
const files = await fg(globPat, {
|
|
243
295
|
cwd: workingDir,
|
|
@@ -252,10 +304,16 @@ export async function executeTool(name, args, workingDir) {
|
|
|
252
304
|
});
|
|
253
305
|
const results = [];
|
|
254
306
|
const regex = new RegExp(query, caseSensitive ? "g" : "gi");
|
|
307
|
+
const realBase = await fs.realpath(path.resolve(workingDir));
|
|
255
308
|
for (const file of files.slice(0, 200)) {
|
|
256
309
|
let resolvedFilePath;
|
|
257
310
|
try {
|
|
258
|
-
|
|
311
|
+
const lexical = safePathLexical(workingDir, file);
|
|
312
|
+
const real = await fs.realpath(lexical);
|
|
313
|
+
const rel = path.relative(realBase, real);
|
|
314
|
+
if (rel.startsWith("..") || path.isAbsolute(rel))
|
|
315
|
+
continue;
|
|
316
|
+
resolvedFilePath = real;
|
|
259
317
|
}
|
|
260
318
|
catch {
|
|
261
319
|
continue;
|
|
@@ -287,13 +345,13 @@ export async function executeTool(name, args, workingDir) {
|
|
|
287
345
|
};
|
|
288
346
|
}
|
|
289
347
|
case "delete_file": {
|
|
290
|
-
const filePath =
|
|
348
|
+
const filePath = await safeExistingPath(workingDir, args.path);
|
|
291
349
|
await fs.unlink(filePath);
|
|
292
350
|
return { success: true, output: `Deleted ${args.path}` };
|
|
293
351
|
}
|
|
294
352
|
case "move_file": {
|
|
295
|
-
const from =
|
|
296
|
-
const to =
|
|
353
|
+
const from = await safeExistingPath(workingDir, args.from);
|
|
354
|
+
const to = await safeNewPath(workingDir, args.to);
|
|
297
355
|
await fs.mkdir(path.dirname(to), { recursive: true });
|
|
298
356
|
await fs.rename(from, to);
|
|
299
357
|
return { success: true, output: `Moved ${args.from} → ${args.to}` };
|
package/dist/mcp/client.js
CHANGED
|
@@ -1,4 +1,43 @@
|
|
|
1
1
|
import { execa } from "execa";
|
|
2
|
+
function writeMessage(state, msg) {
|
|
3
|
+
const body = JSON.stringify(msg);
|
|
4
|
+
const header = `Content-Length: ${Buffer.byteLength(body, "utf-8")}\r\n\r\n`;
|
|
5
|
+
state.process.stdin?.write(header + body);
|
|
6
|
+
}
|
|
7
|
+
function parseFrames(state) {
|
|
8
|
+
const messages = [];
|
|
9
|
+
while (true) {
|
|
10
|
+
const buf = state.buffer;
|
|
11
|
+
const sep = findCRLFCRLF(buf);
|
|
12
|
+
if (sep === -1)
|
|
13
|
+
break;
|
|
14
|
+
const headerStr = buf.slice(0, sep).toString("utf-8");
|
|
15
|
+
const contentLengthMatch = headerStr.match(/Content-Length:\s*(\d+)/i);
|
|
16
|
+
if (!contentLengthMatch) {
|
|
17
|
+
state.buffer = buf.slice(sep + 4);
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
const bodyLen = parseInt(contentLengthMatch[1], 10);
|
|
21
|
+
const bodyStart = sep + 4;
|
|
22
|
+
const bodyEnd = bodyStart + bodyLen;
|
|
23
|
+
if (buf.length < bodyEnd)
|
|
24
|
+
break;
|
|
25
|
+
messages.push(buf.slice(bodyStart, bodyEnd).toString("utf-8"));
|
|
26
|
+
state.buffer = buf.slice(bodyEnd);
|
|
27
|
+
}
|
|
28
|
+
return messages;
|
|
29
|
+
}
|
|
30
|
+
function findCRLFCRLF(buf) {
|
|
31
|
+
for (let i = 0; i <= buf.length - 4; i++) {
|
|
32
|
+
if (buf[i] === 0x0d &&
|
|
33
|
+
buf[i + 1] === 0x0a &&
|
|
34
|
+
buf[i + 2] === 0x0d &&
|
|
35
|
+
buf[i + 3] === 0x0a) {
|
|
36
|
+
return i;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return -1;
|
|
40
|
+
}
|
|
2
41
|
export class MCPClient {
|
|
3
42
|
servers = new Map();
|
|
4
43
|
msgId = 1;
|
|
@@ -9,39 +48,62 @@ export class MCPClient {
|
|
|
9
48
|
stdio: ["pipe", "pipe", "pipe"],
|
|
10
49
|
all: false,
|
|
11
50
|
});
|
|
12
|
-
const state = {
|
|
51
|
+
const state = {
|
|
52
|
+
process: proc,
|
|
53
|
+
buffer: Buffer.alloc(0),
|
|
54
|
+
pending: new Map(),
|
|
55
|
+
};
|
|
13
56
|
this.servers.set(server.id, state);
|
|
14
57
|
proc.stdout?.on("data", (chunk) => {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
if (!line.trim())
|
|
20
|
-
continue;
|
|
58
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
59
|
+
state.buffer = Buffer.concat([state.buffer, bytes]);
|
|
60
|
+
for (const raw of parseFrames(state)) {
|
|
61
|
+
let msg;
|
|
21
62
|
try {
|
|
22
|
-
|
|
23
|
-
if (msg.id !== undefined) {
|
|
24
|
-
const pending = state.pending.get(msg.id);
|
|
25
|
-
if (pending) {
|
|
26
|
-
state.pending.delete(msg.id);
|
|
27
|
-
if (msg.error) {
|
|
28
|
-
pending.reject(new Error(msg.error.message));
|
|
29
|
-
}
|
|
30
|
-
else {
|
|
31
|
-
pending.resolve(msg.result);
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
}
|
|
63
|
+
msg = JSON.parse(raw);
|
|
35
64
|
}
|
|
36
65
|
catch {
|
|
66
|
+
continue;
|
|
37
67
|
}
|
|
68
|
+
if (msg.id !== undefined) {
|
|
69
|
+
const pending = state.pending.get(msg.id);
|
|
70
|
+
if (pending) {
|
|
71
|
+
state.pending.delete(msg.id);
|
|
72
|
+
if (msg.error) {
|
|
73
|
+
pending.reject(new Error(msg.error.message));
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
pending.resolve(msg.result);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
const rejectAll = (reason) => {
|
|
83
|
+
for (const [id, pending] of state.pending) {
|
|
84
|
+
state.pending.delete(id);
|
|
85
|
+
pending.reject(new Error(reason));
|
|
38
86
|
}
|
|
87
|
+
};
|
|
88
|
+
proc.on("exit", (code) => {
|
|
89
|
+
rejectAll(`MCP server exited with code ${code ?? "unknown"}`);
|
|
90
|
+
this.servers.delete(server.id);
|
|
91
|
+
});
|
|
92
|
+
proc.on("error", (err) => {
|
|
93
|
+
rejectAll(`MCP server process error: ${err.message}`);
|
|
94
|
+
this.servers.delete(server.id);
|
|
39
95
|
});
|
|
40
96
|
await this.send(server.id, "initialize", {
|
|
41
97
|
protocolVersion: "2024-11-05",
|
|
42
98
|
capabilities: { tools: {}, resources: {} },
|
|
43
99
|
clientInfo: { name: "macha", version: "0.1.0" },
|
|
44
100
|
});
|
|
101
|
+
const initNotification = {
|
|
102
|
+
jsonrpc: "2.0",
|
|
103
|
+
method: "notifications/initialized",
|
|
104
|
+
params: {},
|
|
105
|
+
};
|
|
106
|
+
writeMessage(state, initNotification);
|
|
45
107
|
}
|
|
46
108
|
async send(serverId, method, params) {
|
|
47
109
|
const state = this.servers.get(serverId);
|
|
@@ -50,14 +112,23 @@ export class MCPClient {
|
|
|
50
112
|
const id = this.msgId++;
|
|
51
113
|
const msg = { jsonrpc: "2.0", id, method, params };
|
|
52
114
|
return new Promise((resolve, reject) => {
|
|
53
|
-
|
|
54
|
-
state.process.stdin?.write(JSON.stringify(msg) + "\n");
|
|
55
|
-
setTimeout(() => {
|
|
115
|
+
const timer = setTimeout(() => {
|
|
56
116
|
if (state.pending.has(id)) {
|
|
57
117
|
state.pending.delete(id);
|
|
58
|
-
reject(new Error(
|
|
118
|
+
reject(new Error(`MCP request timed out (method: ${method})`));
|
|
59
119
|
}
|
|
60
120
|
}, 10000);
|
|
121
|
+
state.pending.set(id, {
|
|
122
|
+
resolve: (v) => {
|
|
123
|
+
clearTimeout(timer);
|
|
124
|
+
resolve(v);
|
|
125
|
+
},
|
|
126
|
+
reject: (e) => {
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
reject(e);
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
writeMessage(state, msg);
|
|
61
132
|
});
|
|
62
133
|
}
|
|
63
134
|
async listTools(serverId, serverName) {
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "macha-ai",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "A beautiful terminal AI coding assistant — like Claude Code and OpenCode. Supports any OpenAI-compatible provider, MCP servers, file tools, and command execution.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"macha": "
|
|
7
|
+
"macha": "bin/macha.js"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"bin/",
|