@hasna/terminal 4.2.0 → 4.3.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/package.json +5 -3
- package/src/ai.ts +4 -4
- package/src/mcp/server.ts +36 -1640
- package/src/mcp/tools/batch.ts +106 -0
- package/src/mcp/tools/execute.ts +248 -0
- package/src/mcp/tools/files.ts +369 -0
- package/src/mcp/tools/git.ts +306 -0
- package/src/mcp/tools/helpers.ts +92 -0
- package/src/mcp/tools/memory.ts +170 -0
- package/src/mcp/tools/meta.ts +202 -0
- package/src/mcp/tools/process.ts +94 -0
- package/src/mcp/tools/project.ts +297 -0
- package/src/mcp/tools/search.ts +118 -0
- package/src/output-processor.ts +7 -2
- package/src/snapshots.ts +2 -2
- package/dist/App.js +0 -404
- package/dist/Browse.js +0 -79
- package/dist/FuzzyPicker.js +0 -47
- package/dist/Onboarding.js +0 -51
- package/dist/Spinner.js +0 -12
- package/dist/StatusBar.js +0 -49
- package/dist/ai.js +0 -315
- package/dist/cache.js +0 -42
- package/dist/cli.js +0 -778
- package/dist/command-rewriter.js +0 -64
- package/dist/command-validator.js +0 -86
- package/dist/compression.js +0 -91
- package/dist/context-hints.js +0 -285
- package/dist/diff-cache.js +0 -107
- package/dist/discover.js +0 -212
- package/dist/economy.js +0 -155
- package/dist/expand-store.js +0 -44
- package/dist/file-cache.js +0 -72
- package/dist/file-index.js +0 -62
- package/dist/history.js +0 -62
- package/dist/lazy-executor.js +0 -54
- package/dist/line-dedup.js +0 -59
- package/dist/loop-detector.js +0 -75
- package/dist/mcp/install.js +0 -189
- package/dist/mcp/server.js +0 -1306
- package/dist/noise-filter.js +0 -94
- package/dist/output-processor.js +0 -229
- package/dist/output-router.js +0 -41
- package/dist/output-store.js +0 -111
- package/dist/parsers/base.js +0 -2
- package/dist/parsers/build.js +0 -64
- package/dist/parsers/errors.js +0 -101
- package/dist/parsers/files.js +0 -78
- package/dist/parsers/git.js +0 -99
- package/dist/parsers/index.js +0 -48
- package/dist/parsers/tests.js +0 -89
- package/dist/providers/anthropic.js +0 -43
- package/dist/providers/base.js +0 -4
- package/dist/providers/cerebras.js +0 -8
- package/dist/providers/groq.js +0 -8
- package/dist/providers/index.js +0 -142
- package/dist/providers/openai-compat.js +0 -93
- package/dist/providers/xai.js +0 -8
- package/dist/recipes/model.js +0 -20
- package/dist/recipes/storage.js +0 -153
- package/dist/search/content-search.js +0 -70
- package/dist/search/file-search.js +0 -61
- package/dist/search/filters.js +0 -34
- package/dist/search/index.js +0 -5
- package/dist/search/semantic.js +0 -346
- package/dist/session-boot.js +0 -59
- package/dist/session-context.js +0 -55
- package/dist/sessions-db.js +0 -231
- package/dist/smart-display.js +0 -286
- package/dist/snapshots.js +0 -51
- package/dist/supervisor.js +0 -112
- package/dist/test-watchlist.js +0 -131
- package/dist/tokens.js +0 -17
- package/dist/tool-profiles.js +0 -129
- package/dist/tree.js +0 -94
- package/dist/usage-cache.js +0 -65
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
// File tools: read_file, read_files, symbols, symbols_dir, read_symbol, edit, review
|
|
2
|
+
|
|
3
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { z, type ToolHelpers } from "./helpers.js";
|
|
5
|
+
import { stripAnsi } from "../../compression.js";
|
|
6
|
+
import { estimateTokens } from "../../tokens.js";
|
|
7
|
+
import { getOutputProvider } from "../../providers/index.js";
|
|
8
|
+
import { cachedRead } from "../../file-cache.js";
|
|
9
|
+
|
|
10
|
+
export function registerFileTools(server: McpServer, h: ToolHelpers): void {
|
|
11
|
+
|
|
12
|
+
// ── read_file ─────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
server.tool(
|
|
15
|
+
"read_file",
|
|
16
|
+
"Read a file with summarize=true for AI outline (~90% fewer tokens). For full file reads without summarization, prefer your native Read tool (faster, no MCP overhead). Use this when you want cached reads or AI summaries.",
|
|
17
|
+
{
|
|
18
|
+
path: z.string().describe("File path"),
|
|
19
|
+
offset: z.number().optional().describe("Start line (0-indexed)"),
|
|
20
|
+
limit: z.number().optional().describe("Max lines to return"),
|
|
21
|
+
summarize: z.boolean().optional().describe("Return AI summary instead of full content (saves ~90% tokens)"),
|
|
22
|
+
focus: z.string().optional().describe("Focus hint for summary (e.g., 'public API', 'error handling', 'auth logic')"),
|
|
23
|
+
},
|
|
24
|
+
async ({ path: rawPath, offset, limit, summarize, focus }) => {
|
|
25
|
+
const start = Date.now();
|
|
26
|
+
const path = h.resolvePath(rawPath);
|
|
27
|
+
const result = cachedRead(path, { offset, limit });
|
|
28
|
+
|
|
29
|
+
if (summarize && result.content.length > 500) {
|
|
30
|
+
const provider = getOutputProvider();
|
|
31
|
+
const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
|
|
32
|
+
const content = result.content.length > 8000 ? result.content.slice(0, 8000) : result.content;
|
|
33
|
+
const focusInstruction = focus
|
|
34
|
+
? `Focus specifically on: ${focus}. Describe only aspects related to "${focus}".`
|
|
35
|
+
: `Describe what this source file does in 2-4 lines. Include: main class/module name, key methods/functions, what it exports, and its purpose.`;
|
|
36
|
+
const summary = await provider.complete(
|
|
37
|
+
`File: ${path}\n\n${content}`,
|
|
38
|
+
{
|
|
39
|
+
model: outputModel,
|
|
40
|
+
system: `${focusInstruction} Be specific — name the actual functions and what they do. Never just say "N lines of code."`,
|
|
41
|
+
maxTokens: 300,
|
|
42
|
+
temperature: 0.2,
|
|
43
|
+
}
|
|
44
|
+
);
|
|
45
|
+
const outputTokens = estimateTokens(result.content);
|
|
46
|
+
const summaryTokens = estimateTokens(summary);
|
|
47
|
+
const saved = Math.max(0, outputTokens - summaryTokens);
|
|
48
|
+
h.logCall("read_file", { command: path, outputTokens, tokensSaved: saved, durationMs: Date.now() - start, aiProcessed: true });
|
|
49
|
+
return {
|
|
50
|
+
content: [{ type: "text" as const, text: JSON.stringify({
|
|
51
|
+
summary,
|
|
52
|
+
lines: result.content.split("\n").length,
|
|
53
|
+
tokensSaved: saved,
|
|
54
|
+
cached: result.cached,
|
|
55
|
+
}) }],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
h.logCall("read_file", { command: path, outputTokens: estimateTokens(result.content), tokensSaved: 0, durationMs: Date.now() - start });
|
|
60
|
+
return {
|
|
61
|
+
content: [{ type: "text" as const, text: JSON.stringify({
|
|
62
|
+
content: result.content,
|
|
63
|
+
cached: result.cached,
|
|
64
|
+
readCount: result.readCount,
|
|
65
|
+
...(result.cached ? { note: `Served from cache (read #${result.readCount})` } : {}),
|
|
66
|
+
}) }],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
// ── read_files ────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
server.tool(
|
|
74
|
+
"read_files",
|
|
75
|
+
"Read multiple files in one call. Use summarize=true for AI outlines (~90% fewer tokens per file). Saves N-1 round trips vs separate read_file calls.",
|
|
76
|
+
{
|
|
77
|
+
files: z.array(z.string()).describe("File paths (relative or absolute)"),
|
|
78
|
+
summarize: z.boolean().optional().describe("AI summary instead of full content"),
|
|
79
|
+
},
|
|
80
|
+
async ({ files, summarize }) => {
|
|
81
|
+
const start = Date.now();
|
|
82
|
+
const results: Record<string, any> = {};
|
|
83
|
+
|
|
84
|
+
for (const f of files.slice(0, 10)) { // max 10 files per call
|
|
85
|
+
const filePath = h.resolvePath(f);
|
|
86
|
+
const result = cachedRead(filePath, {});
|
|
87
|
+
|
|
88
|
+
if (summarize && result.content.length > 500) {
|
|
89
|
+
const provider = getOutputProvider();
|
|
90
|
+
const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
|
|
91
|
+
const content = result.content.length > 8000 ? result.content.slice(0, 8000) : result.content;
|
|
92
|
+
const summary = await provider.complete(`File: ${filePath}\n\n${content}`, {
|
|
93
|
+
model: outputModel,
|
|
94
|
+
system: `Describe what this source file does in 2-4 lines. Include: main class/module name, key methods/functions, what it exports, and its purpose. Be specific.`,
|
|
95
|
+
maxTokens: 300, temperature: 0.2,
|
|
96
|
+
});
|
|
97
|
+
results[f] = { summary, lines: result.content.split("\n").length };
|
|
98
|
+
} else {
|
|
99
|
+
results[f] = { content: result.content, lines: result.content.split("\n").length };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
h.logCall("read_files", { command: `${files.length} files`, durationMs: Date.now() - start, aiProcessed: !!summarize });
|
|
104
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(results) }] };
|
|
105
|
+
}
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// ── symbols ───────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
server.tool(
|
|
111
|
+
"symbols",
|
|
112
|
+
"Get a structured outline of any source file — functions, classes, methods, interfaces, exports with line numbers. Works for ALL languages (TypeScript, Python, Go, Rust, Java, C#, Ruby, PHP, etc.). AI-powered, not regex.",
|
|
113
|
+
{
|
|
114
|
+
path: z.string().describe("File path to extract symbols from"),
|
|
115
|
+
},
|
|
116
|
+
async ({ path: rawPath }) => {
|
|
117
|
+
const start = Date.now();
|
|
118
|
+
const filePath = h.resolvePath(rawPath);
|
|
119
|
+
const result = cachedRead(filePath, {});
|
|
120
|
+
if (!result.content || result.content.startsWith("Error:")) {
|
|
121
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ error: `Cannot read ${filePath}` }) }] };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// AI extracts symbols — works for ANY language
|
|
125
|
+
let symbols: any[] = [];
|
|
126
|
+
try {
|
|
127
|
+
const provider = getOutputProvider();
|
|
128
|
+
const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
|
|
129
|
+
const content = result.content.length > 8000 ? result.content.slice(0, 8000) : result.content;
|
|
130
|
+
const summary = await provider.complete(
|
|
131
|
+
`File: ${filePath}\n\n${content}`,
|
|
132
|
+
{
|
|
133
|
+
model: outputModel,
|
|
134
|
+
system: `Extract all symbols from this source file. Return ONLY a JSON array, no explanation.
|
|
135
|
+
|
|
136
|
+
Each symbol: {"name": "symbolName", "kind": "function|class|method|interface|type|variable|export", "line": lineNumber, "signature": "brief signature"}
|
|
137
|
+
|
|
138
|
+
For class methods, use "ClassName.methodName" as name with kind "method".
|
|
139
|
+
Include: functions, classes, methods, interfaces, types, exported constants.
|
|
140
|
+
Exclude: imports, local variables, comments.
|
|
141
|
+
Line numbers must be accurate (count from 1).`,
|
|
142
|
+
maxTokens: 2000,
|
|
143
|
+
temperature: 0,
|
|
144
|
+
}
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const jsonMatch = summary.match(/\[[\s\S]*\]/);
|
|
148
|
+
if (jsonMatch) symbols = JSON.parse(jsonMatch[0]);
|
|
149
|
+
} catch (err: any) {
|
|
150
|
+
// Surface the error instead of silently returning []
|
|
151
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ error: `AI symbol extraction failed: ${err.message?.slice(0, 200)}`, file: filePath }) }] };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const outputTokens = estimateTokens(result.content);
|
|
155
|
+
const symbolTokens = estimateTokens(JSON.stringify(symbols));
|
|
156
|
+
h.logCall("symbols", { command: filePath, outputTokens, tokensSaved: Math.max(0, outputTokens - symbolTokens), durationMs: Date.now() - start, aiProcessed: true });
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
content: [{ type: "text" as const, text: JSON.stringify(symbols) }],
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// ── symbols_dir ───────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
server.tool(
|
|
167
|
+
"symbols_dir",
|
|
168
|
+
"Get symbols for all source files in a directory. AI-powered, works for any language. One call replaces N separate symbols calls.",
|
|
169
|
+
{
|
|
170
|
+
path: z.string().optional().describe("Directory (default: src/)"),
|
|
171
|
+
maxFiles: z.number().optional().describe("Max files to scan (default: 10)"),
|
|
172
|
+
},
|
|
173
|
+
async ({ path: dirPath, maxFiles }) => {
|
|
174
|
+
const start = Date.now();
|
|
175
|
+
const dir = h.resolvePath(dirPath ?? "src/");
|
|
176
|
+
const limit = maxFiles ?? 10;
|
|
177
|
+
|
|
178
|
+
// Find source files
|
|
179
|
+
const findResult = await h.exec(
|
|
180
|
+
`find "${dir}" -maxdepth 3 -type f \\( -name "*.ts" -o -name "*.js" -o -name "*.py" -o -name "*.go" -o -name "*.rs" -o -name "*.java" -o -name "*.rb" -o -name "*.php" \\) -not -path "*/node_modules/*" -not -path "*/dist/*" -not -name "*.test.*" -not -name "*.spec.*" | head -${limit}`,
|
|
181
|
+
process.cwd(), 5000
|
|
182
|
+
);
|
|
183
|
+
const files = findResult.stdout.split("\n").filter(l => l.trim());
|
|
184
|
+
|
|
185
|
+
const allSymbols: Record<string, any[]> = {};
|
|
186
|
+
const provider = getOutputProvider();
|
|
187
|
+
const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
|
|
188
|
+
|
|
189
|
+
for (const file of files) {
|
|
190
|
+
const result = cachedRead(file, {});
|
|
191
|
+
if (!result.content || result.content.startsWith("Error:")) continue;
|
|
192
|
+
try {
|
|
193
|
+
const content = result.content.length > 6000 ? result.content.slice(0, 6000) : result.content;
|
|
194
|
+
const summary = await provider.complete(`File: ${file}\n\n${content}`, {
|
|
195
|
+
model: outputModel,
|
|
196
|
+
system: `Extract all symbols. Return ONLY a JSON array. Each: {"name":"x","kind":"function|class|method|interface|type","line":N,"signature":"brief"}. For class methods use "Class.method". Exclude imports.`,
|
|
197
|
+
maxTokens: 1500, temperature: 0,
|
|
198
|
+
});
|
|
199
|
+
const jsonMatch = summary.match(/\[[\s\S]*\]/);
|
|
200
|
+
if (jsonMatch) allSymbols[file] = JSON.parse(jsonMatch[0]);
|
|
201
|
+
} catch {}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
h.logCall("symbols_dir", { command: `${files.length} files in ${dir}`, durationMs: Date.now() - start, aiProcessed: true });
|
|
205
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ directory: dir, files: files.length, symbols: allSymbols }) }] };
|
|
206
|
+
}
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
// ── read_symbol ───────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
server.tool(
|
|
212
|
+
"read_symbol",
|
|
213
|
+
"Read a specific function, class, or interface by name from a source file. Returns only the code block — not the entire file. Saves 70-85% tokens vs reading the whole file.",
|
|
214
|
+
{
|
|
215
|
+
path: z.string().describe("Source file path"),
|
|
216
|
+
name: z.string().describe("Symbol name (function, class, interface)"),
|
|
217
|
+
},
|
|
218
|
+
async ({ path: rawPath, name }) => {
|
|
219
|
+
const start = Date.now();
|
|
220
|
+
const filePath = h.resolvePath(rawPath);
|
|
221
|
+
const result = cachedRead(filePath, {});
|
|
222
|
+
if (!result.content || result.content.startsWith("Error:")) {
|
|
223
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ error: `Cannot read ${filePath}` }) }] };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// AI extracts the specific symbol — works for ANY language
|
|
227
|
+
const provider = getOutputProvider();
|
|
228
|
+
const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
|
|
229
|
+
const summary = await provider.complete(
|
|
230
|
+
`File: ${filePath}\nSymbol to extract: ${name}\n\n${result.content.slice(0, 8000)}`,
|
|
231
|
+
{
|
|
232
|
+
model: outputModel,
|
|
233
|
+
system: `Extract the complete code block for the symbol "${name}" from this file. Return ONLY a JSON object:
|
|
234
|
+
{"name": "${name}", "code": "the complete code block", "startLine": N, "endLine": N}
|
|
235
|
+
|
|
236
|
+
If the symbol is not found, return: {"error": "not found", "available": ["list", "of", "symbol", "names"]}
|
|
237
|
+
|
|
238
|
+
Match by function name, class name, method name (including ClassName.method), interface, type, or variable name.`,
|
|
239
|
+
maxTokens: 2000,
|
|
240
|
+
temperature: 0,
|
|
241
|
+
}
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
let parsed: any = {};
|
|
245
|
+
try {
|
|
246
|
+
const jsonMatch = summary.match(/\{[\s\S]*\}/);
|
|
247
|
+
if (jsonMatch) parsed = JSON.parse(jsonMatch[0]);
|
|
248
|
+
} catch {}
|
|
249
|
+
|
|
250
|
+
h.logCall("read_symbol", { command: `${filePath}:${name}`, outputTokens: estimateTokens(result.content), tokensSaved: Math.max(0, estimateTokens(result.content) - estimateTokens(JSON.stringify(parsed))), durationMs: Date.now() - start, aiProcessed: true });
|
|
251
|
+
|
|
252
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(parsed) }] };
|
|
253
|
+
}
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
// ── edit ───────────────────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
server.tool(
|
|
259
|
+
"edit",
|
|
260
|
+
"Find and replace in a file. For simple edits, prefer your native Edit tool (faster). Use this for batch replacements (all=true) or when you don't have a native Edit tool available.",
|
|
261
|
+
{
|
|
262
|
+
file: z.string().describe("File path"),
|
|
263
|
+
find: z.string().describe("Text to find (exact match)"),
|
|
264
|
+
replace: z.string().describe("Replacement text"),
|
|
265
|
+
all: z.boolean().optional().describe("Replace all occurrences (default: first only)"),
|
|
266
|
+
},
|
|
267
|
+
async ({ file: rawFile, find, replace, all }) => {
|
|
268
|
+
const start = Date.now();
|
|
269
|
+
const file = h.resolvePath(rawFile);
|
|
270
|
+
const { readFileSync, writeFileSync } = await import("fs");
|
|
271
|
+
try {
|
|
272
|
+
let content = readFileSync(file, "utf8");
|
|
273
|
+
const count = (content.match(new RegExp(find.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g")) || []).length;
|
|
274
|
+
if (count === 0) {
|
|
275
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ error: "Text not found", file }) }] };
|
|
276
|
+
}
|
|
277
|
+
if (all) {
|
|
278
|
+
content = content.split(find).join(replace);
|
|
279
|
+
} else {
|
|
280
|
+
content = content.replace(find, replace);
|
|
281
|
+
}
|
|
282
|
+
writeFileSync(file, content);
|
|
283
|
+
h.logCall("edit", { command: `edit ${file}`, durationMs: Date.now() - start });
|
|
284
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, file, replacements: all ? count : 1, diff: { removed: find.slice(0, 100), added: replace.slice(0, 100) } }) }] };
|
|
285
|
+
} catch (e: any) {
|
|
286
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ error: e.message }) }] };
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
// ── review ────────────────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
server.tool(
|
|
294
|
+
"review",
|
|
295
|
+
"AI code review of recent changes or specific files. Returns: bugs, security issues, suggestions. One call replaces git diff + manual reading.",
|
|
296
|
+
{
|
|
297
|
+
since: z.string().optional().describe("Git ref to diff against (e.g., 'HEAD~3', 'main')"),
|
|
298
|
+
files: z.array(z.string()).optional().describe("Specific files to review"),
|
|
299
|
+
cwd: z.string().optional().describe("Working directory"),
|
|
300
|
+
},
|
|
301
|
+
async ({ since, files, cwd }) => {
|
|
302
|
+
const start = Date.now();
|
|
303
|
+
const workDir = cwd ?? process.cwd();
|
|
304
|
+
|
|
305
|
+
let content: string;
|
|
306
|
+
if (files && files.length > 0) {
|
|
307
|
+
const fileContents = files.map(f => {
|
|
308
|
+
const result = cachedRead(h.resolvePath(f, workDir), {});
|
|
309
|
+
return `=== ${f} ===\n${result.content.slice(0, 4000)}`;
|
|
310
|
+
});
|
|
311
|
+
content = fileContents.join("\n\n");
|
|
312
|
+
} else {
|
|
313
|
+
const ref = since ?? "HEAD~1";
|
|
314
|
+
const diff = await h.exec(`git diff ${ref} --no-color`, workDir, 15000);
|
|
315
|
+
content = diff.stdout.slice(0, 12000);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const provider = getOutputProvider();
|
|
319
|
+
const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
|
|
320
|
+
const review = await provider.complete(`Review this code:\n\n${content}`, {
|
|
321
|
+
model: outputModel,
|
|
322
|
+
system: `You are a senior code reviewer. Review concisely:
|
|
323
|
+
- Bugs or logic errors
|
|
324
|
+
- Security issues (injection, auth, secrets)
|
|
325
|
+
- Missing error handling
|
|
326
|
+
- Performance concerns
|
|
327
|
+
- Style/naming issues (only if significant)
|
|
328
|
+
|
|
329
|
+
Format: list issues as "- [severity] file:line description". If clean, say "No issues found."
|
|
330
|
+
Be specific, not generic. Only flag real problems.`,
|
|
331
|
+
maxTokens: 800, temperature: 0.2,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
h.logCall("review", { command: `review ${since ?? files?.join(",") ?? "HEAD~1"}`, durationMs: Date.now() - start, aiProcessed: true });
|
|
335
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ review, scope: since ?? files }) }] };
|
|
336
|
+
}
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
// ── write_files ─────────────────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
server.tool(
|
|
342
|
+
"write_files",
|
|
343
|
+
"Write multiple files in one call. Auto-creates parent directories. Saves N-1 round trips vs separate writes.",
|
|
344
|
+
{
|
|
345
|
+
files: z.array(z.object({
|
|
346
|
+
path: z.string().describe("File path (relative or absolute)"),
|
|
347
|
+
content: z.string().describe("File content"),
|
|
348
|
+
})).describe("Files to write"),
|
|
349
|
+
},
|
|
350
|
+
async ({ files }) => {
|
|
351
|
+
const { writeFileSync, mkdirSync, existsSync } = await import("fs");
|
|
352
|
+
const { dirname } = await import("path");
|
|
353
|
+
const results: { path: string; ok: boolean; bytes?: number; error?: string }[] = [];
|
|
354
|
+
for (const f of files.slice(0, 20)) {
|
|
355
|
+
try {
|
|
356
|
+
const filePath = h.resolvePath(f.path);
|
|
357
|
+
const dir = dirname(filePath);
|
|
358
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
359
|
+
writeFileSync(filePath, f.content);
|
|
360
|
+
results.push({ path: f.path, ok: true, bytes: f.content.length });
|
|
361
|
+
} catch (e: any) {
|
|
362
|
+
results.push({ path: f.path, ok: false, error: e.message?.slice(0, 100) });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
h.logCall("write_files", { command: `${files.length} files` });
|
|
366
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ written: results.filter(r => r.ok).length, total: results.length, results }) }] };
|
|
367
|
+
}
|
|
368
|
+
);
|
|
369
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// Git tools: commit, bulk_commit, smart_commit, diff, repo_state, last_commit
|
|
2
|
+
|
|
3
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { z, type ToolHelpers } from "./helpers.js";
|
|
5
|
+
import { stripAnsi } from "../../compression.js";
|
|
6
|
+
import { estimateTokens } from "../../tokens.js";
|
|
7
|
+
import { processOutput } from "../../output-processor.js";
|
|
8
|
+
import { getOutputProvider } from "../../providers/index.js";
|
|
9
|
+
import { invalidateBootCache } from "../../session-boot.js";
|
|
10
|
+
|
|
11
|
+
export function registerGitTools(server: McpServer, h: ToolHelpers): void {
|
|
12
|
+
|
|
13
|
+
// ── commit ────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
server.tool(
|
|
16
|
+
"commit",
|
|
17
|
+
"Commit and optionally push. Agent says what to commit, we handle git add/commit/push. Saves ~400 tokens vs raw git commands.",
|
|
18
|
+
{
|
|
19
|
+
message: z.string().describe("Commit message"),
|
|
20
|
+
files: z.array(z.string()).optional().describe("Files to stage (default: all changed)"),
|
|
21
|
+
push: z.boolean().optional().describe("Push after commit (default: false)"),
|
|
22
|
+
cwd: z.string().optional().describe("Working directory"),
|
|
23
|
+
},
|
|
24
|
+
async ({ message, files, push, cwd }) => {
|
|
25
|
+
const start = Date.now();
|
|
26
|
+
const workDir = cwd ?? process.cwd();
|
|
27
|
+
const addCmd = files && files.length > 0 ? `git add ${files.map(f => `"${f}"`).join(" ")}` : "git add -A";
|
|
28
|
+
const commitCmd = `${addCmd} && git commit -m ${JSON.stringify(message)}`;
|
|
29
|
+
const fullCmd = push ? `${commitCmd} && git push` : commitCmd;
|
|
30
|
+
|
|
31
|
+
const result = await h.exec(fullCmd, workDir, 30000);
|
|
32
|
+
const output = (result.stdout + result.stderr).trim();
|
|
33
|
+
h.logCall("commit", { command: `commit: ${message.slice(0, 80)}`, durationMs: Date.now() - start, exitCode: result.exitCode });
|
|
34
|
+
invalidateBootCache();
|
|
35
|
+
|
|
36
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
37
|
+
exitCode: result.exitCode,
|
|
38
|
+
output: stripAnsi(output).split("\n").filter(l => l.trim()).slice(0, 5).join("\n"),
|
|
39
|
+
pushed: push ?? false,
|
|
40
|
+
}) }] };
|
|
41
|
+
}
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// ── bulk_commit ───────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
server.tool(
|
|
47
|
+
"bulk_commit",
|
|
48
|
+
"Multiple logical commits in one call. Agent decides which files go in which commit, we handle all git commands. No AI cost. Use smart_commit instead if you want AI to decide the grouping.",
|
|
49
|
+
{
|
|
50
|
+
commits: z.array(z.object({
|
|
51
|
+
message: z.string().describe("Commit message"),
|
|
52
|
+
files: z.array(z.string()).describe("Files to stage for this commit"),
|
|
53
|
+
})).describe("Array of logical commits"),
|
|
54
|
+
push: z.boolean().optional().describe("Push after all commits (default: true)"),
|
|
55
|
+
cwd: z.string().optional().describe("Working directory"),
|
|
56
|
+
},
|
|
57
|
+
async ({ commits, push, cwd }) => {
|
|
58
|
+
const start = Date.now();
|
|
59
|
+
const workDir = cwd ?? process.cwd();
|
|
60
|
+
const results: { message: string; files: number; ok: boolean }[] = [];
|
|
61
|
+
|
|
62
|
+
for (const c of commits) {
|
|
63
|
+
const fileArgs = c.files.map(f => `"${f}"`).join(" ");
|
|
64
|
+
const cmd = `git add ${fileArgs} && git commit -m ${JSON.stringify(c.message)}`;
|
|
65
|
+
const r = await h.exec(cmd, workDir, 15000);
|
|
66
|
+
results.push({ message: c.message, files: c.files.length, ok: r.exitCode === 0 });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let pushed = false;
|
|
70
|
+
if (push !== false) {
|
|
71
|
+
const pushResult = await h.exec("git push", workDir, 30000);
|
|
72
|
+
pushed = pushResult.exitCode === 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
invalidateBootCache();
|
|
76
|
+
h.logCall("bulk_commit", { command: `${commits.length} commits`, durationMs: Date.now() - start });
|
|
77
|
+
|
|
78
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ commits: results, pushed, total: results.length }) }] };
|
|
79
|
+
}
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
// ── smart_commit ──────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
server.tool(
|
|
85
|
+
"smart_commit",
|
|
86
|
+
"AI-powered git commit. Analyzes all changes, groups into logical commits with generated messages, stages and commits each group, optionally pushes. One call replaces the entire git workflow. Agent just says 'commit my work'.",
|
|
87
|
+
{
|
|
88
|
+
push: z.boolean().optional().describe("Push after all commits (default: true)"),
|
|
89
|
+
hint: z.string().optional().describe("Optional context about the changes (e.g., 'fixed auth + added users endpoint')"),
|
|
90
|
+
cwd: z.string().optional().describe("Working directory"),
|
|
91
|
+
},
|
|
92
|
+
async ({ push, hint, cwd }) => {
|
|
93
|
+
const start = Date.now();
|
|
94
|
+
const workDir = cwd ?? process.cwd();
|
|
95
|
+
|
|
96
|
+
// 1. Get all changed files
|
|
97
|
+
const status = await h.exec("git status --porcelain", workDir, 10000);
|
|
98
|
+
const diffStat = await h.exec("git diff --stat", workDir, 10000);
|
|
99
|
+
const untrackedDiff = await h.exec("git diff HEAD --stat", workDir, 10000);
|
|
100
|
+
|
|
101
|
+
const changedFiles = status.stdout.trim();
|
|
102
|
+
if (!changedFiles) {
|
|
103
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ message: "Nothing to commit — working tree clean" }) }] };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 2. AI groups changes into logical commits
|
|
107
|
+
const provider = getOutputProvider();
|
|
108
|
+
const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
|
|
109
|
+
|
|
110
|
+
const grouping = await provider.complete(
|
|
111
|
+
`Changed files:\n${changedFiles}\n\nDiff stats:\n${diffStat.stdout}\n${untrackedDiff.stdout}${hint ? `\n\nContext: ${hint}` : ""}`,
|
|
112
|
+
{
|
|
113
|
+
model: outputModel,
|
|
114
|
+
system: `You are a git commit assistant. Group these changed files into logical commits. Return ONLY a JSON array:
|
|
115
|
+
|
|
116
|
+
[{"message": "conventional commit message", "files": ["file1.ts", "file2.ts"]}]
|
|
117
|
+
|
|
118
|
+
Rules:
|
|
119
|
+
- Group related changes (same feature, same fix, same refactor)
|
|
120
|
+
- Use conventional commits: feat:, fix:, refactor:, test:, docs:, chore:
|
|
121
|
+
- Message should explain WHY, not WHAT (the diff shows what)
|
|
122
|
+
- Each file appears in exactly one group
|
|
123
|
+
- If all changes are related, use a single commit
|
|
124
|
+
- Extract file paths from the status output (skip the status prefix like M, A, ??)`,
|
|
125
|
+
maxTokens: 1000,
|
|
126
|
+
temperature: 0,
|
|
127
|
+
}
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
let commits: { message: string; files: string[] }[] = [];
|
|
131
|
+
try {
|
|
132
|
+
const jsonMatch = grouping.match(/\[[\s\S]*\]/);
|
|
133
|
+
if (jsonMatch) commits = JSON.parse(jsonMatch[0]);
|
|
134
|
+
} catch {}
|
|
135
|
+
|
|
136
|
+
if (commits.length === 0) {
|
|
137
|
+
// Fallback: single commit with all files
|
|
138
|
+
commits = [{ message: hint ?? "chore: update files", files: changedFiles.split("\n").map(l => l.slice(3).trim()) }];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 3. Execute each commit
|
|
142
|
+
const results: { message: string; files: number; ok: boolean }[] = [];
|
|
143
|
+
for (const c of commits) {
|
|
144
|
+
const fileArgs = c.files.map(f => `"${f}"`).join(" ");
|
|
145
|
+
const cmd = `git add ${fileArgs} && git commit -m ${JSON.stringify(c.message)}`;
|
|
146
|
+
const r = await h.exec(cmd, workDir, 15000);
|
|
147
|
+
results.push({ message: c.message, files: c.files.length, ok: r.exitCode === 0 });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 4. Push if requested
|
|
151
|
+
let pushed = false;
|
|
152
|
+
if (push !== false) {
|
|
153
|
+
const pushResult = await h.exec("git push", workDir, 30000);
|
|
154
|
+
pushed = pushResult.exitCode === 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
invalidateBootCache();
|
|
158
|
+
h.logCall("smart_commit", { command: `${commits.length} commits`, durationMs: Date.now() - start, aiProcessed: true });
|
|
159
|
+
|
|
160
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
161
|
+
commits: results,
|
|
162
|
+
pushed,
|
|
163
|
+
total: results.length,
|
|
164
|
+
ok: results.every(r => r.ok),
|
|
165
|
+
}) }] };
|
|
166
|
+
}
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
// ── diff ──────────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
server.tool(
|
|
172
|
+
"diff",
|
|
173
|
+
"Show what changed — git diff with AI summary. One call replaces constructing git diff commands.",
|
|
174
|
+
{
|
|
175
|
+
ref: z.string().optional().describe("Diff against this ref (default: unstaged changes). Examples: HEAD~1, main, abc123"),
|
|
176
|
+
file: z.string().optional().describe("Diff a specific file only"),
|
|
177
|
+
stat: z.boolean().optional().describe("Show file-level stats only, not full diff (default: false)"),
|
|
178
|
+
cwd: z.string().optional().describe("Working directory"),
|
|
179
|
+
},
|
|
180
|
+
async ({ ref, file, stat, cwd }) => {
|
|
181
|
+
const start = Date.now();
|
|
182
|
+
const workDir = cwd ?? process.cwd();
|
|
183
|
+
let cmd = "git diff";
|
|
184
|
+
if (ref) cmd += ` ${ref}`;
|
|
185
|
+
if (stat) cmd += " --stat";
|
|
186
|
+
if (file) cmd += ` -- ${file}`;
|
|
187
|
+
|
|
188
|
+
const result = await h.exec(cmd, workDir, 15000);
|
|
189
|
+
const output = (result.stdout + result.stderr).trim();
|
|
190
|
+
|
|
191
|
+
if (!output) {
|
|
192
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ clean: true, message: "No changes" }) }] };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const processed = await processOutput(cmd, output);
|
|
196
|
+
h.logCall("diff", { command: cmd, outputTokens: estimateTokens(output), tokensSaved: processed.tokensSaved, durationMs: Date.now() - start, aiProcessed: processed.aiProcessed });
|
|
197
|
+
|
|
198
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
199
|
+
summary: processed.summary,
|
|
200
|
+
lines: output.split("\n").length,
|
|
201
|
+
tokensSaved: processed.tokensSaved,
|
|
202
|
+
}) }] };
|
|
203
|
+
}
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
// ── repo_state ────────────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
server.tool(
|
|
209
|
+
"repo_state",
|
|
210
|
+
"Get full repository state in one call — branch, status, staged/unstaged files, recent commits. Replaces the common 3-command pattern: git status + git diff --stat + git log.",
|
|
211
|
+
{
|
|
212
|
+
path: z.string().optional().describe("Repo path (default: cwd)"),
|
|
213
|
+
},
|
|
214
|
+
async ({ path }) => {
|
|
215
|
+
const cwd = path ?? process.cwd();
|
|
216
|
+
const [statusResult, diffResult, logResult] = await Promise.all([
|
|
217
|
+
h.exec("git status --porcelain", cwd),
|
|
218
|
+
h.exec("git diff --stat", cwd),
|
|
219
|
+
h.exec("git log --oneline -12 --decorate", cwd),
|
|
220
|
+
]);
|
|
221
|
+
|
|
222
|
+
const branchResult = await h.exec("git branch --show-current", cwd);
|
|
223
|
+
|
|
224
|
+
const staged: string[] = [];
|
|
225
|
+
const unstaged: string[] = [];
|
|
226
|
+
const untracked: string[] = [];
|
|
227
|
+
for (const line of statusResult.stdout.split("\n").filter(l => l.trim())) {
|
|
228
|
+
const x = line[0], y = line[1], file = line.slice(3);
|
|
229
|
+
if (x === "?" && y === "?") untracked.push(file);
|
|
230
|
+
else if (x !== " " && x !== "?") staged.push(file);
|
|
231
|
+
if (y !== " " && y !== "?") unstaged.push(file);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const commits = logResult.stdout.split("\n").filter(l => l.trim()).map(l => {
|
|
235
|
+
const match = l.match(/^([a-f0-9]+)\s+(.+)$/);
|
|
236
|
+
return match ? { hash: match[1], message: match[2] } : { hash: "", message: l };
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
content: [{ type: "text" as const, text: JSON.stringify({
|
|
241
|
+
branch: branchResult.stdout.trim(),
|
|
242
|
+
dirty: staged.length + unstaged.length + untracked.length > 0,
|
|
243
|
+
staged, unstaged, untracked,
|
|
244
|
+
diffSummary: diffResult.stdout.trim() || "no changes",
|
|
245
|
+
recentCommits: commits,
|
|
246
|
+
}) }],
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
// ── last_commit ───────────────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
server.tool(
|
|
254
|
+
"last_commit",
|
|
255
|
+
"Get details of the last commit — hash, message, files changed, diff stats. Replaces: git log -1 + git show --stat + git diff HEAD~1.",
|
|
256
|
+
{
|
|
257
|
+
path: z.string().optional().describe("Repo path (default: cwd)"),
|
|
258
|
+
},
|
|
259
|
+
async ({ path }) => {
|
|
260
|
+
const cwd = path ?? process.cwd();
|
|
261
|
+
const [logResult, statResult] = await Promise.all([
|
|
262
|
+
h.exec("git log -1 --format='%H%n%s%n%an%n%ai'", cwd),
|
|
263
|
+
h.exec("git show --stat --format='' HEAD", cwd),
|
|
264
|
+
]);
|
|
265
|
+
|
|
266
|
+
const [hash, message, author, date] = logResult.stdout.split("\n");
|
|
267
|
+
const filesChanged = statResult.stdout.split("\n").filter(l => l.trim() && !l.includes("changed"));
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
content: [{ type: "text" as const, text: JSON.stringify({
|
|
271
|
+
hash: hash?.trim(),
|
|
272
|
+
message: message?.trim(),
|
|
273
|
+
author: author?.trim(),
|
|
274
|
+
date: date?.trim(),
|
|
275
|
+
filesChanged,
|
|
276
|
+
}) }],
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
// ── git_init ────────────────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
server.tool(
|
|
284
|
+
"git_init",
|
|
285
|
+
"Initialize a new git repo, optionally with .gitignore and initial commit.",
|
|
286
|
+
{
|
|
287
|
+
cwd: z.string().optional().describe("Directory to init (default: cwd)"),
|
|
288
|
+
gitignore: z.string().optional().describe("Content for .gitignore file"),
|
|
289
|
+
initialCommit: z.boolean().optional().describe("Create initial commit (default: true)"),
|
|
290
|
+
},
|
|
291
|
+
async ({ cwd, gitignore, initialCommit }) => {
|
|
292
|
+
const workDir = cwd ?? process.cwd();
|
|
293
|
+
await h.exec("git init", workDir, 5000);
|
|
294
|
+
if (gitignore) {
|
|
295
|
+
const { writeFileSync } = await import("fs");
|
|
296
|
+
const { join } = await import("path");
|
|
297
|
+
writeFileSync(join(workDir, ".gitignore"), gitignore);
|
|
298
|
+
}
|
|
299
|
+
if (initialCommit !== false) {
|
|
300
|
+
await h.exec("git add -A && git commit -m 'init' --allow-empty", workDir, 10000);
|
|
301
|
+
}
|
|
302
|
+
h.logCall("git_init", { command: "git init" });
|
|
303
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ initialized: true, cwd: workDir }) }] };
|
|
304
|
+
}
|
|
305
|
+
);
|
|
306
|
+
}
|