macha-ai 0.1.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/bin/macha.js +2 -0
- package/dist/App.js +134 -0
- package/dist/ai/client.js +150 -0
- package/dist/ai/tools.js +313 -0
- package/dist/components/FileTree.js +105 -0
- package/dist/components/Header.js +6 -0
- package/dist/components/Input.js +50 -0
- package/dist/components/MessageList.js +43 -0
- package/dist/components/Settings.js +102 -0
- package/dist/components/ToolCall.js +60 -0
- package/dist/config/index.js +98 -0
- package/dist/index.js +114 -0
- package/dist/mcp/client.js +107 -0
- package/package.json +61 -0
package/bin/macha.js
ADDED
package/dist/App.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useCallback, useRef } from "react";
|
|
3
|
+
import { Box, Text, useApp, useInput, useStdout } from "ink";
|
|
4
|
+
import { Header } from "./components/Header.js";
|
|
5
|
+
import { MessageList } from "./components/MessageList.js";
|
|
6
|
+
import { Input } from "./components/Input.js";
|
|
7
|
+
import { FileTree } from "./components/FileTree.js";
|
|
8
|
+
import { Settings } from "./components/Settings.js";
|
|
9
|
+
import { streamChat, generateId, } from "./ai/client.js";
|
|
10
|
+
import { getConfig } from "./config/index.js";
|
|
11
|
+
export function App({ workingDir, initialPrompt }) {
|
|
12
|
+
const { exit } = useApp();
|
|
13
|
+
const { stdout } = useStdout();
|
|
14
|
+
const [screen, setScreen] = useState("chat");
|
|
15
|
+
const [messages, setMessages] = useState([]);
|
|
16
|
+
const [streamingText, setStreamingText] = useState("");
|
|
17
|
+
const [isStreaming, setIsStreaming] = useState(false);
|
|
18
|
+
const [config, setConfig] = useState(getConfig());
|
|
19
|
+
const abortRef = useRef(false);
|
|
20
|
+
useInput((input, key) => {
|
|
21
|
+
if (key.ctrl && input === "c") {
|
|
22
|
+
if (isStreaming) {
|
|
23
|
+
abortRef.current = true;
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
exit();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (key.tab) {
|
|
30
|
+
setScreen((s) => (s === "chat" ? "settings" : "chat"));
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
const handleSend = useCallback(async (text) => {
|
|
34
|
+
if (isStreaming)
|
|
35
|
+
return;
|
|
36
|
+
const userMessage = {
|
|
37
|
+
id: generateId(),
|
|
38
|
+
role: "user",
|
|
39
|
+
content: text,
|
|
40
|
+
timestamp: new Date(),
|
|
41
|
+
};
|
|
42
|
+
setMessages((prev) => [...prev, userMessage]);
|
|
43
|
+
setIsStreaming(true);
|
|
44
|
+
setStreamingText("");
|
|
45
|
+
abortRef.current = false;
|
|
46
|
+
const assistantId = generateId();
|
|
47
|
+
let fullText = "";
|
|
48
|
+
const toolCalls = [];
|
|
49
|
+
const pendingToolCalls = new Map();
|
|
50
|
+
try {
|
|
51
|
+
const allMessages = [...messages, userMessage];
|
|
52
|
+
const stream = streamChat(allMessages, workingDir);
|
|
53
|
+
for await (const event of stream) {
|
|
54
|
+
if (abortRef.current)
|
|
55
|
+
break;
|
|
56
|
+
if (event.type === "text_delta") {
|
|
57
|
+
fullText += event.delta || "";
|
|
58
|
+
setStreamingText(fullText);
|
|
59
|
+
}
|
|
60
|
+
else if (event.type === "tool_call" && event.toolCall) {
|
|
61
|
+
const tc = event.toolCall;
|
|
62
|
+
if (!tc.result) {
|
|
63
|
+
pendingToolCalls.set(tc.id, { ...tc });
|
|
64
|
+
setStreamingText(fullText);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
const updated = { ...tc };
|
|
68
|
+
pendingToolCalls.delete(tc.id);
|
|
69
|
+
const idx = toolCalls.findIndex((t) => t.id === tc.id);
|
|
70
|
+
if (idx >= 0) {
|
|
71
|
+
toolCalls[idx] = updated;
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
toolCalls.push(updated);
|
|
75
|
+
}
|
|
76
|
+
setMessages((prev) => {
|
|
77
|
+
const existing = prev.find((m) => m.id === assistantId);
|
|
78
|
+
if (existing) {
|
|
79
|
+
return prev.map((m) => m.id === assistantId
|
|
80
|
+
? { ...m, content: fullText, toolCalls: [...toolCalls] }
|
|
81
|
+
: m);
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
const assistantMsg = {
|
|
85
|
+
id: assistantId,
|
|
86
|
+
role: "assistant",
|
|
87
|
+
content: fullText,
|
|
88
|
+
toolCalls: [...toolCalls],
|
|
89
|
+
timestamp: new Date(),
|
|
90
|
+
};
|
|
91
|
+
return [...prev, assistantMsg];
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
else if (event.type === "done" || event.type === "error") {
|
|
97
|
+
if (event.type === "error") {
|
|
98
|
+
fullText += `\n\nError: ${event.error}`;
|
|
99
|
+
}
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
const e = err;
|
|
106
|
+
fullText += `\n\nError: ${e.message || String(err)}`;
|
|
107
|
+
}
|
|
108
|
+
setMessages((prev) => {
|
|
109
|
+
const existing = prev.find((m) => m.id === assistantId);
|
|
110
|
+
const finalMsg = {
|
|
111
|
+
id: assistantId,
|
|
112
|
+
role: "assistant",
|
|
113
|
+
content: fullText || "(no response)",
|
|
114
|
+
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
115
|
+
timestamp: new Date(),
|
|
116
|
+
};
|
|
117
|
+
if (existing) {
|
|
118
|
+
return prev.map((m) => (m.id === assistantId ? finalMsg : m));
|
|
119
|
+
}
|
|
120
|
+
return [...prev, finalMsg];
|
|
121
|
+
});
|
|
122
|
+
setStreamingText("");
|
|
123
|
+
setIsStreaming(false);
|
|
124
|
+
}, [messages, workingDir, isStreaming]);
|
|
125
|
+
const sentInitial = useRef(false);
|
|
126
|
+
if (initialPrompt && !sentInitial.current) {
|
|
127
|
+
sentInitial.current = true;
|
|
128
|
+
setTimeout(() => handleSend(initialPrompt), 100);
|
|
129
|
+
}
|
|
130
|
+
const termWidth = stdout.columns || 120;
|
|
131
|
+
const sidebarWidth = Math.min(30, Math.floor(termWidth * 0.22));
|
|
132
|
+
const showSidebar = config.showFileTree && termWidth > 90;
|
|
133
|
+
return (_jsxs(Box, { flexDirection: "column", height: process.stdout.rows || 40, children: [_jsx(Header, { config: config, workingDir: workingDir, mcpCount: config.mcpServers.filter((s) => s.enabled).length, screen: screen, isStreaming: isStreaming }), _jsxs(Box, { flexGrow: 1, flexDirection: "row", gap: 0, children: [showSidebar && screen === "chat" && (_jsx(Box, { flexDirection: "column", width: sidebarWidth, borderStyle: "single", borderColor: "gray", paddingX: 1, marginRight: 0, children: _jsx(FileTree, { workingDir: workingDir }) })), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: screen === "chat" ? (_jsxs(_Fragment, { children: [_jsx(Box, { flexDirection: "column", flexGrow: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, overflowY: "hidden", children: _jsx(MessageList, { messages: messages, streamingText: streamingText, isStreaming: isStreaming }) }), _jsx(Input, { onSubmit: handleSend, onTabPress: () => setScreen("settings"), onClearChat: () => setMessages([]), isStreaming: isStreaming })] })) : (_jsx(Settings, { onClose: () => setScreen("chat"), onConfigChange: () => setConfig(getConfig()) })) })] }), _jsxs(Box, { paddingX: 1, justifyContent: "space-between", children: [_jsxs(Box, { gap: 2, children: [_jsxs(Text, { color: "gray", dimColor: true, children: ["Ctrl+C: ", isStreaming ? "stop" : "quit"] }), _jsx(Text, { color: "gray", dimColor: true, children: "Tab: settings" }), _jsx(Text, { color: "gray", dimColor: true, children: "Ctrl+L: clear chat" }), _jsx(Text, { color: "gray", dimColor: true, children: "\u2191\u2193: history" })] }), _jsx(Text, { color: "gray", dimColor: true, children: "macha v0.1.0" })] })] }));
|
|
134
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import OpenAI from "openai";
|
|
2
|
+
import { getConfig } from "../config/index.js";
|
|
3
|
+
import { TOOL_DEFINITIONS, executeTool } from "./tools.js";
|
|
4
|
+
function createClient() {
|
|
5
|
+
const config = getConfig();
|
|
6
|
+
return new OpenAI({
|
|
7
|
+
apiKey: config.apiKey || "no-key",
|
|
8
|
+
baseURL: config.baseURL,
|
|
9
|
+
dangerouslyAllowBrowser: false,
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
function buildApiMessages(systemPrompt, messages) {
|
|
13
|
+
const result = [
|
|
14
|
+
{ role: "system", content: systemPrompt },
|
|
15
|
+
];
|
|
16
|
+
for (const msg of messages) {
|
|
17
|
+
if (msg.role === "user") {
|
|
18
|
+
result.push({ role: "user", content: msg.content });
|
|
19
|
+
}
|
|
20
|
+
else if (msg.role === "assistant") {
|
|
21
|
+
if (msg.toolCalls && msg.toolCalls.length > 0) {
|
|
22
|
+
result.push({
|
|
23
|
+
role: "assistant",
|
|
24
|
+
content: msg.content || null,
|
|
25
|
+
tool_calls: msg.toolCalls.map((tc) => ({
|
|
26
|
+
id: tc.id,
|
|
27
|
+
type: "function",
|
|
28
|
+
function: { name: tc.name, arguments: tc.args },
|
|
29
|
+
})),
|
|
30
|
+
});
|
|
31
|
+
for (const tc of msg.toolCalls) {
|
|
32
|
+
if (tc.result) {
|
|
33
|
+
const toolMsg = {
|
|
34
|
+
role: "tool",
|
|
35
|
+
tool_call_id: tc.id,
|
|
36
|
+
content: tc.result.error
|
|
37
|
+
? `Error: ${tc.result.error}`
|
|
38
|
+
: tc.result.output || "(no output)",
|
|
39
|
+
};
|
|
40
|
+
result.push(toolMsg);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
result.push({ role: "assistant", content: msg.content });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
export async function* streamChat(messages, workingDir) {
|
|
52
|
+
const config = getConfig();
|
|
53
|
+
const client = createClient();
|
|
54
|
+
const MAX_TOOL_ROUNDS = 10;
|
|
55
|
+
const apiMessages = buildApiMessages(config.systemPrompt, messages);
|
|
56
|
+
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
|
57
|
+
try {
|
|
58
|
+
const stream = await client.chat.completions.create({
|
|
59
|
+
model: config.model,
|
|
60
|
+
messages: apiMessages,
|
|
61
|
+
tools: TOOL_DEFINITIONS,
|
|
62
|
+
tool_choice: "auto",
|
|
63
|
+
max_tokens: config.maxTokens,
|
|
64
|
+
temperature: config.temperature,
|
|
65
|
+
stream: true,
|
|
66
|
+
});
|
|
67
|
+
let currentText = "";
|
|
68
|
+
const pendingToolCalls = {};
|
|
69
|
+
let finishReason = null;
|
|
70
|
+
for await (const chunk of stream) {
|
|
71
|
+
const choice = chunk.choices[0];
|
|
72
|
+
if (!choice)
|
|
73
|
+
continue;
|
|
74
|
+
const delta = choice.delta;
|
|
75
|
+
if (delta?.content) {
|
|
76
|
+
currentText += delta.content;
|
|
77
|
+
yield { type: "text_delta", delta: delta.content };
|
|
78
|
+
}
|
|
79
|
+
if (delta?.tool_calls) {
|
|
80
|
+
for (const tc of delta.tool_calls) {
|
|
81
|
+
const idx = String(tc.index);
|
|
82
|
+
if (!pendingToolCalls[idx]) {
|
|
83
|
+
pendingToolCalls[idx] = {
|
|
84
|
+
id: tc.id || `call_${idx}_${round}`,
|
|
85
|
+
name: tc.function?.name || "",
|
|
86
|
+
args: "",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
if (tc.id)
|
|
90
|
+
pendingToolCalls[idx].id = tc.id;
|
|
91
|
+
if (tc.function?.name)
|
|
92
|
+
pendingToolCalls[idx].name = tc.function.name;
|
|
93
|
+
if (tc.function?.arguments)
|
|
94
|
+
pendingToolCalls[idx].args += tc.function.arguments;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (choice.finish_reason) {
|
|
98
|
+
finishReason = choice.finish_reason;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (finishReason !== "tool_calls" || Object.keys(pendingToolCalls).length === 0) {
|
|
102
|
+
yield { type: "done" };
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
apiMessages.push({
|
|
106
|
+
role: "assistant",
|
|
107
|
+
content: currentText || null,
|
|
108
|
+
tool_calls: Object.values(pendingToolCalls).map((tc) => ({
|
|
109
|
+
id: tc.id,
|
|
110
|
+
type: "function",
|
|
111
|
+
function: { name: tc.name, arguments: tc.args },
|
|
112
|
+
})),
|
|
113
|
+
});
|
|
114
|
+
const toolResultMessages = [];
|
|
115
|
+
for (const tc of Object.values(pendingToolCalls)) {
|
|
116
|
+
let parsedArgs = {};
|
|
117
|
+
try {
|
|
118
|
+
parsedArgs = JSON.parse(tc.args);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
parsedArgs = {};
|
|
122
|
+
}
|
|
123
|
+
const startEvent = { type: "tool_call", id: tc.id, name: tc.name, args: tc.args };
|
|
124
|
+
yield { type: "tool_call", toolCall: startEvent };
|
|
125
|
+
const result = await executeTool(tc.name, parsedArgs, workingDir);
|
|
126
|
+
yield { type: "tool_call", toolCall: { ...startEvent, result } };
|
|
127
|
+
toolResultMessages.push({
|
|
128
|
+
role: "tool",
|
|
129
|
+
tool_call_id: tc.id,
|
|
130
|
+
content: result.error
|
|
131
|
+
? `Error: ${result.error}`
|
|
132
|
+
: result.output || "(no output)",
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
apiMessages.push(...toolResultMessages);
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
const e = err;
|
|
139
|
+
yield { type: "error", error: e.message || String(err) };
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
yield {
|
|
144
|
+
type: "error",
|
|
145
|
+
error: "Reached maximum tool-call iterations without a final response.",
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
export function generateId() {
|
|
149
|
+
return Math.random().toString(36).slice(2, 11);
|
|
150
|
+
}
|
package/dist/ai/tools.js
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { execa } from "execa";
|
|
4
|
+
import fg from "fast-glob";
|
|
5
|
+
export const TOOL_DEFINITIONS = [
|
|
6
|
+
{
|
|
7
|
+
type: "function",
|
|
8
|
+
function: {
|
|
9
|
+
name: "read_file",
|
|
10
|
+
description: "Read the contents of a file in the working directory. Use to inspect code, configs, or any text file.",
|
|
11
|
+
parameters: {
|
|
12
|
+
type: "object",
|
|
13
|
+
properties: {
|
|
14
|
+
path: {
|
|
15
|
+
type: "string",
|
|
16
|
+
description: "Relative path to the file from the working directory",
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
required: ["path"],
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
type: "function",
|
|
25
|
+
function: {
|
|
26
|
+
name: "write_file",
|
|
27
|
+
description: "Create or overwrite a file with the given content. Creates parent directories if needed.",
|
|
28
|
+
parameters: {
|
|
29
|
+
type: "object",
|
|
30
|
+
properties: {
|
|
31
|
+
path: {
|
|
32
|
+
type: "string",
|
|
33
|
+
description: "Relative path to the file from the working directory",
|
|
34
|
+
},
|
|
35
|
+
content: {
|
|
36
|
+
type: "string",
|
|
37
|
+
description: "Full content to write to the file",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
required: ["path", "content"],
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
type: "function",
|
|
46
|
+
function: {
|
|
47
|
+
name: "list_files",
|
|
48
|
+
description: "List files and directories in a path. Shows the file tree of the project.",
|
|
49
|
+
parameters: {
|
|
50
|
+
type: "object",
|
|
51
|
+
properties: {
|
|
52
|
+
path: {
|
|
53
|
+
type: "string",
|
|
54
|
+
description: "Relative path to list (default: '.' for working directory)",
|
|
55
|
+
},
|
|
56
|
+
depth: {
|
|
57
|
+
type: "number",
|
|
58
|
+
description: "How deep to recurse (default: 2, max: 5)",
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
required: [],
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
type: "function",
|
|
67
|
+
function: {
|
|
68
|
+
name: "execute_command",
|
|
69
|
+
description: "Execute a shell command in the working directory. Use for running scripts, installing packages, running tests, etc.",
|
|
70
|
+
parameters: {
|
|
71
|
+
type: "object",
|
|
72
|
+
properties: {
|
|
73
|
+
command: {
|
|
74
|
+
type: "string",
|
|
75
|
+
description: "Shell command to execute",
|
|
76
|
+
},
|
|
77
|
+
timeout: {
|
|
78
|
+
type: "number",
|
|
79
|
+
description: "Timeout in milliseconds (default: 30000)",
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
required: ["command"],
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
type: "function",
|
|
88
|
+
function: {
|
|
89
|
+
name: "search_files",
|
|
90
|
+
description: "Search for text content across files in the project. Returns file paths and matching line numbers.",
|
|
91
|
+
parameters: {
|
|
92
|
+
type: "object",
|
|
93
|
+
properties: {
|
|
94
|
+
query: {
|
|
95
|
+
type: "string",
|
|
96
|
+
description: "Text or regex pattern to search for",
|
|
97
|
+
},
|
|
98
|
+
glob: {
|
|
99
|
+
type: "string",
|
|
100
|
+
description: "Glob pattern to filter files (e.g. '**/*.ts', '**/*.py'). Default: all files",
|
|
101
|
+
},
|
|
102
|
+
case_sensitive: {
|
|
103
|
+
type: "boolean",
|
|
104
|
+
description: "Whether search is case sensitive (default: false)",
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
required: ["query"],
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
type: "function",
|
|
113
|
+
function: {
|
|
114
|
+
name: "delete_file",
|
|
115
|
+
description: "Delete a file from the working directory.",
|
|
116
|
+
parameters: {
|
|
117
|
+
type: "object",
|
|
118
|
+
properties: {
|
|
119
|
+
path: {
|
|
120
|
+
type: "string",
|
|
121
|
+
description: "Relative path to the file to delete",
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
required: ["path"],
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
type: "function",
|
|
130
|
+
function: {
|
|
131
|
+
name: "move_file",
|
|
132
|
+
description: "Move or rename a file within the working directory.",
|
|
133
|
+
parameters: {
|
|
134
|
+
type: "object",
|
|
135
|
+
properties: {
|
|
136
|
+
from: {
|
|
137
|
+
type: "string",
|
|
138
|
+
description: "Source file path (relative)",
|
|
139
|
+
},
|
|
140
|
+
to: {
|
|
141
|
+
type: "string",
|
|
142
|
+
description: "Destination file path (relative)",
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
required: ["from", "to"],
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
];
|
|
150
|
+
function safePath(workingDir, filePath) {
|
|
151
|
+
const base = path.resolve(workingDir);
|
|
152
|
+
const resolved = path.resolve(base, filePath);
|
|
153
|
+
const rel = path.relative(base, resolved);
|
|
154
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
155
|
+
throw new Error(`Path traversal denied: ${filePath} escapes working directory`);
|
|
156
|
+
}
|
|
157
|
+
return resolved;
|
|
158
|
+
}
|
|
159
|
+
export async function executeTool(name, args, workingDir) {
|
|
160
|
+
try {
|
|
161
|
+
switch (name) {
|
|
162
|
+
case "read_file": {
|
|
163
|
+
const filePath = safePath(workingDir, args.path);
|
|
164
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
165
|
+
const lines = content.split("\n");
|
|
166
|
+
const preview = lines.length > 500
|
|
167
|
+
? lines.slice(0, 500).join("\n") +
|
|
168
|
+
`\n\n... (${lines.length - 500} more lines)`
|
|
169
|
+
: content;
|
|
170
|
+
return { success: true, output: preview };
|
|
171
|
+
}
|
|
172
|
+
case "write_file": {
|
|
173
|
+
const filePath = safePath(workingDir, args.path);
|
|
174
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
175
|
+
await fs.writeFile(filePath, args.content, "utf-8");
|
|
176
|
+
return {
|
|
177
|
+
success: true,
|
|
178
|
+
output: `Written ${args.content.length} bytes to ${args.path}`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
case "list_files": {
|
|
182
|
+
const dirPath = args.path
|
|
183
|
+
? safePath(workingDir, args.path)
|
|
184
|
+
: workingDir;
|
|
185
|
+
const maxDepth = Math.min(args.depth || 2, 5);
|
|
186
|
+
const patterns = Array.from({ length: maxDepth }, (_, i) => `${"*/".repeat(i + 1)}*`);
|
|
187
|
+
const files = await fg([...patterns, "*"], {
|
|
188
|
+
cwd: dirPath,
|
|
189
|
+
dot: false,
|
|
190
|
+
ignore: [
|
|
191
|
+
"node_modules/**",
|
|
192
|
+
".git/**",
|
|
193
|
+
"dist/**",
|
|
194
|
+
".next/**",
|
|
195
|
+
"__pycache__/**",
|
|
196
|
+
"*.pyc",
|
|
197
|
+
],
|
|
198
|
+
onlyFiles: false,
|
|
199
|
+
markDirectories: true,
|
|
200
|
+
});
|
|
201
|
+
files.sort();
|
|
202
|
+
const output = files.slice(0, 200).join("\n");
|
|
203
|
+
return {
|
|
204
|
+
success: true,
|
|
205
|
+
output: output || "(empty directory)",
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
case "execute_command": {
|
|
209
|
+
const timeout = args.timeout || 30000;
|
|
210
|
+
const cmd = args.command;
|
|
211
|
+
try {
|
|
212
|
+
const result = await execa("sh", ["-c", cmd], {
|
|
213
|
+
cwd: workingDir,
|
|
214
|
+
timeout,
|
|
215
|
+
all: true,
|
|
216
|
+
reject: false,
|
|
217
|
+
});
|
|
218
|
+
const out = result.all || result.stdout || result.stderr || "";
|
|
219
|
+
const truncated = out.length > 8000 ? out.slice(0, 8000) + "\n... (truncated)" : out;
|
|
220
|
+
return {
|
|
221
|
+
success: result.exitCode === 0,
|
|
222
|
+
output: truncated ||
|
|
223
|
+
`(exit ${result.exitCode}${result.exitCode !== 0 ? " — no output" : ""})`,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
const e = err;
|
|
228
|
+
return {
|
|
229
|
+
success: false,
|
|
230
|
+
output: e.all || "",
|
|
231
|
+
error: e.message || "Command failed",
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
case "search_files": {
|
|
236
|
+
const query = args.query;
|
|
237
|
+
const globPat = args.glob || "**/*";
|
|
238
|
+
const caseSensitive = args.case_sensitive || false;
|
|
239
|
+
if (globPat.includes("..")) {
|
|
240
|
+
return { success: false, output: "", error: "Glob pattern must not contain '..'" };
|
|
241
|
+
}
|
|
242
|
+
const files = await fg(globPat, {
|
|
243
|
+
cwd: workingDir,
|
|
244
|
+
ignore: [
|
|
245
|
+
"node_modules/**",
|
|
246
|
+
".git/**",
|
|
247
|
+
"dist/**",
|
|
248
|
+
".next/**",
|
|
249
|
+
"*.lock",
|
|
250
|
+
],
|
|
251
|
+
onlyFiles: true,
|
|
252
|
+
});
|
|
253
|
+
const results = [];
|
|
254
|
+
const regex = new RegExp(query, caseSensitive ? "g" : "gi");
|
|
255
|
+
for (const file of files.slice(0, 200)) {
|
|
256
|
+
let resolvedFilePath;
|
|
257
|
+
try {
|
|
258
|
+
resolvedFilePath = safePath(workingDir, file);
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
const content = await fs.readFile(resolvedFilePath, "utf-8");
|
|
265
|
+
const lines = content.split("\n");
|
|
266
|
+
const matches = [];
|
|
267
|
+
lines.forEach((line, i) => {
|
|
268
|
+
if (regex.test(line)) {
|
|
269
|
+
matches.push(` L${i + 1}: ${line.trim()}`);
|
|
270
|
+
}
|
|
271
|
+
regex.lastIndex = 0;
|
|
272
|
+
});
|
|
273
|
+
if (matches.length > 0) {
|
|
274
|
+
results.push(`${file}:\n${matches.slice(0, 5).join("\n")}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
}
|
|
279
|
+
if (results.length >= 20)
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
return {
|
|
283
|
+
success: true,
|
|
284
|
+
output: results.length > 0
|
|
285
|
+
? results.join("\n\n")
|
|
286
|
+
: `No matches found for "${query}"`,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
case "delete_file": {
|
|
290
|
+
const filePath = safePath(workingDir, args.path);
|
|
291
|
+
await fs.unlink(filePath);
|
|
292
|
+
return { success: true, output: `Deleted ${args.path}` };
|
|
293
|
+
}
|
|
294
|
+
case "move_file": {
|
|
295
|
+
const from = safePath(workingDir, args.from);
|
|
296
|
+
const to = safePath(workingDir, args.to);
|
|
297
|
+
await fs.mkdir(path.dirname(to), { recursive: true });
|
|
298
|
+
await fs.rename(from, to);
|
|
299
|
+
return { success: true, output: `Moved ${args.from} → ${args.to}` };
|
|
300
|
+
}
|
|
301
|
+
default:
|
|
302
|
+
return { success: false, output: "", error: `Unknown tool: ${name}` };
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
catch (err) {
|
|
306
|
+
const e = err;
|
|
307
|
+
return {
|
|
308
|
+
success: false,
|
|
309
|
+
output: "",
|
|
310
|
+
error: e.message || String(err),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
}
|