@hasna/terminal 0.1.4 → 0.2.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/.claude/scheduled_tasks.lock +1 -1
- package/README.md +186 -0
- package/dist/App.js +217 -105
- package/dist/Browse.js +79 -0
- package/dist/FuzzyPicker.js +47 -0
- package/dist/StatusBar.js +20 -16
- package/dist/ai.js +45 -50
- package/dist/cli.js +138 -6
- package/dist/compression.js +107 -0
- package/dist/compression.test.js +42 -0
- package/dist/diff-cache.js +87 -0
- package/dist/diff-cache.test.js +27 -0
- package/dist/economy.js +79 -0
- package/dist/economy.test.js +13 -0
- package/dist/mcp/install.js +98 -0
- package/dist/mcp/server.js +333 -0
- package/dist/output-router.js +41 -0
- package/dist/parsers/base.js +2 -0
- package/dist/parsers/build.js +64 -0
- package/dist/parsers/errors.js +101 -0
- package/dist/parsers/files.js +78 -0
- package/dist/parsers/git.js +86 -0
- package/dist/parsers/index.js +48 -0
- package/dist/parsers/parsers.test.js +136 -0
- package/dist/parsers/tests.js +89 -0
- package/dist/providers/anthropic.js +39 -0
- package/dist/providers/base.js +4 -0
- package/dist/providers/cerebras.js +95 -0
- package/dist/providers/index.js +49 -0
- package/dist/providers/providers.test.js +14 -0
- package/dist/recipes/model.js +20 -0
- package/dist/recipes/recipes.test.js +36 -0
- package/dist/recipes/storage.js +118 -0
- package/dist/search/content-search.js +61 -0
- package/dist/search/file-search.js +61 -0
- package/dist/search/filters.js +34 -0
- package/dist/search/index.js +4 -0
- package/dist/search/search.test.js +22 -0
- package/dist/snapshots.js +51 -0
- package/dist/supervisor.js +112 -0
- package/dist/tree.js +94 -0
- package/package.json +7 -4
- package/src/App.tsx +371 -245
- package/src/Browse.tsx +103 -0
- package/src/FuzzyPicker.tsx +69 -0
- package/src/StatusBar.tsx +28 -34
- package/src/ai.ts +63 -51
- package/src/cli.tsx +132 -6
- package/src/compression.test.ts +50 -0
- package/src/compression.ts +140 -0
- package/src/diff-cache.test.ts +30 -0
- package/src/diff-cache.ts +125 -0
- package/src/economy.test.ts +16 -0
- package/src/economy.ts +99 -0
- package/src/mcp/install.ts +94 -0
- package/src/mcp/server.ts +476 -0
- package/src/output-router.ts +56 -0
- package/src/parsers/base.ts +72 -0
- package/src/parsers/build.ts +73 -0
- package/src/parsers/errors.ts +107 -0
- package/src/parsers/files.ts +91 -0
- package/src/parsers/git.ts +86 -0
- package/src/parsers/index.ts +66 -0
- package/src/parsers/parsers.test.ts +153 -0
- package/src/parsers/tests.ts +98 -0
- package/src/providers/anthropic.ts +44 -0
- package/src/providers/base.ts +34 -0
- package/src/providers/cerebras.ts +108 -0
- package/src/providers/index.ts +60 -0
- package/src/providers/providers.test.ts +16 -0
- package/src/recipes/model.ts +55 -0
- package/src/recipes/recipes.test.ts +44 -0
- package/src/recipes/storage.ts +142 -0
- package/src/search/content-search.ts +97 -0
- package/src/search/file-search.ts +86 -0
- package/src/search/filters.ts +36 -0
- package/src/search/index.ts +7 -0
- package/src/search/search.test.ts +25 -0
- package/src/snapshots.ts +67 -0
- package/src/supervisor.ts +129 -0
- package/src/tree.ts +101 -0
- package/tsconfig.json +2 -1
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
// MCP Server for open-terminal — exposes terminal capabilities to AI agents
|
|
2
|
+
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { spawn } from "child_process";
|
|
7
|
+
import { compress, stripAnsi } from "../compression.js";
|
|
8
|
+
import { parseOutput, tokenSavings, estimateTokens } from "../parsers/index.js";
|
|
9
|
+
import { summarizeOutput } from "../ai.js";
|
|
10
|
+
import { searchFiles, searchContent } from "../search/index.js";
|
|
11
|
+
import { listRecipes, listCollections, getRecipe, createRecipe } from "../recipes/storage.js";
|
|
12
|
+
import { substituteVariables } from "../recipes/model.js";
|
|
13
|
+
import { bgStart, bgStatus, bgStop, bgLogs, bgWaitPort } from "../supervisor.js";
|
|
14
|
+
import { diffOutput } from "../diff-cache.js";
|
|
15
|
+
import { getEconomyStats, recordSaving } from "../economy.js";
|
|
16
|
+
import { captureSnapshot } from "../snapshots.js";
|
|
17
|
+
|
|
18
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
function exec(command: string, cwd?: string, timeout?: number): Promise<{ exitCode: number; stdout: string; stderr: string; duration: number }> {
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
const start = Date.now();
|
|
23
|
+
const proc = spawn("/bin/zsh", ["-c", command], {
|
|
24
|
+
cwd: cwd ?? process.cwd(),
|
|
25
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
let stdout = "";
|
|
29
|
+
let stderr = "";
|
|
30
|
+
|
|
31
|
+
proc.stdout?.on("data", (d: Buffer) => { stdout += d.toString(); });
|
|
32
|
+
proc.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); });
|
|
33
|
+
|
|
34
|
+
const timer = timeout ? setTimeout(() => { try { proc.kill("SIGTERM"); } catch {} }, timeout) : null;
|
|
35
|
+
|
|
36
|
+
proc.on("close", (code) => {
|
|
37
|
+
if (timer) clearTimeout(timer);
|
|
38
|
+
resolve({ exitCode: code ?? 0, stdout, stderr, duration: Date.now() - start });
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── server ───────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
export function createServer(): McpServer {
|
|
46
|
+
const server = new McpServer({
|
|
47
|
+
name: "open-terminal",
|
|
48
|
+
version: "0.2.0",
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// ── execute: run a command, return structured result ──────────────────────
|
|
52
|
+
|
|
53
|
+
server.tool(
|
|
54
|
+
"execute",
|
|
55
|
+
"Run a shell command and return the result. Supports structured output parsing (json), token compression (compressed), and AI summarization (summary).",
|
|
56
|
+
{
|
|
57
|
+
command: z.string().describe("Shell command to execute"),
|
|
58
|
+
cwd: z.string().optional().describe("Working directory (default: server cwd)"),
|
|
59
|
+
timeout: z.number().optional().describe("Timeout in ms (default: 30000)"),
|
|
60
|
+
format: z.enum(["raw", "json", "compressed", "summary"]).optional().describe("Output format"),
|
|
61
|
+
maxTokens: z.number().optional().describe("Token budget for compressed/summary format"),
|
|
62
|
+
},
|
|
63
|
+
async ({ command, cwd, timeout, format, maxTokens }) => {
|
|
64
|
+
const result = await exec(command, cwd, timeout ?? 30000);
|
|
65
|
+
const output = (result.stdout + result.stderr).trim();
|
|
66
|
+
|
|
67
|
+
// Raw mode
|
|
68
|
+
if (!format || format === "raw") {
|
|
69
|
+
const clean = stripAnsi(output);
|
|
70
|
+
return {
|
|
71
|
+
content: [{ type: "text" as const, text: JSON.stringify({
|
|
72
|
+
exitCode: result.exitCode, output: clean, duration: result.duration, tokens: estimateTokens(clean),
|
|
73
|
+
}) }],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// JSON mode — structured parsing
|
|
78
|
+
if (format === "json") {
|
|
79
|
+
const parsed = parseOutput(command, output);
|
|
80
|
+
if (parsed) {
|
|
81
|
+
const savings = tokenSavings(output, parsed.data);
|
|
82
|
+
return {
|
|
83
|
+
content: [{ type: "text" as const, text: JSON.stringify({
|
|
84
|
+
exitCode: result.exitCode, parsed: parsed.data, parser: parsed.parser,
|
|
85
|
+
duration: result.duration, tokensSaved: savings.saved, savingsPercent: savings.percent,
|
|
86
|
+
}) }],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Compressed mode (also fallback for json when no parser matches)
|
|
92
|
+
if (format === "compressed" || format === "json") {
|
|
93
|
+
const compressed = compress(command, output, { maxTokens, format: "json" });
|
|
94
|
+
return {
|
|
95
|
+
content: [{ type: "text" as const, text: JSON.stringify({
|
|
96
|
+
exitCode: result.exitCode, output: compressed.content, format: compressed.format,
|
|
97
|
+
duration: result.duration, tokensSaved: compressed.tokensSaved, savingsPercent: compressed.savingsPercent,
|
|
98
|
+
}) }],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Summary mode — AI-powered
|
|
103
|
+
if (format === "summary") {
|
|
104
|
+
try {
|
|
105
|
+
const summary = await summarizeOutput(command, output, maxTokens ?? 200);
|
|
106
|
+
const rawTokens = estimateTokens(output);
|
|
107
|
+
const summaryTokens = estimateTokens(summary);
|
|
108
|
+
return {
|
|
109
|
+
content: [{ type: "text" as const, text: JSON.stringify({
|
|
110
|
+
exitCode: result.exitCode, summary, duration: result.duration,
|
|
111
|
+
tokensSaved: rawTokens - summaryTokens,
|
|
112
|
+
}) }],
|
|
113
|
+
};
|
|
114
|
+
} catch {
|
|
115
|
+
const compressed = compress(command, output, { maxTokens });
|
|
116
|
+
return {
|
|
117
|
+
content: [{ type: "text" as const, text: JSON.stringify({
|
|
118
|
+
exitCode: result.exitCode, output: compressed.content, duration: result.duration,
|
|
119
|
+
tokensSaved: compressed.tokensSaved,
|
|
120
|
+
}) }],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { content: [{ type: "text" as const, text: output }] };
|
|
126
|
+
}
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
// ── browse: list files/dirs as structured JSON ────────────────────────────
|
|
130
|
+
|
|
131
|
+
server.tool(
|
|
132
|
+
"browse",
|
|
133
|
+
"List files and directories as structured JSON. Auto-filters node_modules, .git, dist by default.",
|
|
134
|
+
{
|
|
135
|
+
path: z.string().optional().describe("Directory path (default: cwd)"),
|
|
136
|
+
recursive: z.boolean().optional().describe("List recursively (default: false)"),
|
|
137
|
+
maxDepth: z.number().optional().describe("Max depth for recursive listing (default: 2)"),
|
|
138
|
+
includeHidden: z.boolean().optional().describe("Include hidden files (default: false)"),
|
|
139
|
+
},
|
|
140
|
+
async ({ path, recursive, maxDepth, includeHidden }) => {
|
|
141
|
+
const target = path ?? process.cwd();
|
|
142
|
+
const depth = maxDepth ?? 2;
|
|
143
|
+
|
|
144
|
+
let command: string;
|
|
145
|
+
if (recursive) {
|
|
146
|
+
command = `find "${target}" -maxdepth ${depth} -not -path '*/node_modules/*' -not -path '*/.git/*' -not -path '*/dist/*' -not -path '*/.next/*'`;
|
|
147
|
+
if (!includeHidden) command += " -not -name '.*'";
|
|
148
|
+
} else {
|
|
149
|
+
command = includeHidden ? `ls -la "${target}"` : `ls -l "${target}"`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const result = await exec(command);
|
|
153
|
+
const parsed = parseOutput(command, result.stdout);
|
|
154
|
+
|
|
155
|
+
if (parsed) {
|
|
156
|
+
return {
|
|
157
|
+
content: [{ type: "text" as const, text: JSON.stringify({ cwd: target, ...parsed.data as object, parser: parsed.parser }) }],
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const files = result.stdout.split("\n").filter(l => l.trim());
|
|
162
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ cwd: target, files }) }] };
|
|
163
|
+
}
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
// ── explain_error: structured error diagnosis ─────────────────────────────
|
|
167
|
+
|
|
168
|
+
server.tool(
|
|
169
|
+
"explain_error",
|
|
170
|
+
"Parse error output and return structured diagnosis with root cause and fix suggestion.",
|
|
171
|
+
{
|
|
172
|
+
error: z.string().describe("Error output text"),
|
|
173
|
+
command: z.string().optional().describe("The command that produced the error"),
|
|
174
|
+
},
|
|
175
|
+
async ({ error, command }) => {
|
|
176
|
+
const { errorParser } = await import("../parsers/errors.js");
|
|
177
|
+
if (errorParser.detect(command ?? "", error)) {
|
|
178
|
+
const info = errorParser.parse(command ?? "", error);
|
|
179
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(info) }] };
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
content: [{ type: "text" as const, text: JSON.stringify({
|
|
183
|
+
type: "unknown", message: error.split("\n")[0]?.trim() ?? "Unknown error",
|
|
184
|
+
}) }],
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
// ── status: show server info ──────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
server.tool(
|
|
192
|
+
"status",
|
|
193
|
+
"Get open-terminal server status, capabilities, and available parsers.",
|
|
194
|
+
async () => {
|
|
195
|
+
return {
|
|
196
|
+
content: [{ type: "text" as const, text: JSON.stringify({
|
|
197
|
+
name: "open-terminal", version: "0.2.0", cwd: process.cwd(),
|
|
198
|
+
parsers: ["ls", "find", "test", "git-log", "git-status", "build", "npm-install", "error"],
|
|
199
|
+
features: ["structured-output", "token-compression", "ai-summary", "error-diagnosis"],
|
|
200
|
+
}) }],
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
// ── search_files: smart file search with auto-filtering ────────────────────
|
|
206
|
+
|
|
207
|
+
server.tool(
|
|
208
|
+
"search_files",
|
|
209
|
+
"Search for files by name pattern. Auto-filters node_modules, .git, dist. Returns categorized results (source, config, other) with token savings.",
|
|
210
|
+
{
|
|
211
|
+
pattern: z.string().describe("Glob pattern (e.g., '*hooks*', '*.test.ts')"),
|
|
212
|
+
path: z.string().optional().describe("Search root (default: cwd)"),
|
|
213
|
+
includeNodeModules: z.boolean().optional().describe("Include node_modules (default: false)"),
|
|
214
|
+
maxResults: z.number().optional().describe("Max results per category (default: 50)"),
|
|
215
|
+
},
|
|
216
|
+
async ({ pattern, path, includeNodeModules, maxResults }) => {
|
|
217
|
+
const result = await searchFiles(pattern, path ?? process.cwd(), { includeNodeModules, maxResults });
|
|
218
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(result) }] };
|
|
219
|
+
}
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
// ── search_content: smart grep with grouping ──────────────────────────────
|
|
223
|
+
|
|
224
|
+
server.tool(
|
|
225
|
+
"search_content",
|
|
226
|
+
"Search file contents by regex pattern. Groups matches by file, sorted by relevance. Auto-filters excluded directories.",
|
|
227
|
+
{
|
|
228
|
+
pattern: z.string().describe("Search pattern (regex)"),
|
|
229
|
+
path: z.string().optional().describe("Search root (default: cwd)"),
|
|
230
|
+
fileType: z.string().optional().describe("File type filter (e.g., 'ts', 'py')"),
|
|
231
|
+
maxResults: z.number().optional().describe("Max files to return (default: 30)"),
|
|
232
|
+
contextLines: z.number().optional().describe("Context lines around matches (default: 0)"),
|
|
233
|
+
},
|
|
234
|
+
async ({ pattern, path, fileType, maxResults, contextLines }) => {
|
|
235
|
+
const result = await searchContent(pattern, path ?? process.cwd(), { fileType, maxResults, contextLines });
|
|
236
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(result) }] };
|
|
237
|
+
}
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
// ── list_recipes: list saved command recipes ──────────────────────────────
|
|
241
|
+
|
|
242
|
+
server.tool(
|
|
243
|
+
"list_recipes",
|
|
244
|
+
"List saved command recipes. Optionally filter by collection or project.",
|
|
245
|
+
{
|
|
246
|
+
collection: z.string().optional().describe("Filter by collection name"),
|
|
247
|
+
project: z.string().optional().describe("Project path for project-scoped recipes"),
|
|
248
|
+
},
|
|
249
|
+
async ({ collection, project }) => {
|
|
250
|
+
let recipes = listRecipes(project);
|
|
251
|
+
if (collection) recipes = recipes.filter(r => r.collection === collection);
|
|
252
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(recipes) }] };
|
|
253
|
+
}
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
// ── run_recipe: execute a saved recipe ────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
server.tool(
|
|
259
|
+
"run_recipe",
|
|
260
|
+
"Run a saved recipe by name with optional variable substitution.",
|
|
261
|
+
{
|
|
262
|
+
name: z.string().describe("Recipe name"),
|
|
263
|
+
variables: z.record(z.string(), z.string()).optional().describe("Variable values: {port: '3000'}"),
|
|
264
|
+
cwd: z.string().optional().describe("Working directory"),
|
|
265
|
+
format: z.enum(["raw", "json", "compressed"]).optional().describe("Output format"),
|
|
266
|
+
},
|
|
267
|
+
async ({ name, variables, cwd, format }) => {
|
|
268
|
+
const recipe = getRecipe(name, cwd);
|
|
269
|
+
if (!recipe) {
|
|
270
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ error: `Recipe '${name}' not found` }) }] };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const command = variables ? substituteVariables(recipe.command, variables) : recipe.command;
|
|
274
|
+
const result = await exec(command, cwd, 30000);
|
|
275
|
+
const output = (result.stdout + result.stderr).trim();
|
|
276
|
+
|
|
277
|
+
if (format === "json") {
|
|
278
|
+
const parsed = parseOutput(command, output);
|
|
279
|
+
if (parsed) {
|
|
280
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
281
|
+
recipe: name, exitCode: result.exitCode, parsed: parsed.data, duration: result.duration,
|
|
282
|
+
}) }] };
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (format === "compressed") {
|
|
287
|
+
const compressed = compress(command, output, { format: "json" });
|
|
288
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
289
|
+
recipe: name, exitCode: result.exitCode, output: compressed.content, duration: result.duration,
|
|
290
|
+
tokensSaved: compressed.tokensSaved,
|
|
291
|
+
}) }] };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
295
|
+
recipe: name, exitCode: result.exitCode, output: stripAnsi(output), duration: result.duration,
|
|
296
|
+
}) }] };
|
|
297
|
+
}
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
// ── save_recipe: save a new recipe ────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
server.tool(
|
|
303
|
+
"save_recipe",
|
|
304
|
+
"Save a reusable command recipe. Variables in commands use {name} syntax.",
|
|
305
|
+
{
|
|
306
|
+
name: z.string().describe("Recipe name"),
|
|
307
|
+
command: z.string().describe("Shell command (use {var} for variables)"),
|
|
308
|
+
description: z.string().optional().describe("Description"),
|
|
309
|
+
collection: z.string().optional().describe("Collection to add to"),
|
|
310
|
+
project: z.string().optional().describe("Project path (for project-scoped recipe)"),
|
|
311
|
+
tags: z.array(z.string()).optional().describe("Tags"),
|
|
312
|
+
},
|
|
313
|
+
async ({ name, command, description, collection, project, tags }) => {
|
|
314
|
+
const recipe = createRecipe({ name, command, description, collection, project, tags });
|
|
315
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(recipe) }] };
|
|
316
|
+
}
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
// ── list_collections: list recipe collections ─────────────────────────────
|
|
320
|
+
|
|
321
|
+
server.tool(
|
|
322
|
+
"list_collections",
|
|
323
|
+
"List recipe collections.",
|
|
324
|
+
{
|
|
325
|
+
project: z.string().optional().describe("Project path"),
|
|
326
|
+
},
|
|
327
|
+
async ({ project }) => {
|
|
328
|
+
const collections = listCollections(project);
|
|
329
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(collections) }] };
|
|
330
|
+
}
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
// ── bg_start: start a background process ───────────────────────────────────
|
|
334
|
+
|
|
335
|
+
server.tool(
|
|
336
|
+
"bg_start",
|
|
337
|
+
"Start a background process (e.g., dev server). Auto-detects port from command.",
|
|
338
|
+
{
|
|
339
|
+
command: z.string().describe("Command to run in background"),
|
|
340
|
+
cwd: z.string().optional().describe("Working directory"),
|
|
341
|
+
},
|
|
342
|
+
async ({ command, cwd }) => {
|
|
343
|
+
const result = bgStart(command, cwd);
|
|
344
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(result) }] };
|
|
345
|
+
}
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
// ── bg_status: list background processes ──────────────────────────────────
|
|
349
|
+
|
|
350
|
+
server.tool(
|
|
351
|
+
"bg_status",
|
|
352
|
+
"List all managed background processes with status, ports, and recent output.",
|
|
353
|
+
async () => {
|
|
354
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(bgStatus()) }] };
|
|
355
|
+
}
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
// ── bg_stop: stop a background process ────────────────────────────────────
|
|
359
|
+
|
|
360
|
+
server.tool(
|
|
361
|
+
"bg_stop",
|
|
362
|
+
"Stop a managed background process by PID.",
|
|
363
|
+
{ pid: z.number().describe("Process ID to stop") },
|
|
364
|
+
async ({ pid }) => {
|
|
365
|
+
const ok = bgStop(pid);
|
|
366
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ stopped: ok, pid }) }] };
|
|
367
|
+
}
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
// ── bg_logs: get process output ───────────────────────────────────────────
|
|
371
|
+
|
|
372
|
+
server.tool(
|
|
373
|
+
"bg_logs",
|
|
374
|
+
"Get recent output lines from a background process.",
|
|
375
|
+
{
|
|
376
|
+
pid: z.number().describe("Process ID"),
|
|
377
|
+
tail: z.number().optional().describe("Number of lines (default: 20)"),
|
|
378
|
+
},
|
|
379
|
+
async ({ pid, tail }) => {
|
|
380
|
+
const lines = bgLogs(pid, tail);
|
|
381
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ pid, lines }) }] };
|
|
382
|
+
}
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
// ── bg_wait_port: wait for port to be ready ───────────────────────────────
|
|
386
|
+
|
|
387
|
+
server.tool(
|
|
388
|
+
"bg_wait_port",
|
|
389
|
+
"Wait for a port to start accepting connections. Useful after starting a dev server.",
|
|
390
|
+
{
|
|
391
|
+
port: z.number().describe("Port number to wait for"),
|
|
392
|
+
timeout: z.number().optional().describe("Timeout in ms (default: 30000)"),
|
|
393
|
+
},
|
|
394
|
+
async ({ port, timeout }) => {
|
|
395
|
+
const ready = await bgWaitPort(port, timeout);
|
|
396
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ port, ready }) }] };
|
|
397
|
+
}
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
// ── execute_diff: run command with diff from last run ───────────────────────
|
|
401
|
+
|
|
402
|
+
server.tool(
|
|
403
|
+
"execute_diff",
|
|
404
|
+
"Run a command and return diff from its last execution. Ideal for edit→test loops — only shows what changed.",
|
|
405
|
+
{
|
|
406
|
+
command: z.string().describe("Shell command to execute"),
|
|
407
|
+
cwd: z.string().optional().describe("Working directory"),
|
|
408
|
+
timeout: z.number().optional().describe("Timeout in ms"),
|
|
409
|
+
},
|
|
410
|
+
async ({ command, cwd, timeout }) => {
|
|
411
|
+
const workDir = cwd ?? process.cwd();
|
|
412
|
+
const result = await exec(command, workDir, timeout ?? 30000);
|
|
413
|
+
const output = (result.stdout + result.stderr).trim();
|
|
414
|
+
const diff = diffOutput(command, workDir, output);
|
|
415
|
+
|
|
416
|
+
if (diff.tokensSaved > 0) {
|
|
417
|
+
recordSaving("diff", diff.tokensSaved);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (diff.unchanged) {
|
|
421
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
422
|
+
exitCode: result.exitCode, unchanged: true, diffSummary: diff.diffSummary,
|
|
423
|
+
duration: result.duration, tokensSaved: diff.tokensSaved,
|
|
424
|
+
}) }] };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (diff.hasPrevious) {
|
|
428
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
429
|
+
exitCode: result.exitCode, diffSummary: diff.diffSummary,
|
|
430
|
+
added: diff.added.slice(0, 50), removed: diff.removed.slice(0, 50),
|
|
431
|
+
duration: result.duration, tokensSaved: diff.tokensSaved,
|
|
432
|
+
}) }] };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// First run — return full output
|
|
436
|
+
const compressed = compress(command, output, { format: "json" });
|
|
437
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
438
|
+
exitCode: result.exitCode, output: compressed.content,
|
|
439
|
+
diffSummary: "first run", duration: result.duration,
|
|
440
|
+
}) }] };
|
|
441
|
+
}
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
// ── token_stats: economy dashboard ────────────────────────────────────────
|
|
445
|
+
|
|
446
|
+
server.tool(
|
|
447
|
+
"token_stats",
|
|
448
|
+
"Get token economy stats — how many tokens have been saved by structured output, compression, diffing, and caching.",
|
|
449
|
+
async () => {
|
|
450
|
+
const stats = getEconomyStats();
|
|
451
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(stats) }] };
|
|
452
|
+
}
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
// ── snapshot: capture terminal state ──────────────────────────────────────
|
|
456
|
+
|
|
457
|
+
server.tool(
|
|
458
|
+
"snapshot",
|
|
459
|
+
"Capture a compact snapshot of terminal state (cwd, env, running processes, recent commands, recipes). Useful for agent context handoff.",
|
|
460
|
+
async () => {
|
|
461
|
+
const snap = captureSnapshot();
|
|
462
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(snap) }] };
|
|
463
|
+
}
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
return server;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ── main: start MCP server via stdio ─────────────────────────────────────────
|
|
470
|
+
|
|
471
|
+
export async function startMcpServer(): Promise<void> {
|
|
472
|
+
const server = createServer();
|
|
473
|
+
const transport = new StdioServerTransport();
|
|
474
|
+
await server.connect(transport);
|
|
475
|
+
console.error("open-terminal MCP server running on stdio");
|
|
476
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Output intelligence router — auto-detect command type and optimize output
|
|
2
|
+
|
|
3
|
+
import { parseOutput, estimateTokens } from "./parsers/index.js";
|
|
4
|
+
import { compress, stripAnsi } from "./compression.js";
|
|
5
|
+
import { recordSaving } from "./economy.js";
|
|
6
|
+
|
|
7
|
+
export interface RouterResult {
|
|
8
|
+
raw: string;
|
|
9
|
+
structured?: unknown;
|
|
10
|
+
compressed?: string;
|
|
11
|
+
parser?: string;
|
|
12
|
+
tokensSaved: number;
|
|
13
|
+
format: "raw" | "json" | "compressed";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Route command output through the best optimization path */
|
|
17
|
+
export function routeOutput(command: string, output: string, maxTokens?: number): RouterResult {
|
|
18
|
+
const clean = stripAnsi(output);
|
|
19
|
+
const rawTokens = estimateTokens(clean);
|
|
20
|
+
|
|
21
|
+
// Try structured parsing first
|
|
22
|
+
const parsed = parseOutput(command, clean);
|
|
23
|
+
if (parsed) {
|
|
24
|
+
const json = JSON.stringify(parsed.data);
|
|
25
|
+
const jsonTokens = estimateTokens(json);
|
|
26
|
+
const saved = rawTokens - jsonTokens;
|
|
27
|
+
|
|
28
|
+
if (saved > 0) {
|
|
29
|
+
recordSaving("structured", saved);
|
|
30
|
+
return {
|
|
31
|
+
raw: clean,
|
|
32
|
+
structured: parsed.data,
|
|
33
|
+
parser: parsed.parser,
|
|
34
|
+
tokensSaved: saved,
|
|
35
|
+
format: "json",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Try compression if structured didn't save enough
|
|
41
|
+
if (maxTokens || rawTokens > 200) {
|
|
42
|
+
const compressed = compress(command, clean, { maxTokens, format: "text" });
|
|
43
|
+
if (compressed.tokensSaved > 0) {
|
|
44
|
+
recordSaving("compressed", compressed.tokensSaved);
|
|
45
|
+
return {
|
|
46
|
+
raw: clean,
|
|
47
|
+
compressed: compressed.content,
|
|
48
|
+
tokensSaved: compressed.tokensSaved,
|
|
49
|
+
format: "compressed",
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Return raw if no optimization helps
|
|
55
|
+
return { raw: clean, tokensSaved: 0, format: "raw" };
|
|
56
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Base types for output parsers
|
|
2
|
+
|
|
3
|
+
export interface Parser<T = unknown> {
|
|
4
|
+
/** Name of this parser */
|
|
5
|
+
readonly name: string;
|
|
6
|
+
|
|
7
|
+
/** Test if this parser can handle the given command/output */
|
|
8
|
+
detect(command: string, output: string): boolean;
|
|
9
|
+
|
|
10
|
+
/** Parse the output into structured data */
|
|
11
|
+
parse(command: string, output: string): T;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface FileEntry {
|
|
15
|
+
name: string;
|
|
16
|
+
type: "file" | "dir" | "symlink" | "other";
|
|
17
|
+
size?: number;
|
|
18
|
+
modified?: string;
|
|
19
|
+
permissions?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface TestResult {
|
|
23
|
+
passed: number;
|
|
24
|
+
failed: number;
|
|
25
|
+
skipped: number;
|
|
26
|
+
total: number;
|
|
27
|
+
duration?: string;
|
|
28
|
+
failures: { test: string; error: string }[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface GitLogEntry {
|
|
32
|
+
hash: string;
|
|
33
|
+
author: string;
|
|
34
|
+
date: string;
|
|
35
|
+
message: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface GitStatus {
|
|
39
|
+
branch: string;
|
|
40
|
+
staged: string[];
|
|
41
|
+
unstaged: string[];
|
|
42
|
+
untracked: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface BuildResult {
|
|
46
|
+
status: "success" | "failure";
|
|
47
|
+
warnings: number;
|
|
48
|
+
errors: number;
|
|
49
|
+
duration?: string;
|
|
50
|
+
output?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface NpmInstallResult {
|
|
54
|
+
installed: number;
|
|
55
|
+
duration?: string;
|
|
56
|
+
vulnerabilities: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ErrorInfo {
|
|
60
|
+
type: string;
|
|
61
|
+
message: string;
|
|
62
|
+
file?: string;
|
|
63
|
+
line?: number;
|
|
64
|
+
suggestion?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface SearchResult {
|
|
68
|
+
total: number;
|
|
69
|
+
source: FileEntry[];
|
|
70
|
+
other: FileEntry[];
|
|
71
|
+
filtered: { count: number; reason: string }[];
|
|
72
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Parser for build output (npm/bun/pnpm build, tsc, webpack, vite, etc.)
|
|
2
|
+
|
|
3
|
+
import type { Parser, BuildResult, NpmInstallResult } from "./base.js";
|
|
4
|
+
|
|
5
|
+
export const buildParser: Parser<BuildResult> = {
|
|
6
|
+
name: "build",
|
|
7
|
+
|
|
8
|
+
detect(command: string, output: string): boolean {
|
|
9
|
+
if (/\b(npm|bun|pnpm|yarn)\s+(run\s+)?build\b/.test(command)) return true;
|
|
10
|
+
if (/\btsc\b/.test(command)) return true;
|
|
11
|
+
if (/\b(webpack|vite|esbuild|rollup|turbo)\b/.test(command)) return true;
|
|
12
|
+
return /\b(compiled|bundled|built)\b/i.test(output) && /\b(success|error|warning)\b/i.test(output);
|
|
13
|
+
},
|
|
14
|
+
|
|
15
|
+
parse(_command: string, output: string): BuildResult {
|
|
16
|
+
const lines = output.split("\n");
|
|
17
|
+
let warnings = 0, errors = 0, duration: string | undefined;
|
|
18
|
+
|
|
19
|
+
// Count warnings and errors
|
|
20
|
+
for (const line of lines) {
|
|
21
|
+
if (/\bwarning\b/i.test(line)) warnings++;
|
|
22
|
+
if (/\berror\b/i.test(line) && !/0 errors/.test(line)) errors++;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Specific patterns
|
|
26
|
+
const tscErrors = output.match(/Found (\d+) error/);
|
|
27
|
+
if (tscErrors) errors = parseInt(tscErrors[1]);
|
|
28
|
+
|
|
29
|
+
const warningCount = output.match(/(\d+)\s+warning/);
|
|
30
|
+
if (warningCount) warnings = parseInt(warningCount[1]);
|
|
31
|
+
|
|
32
|
+
// Duration
|
|
33
|
+
const timeMatch = output.match(/(?:in|took)\s+([\d.]+\s*(?:s|ms|m))/i) ||
|
|
34
|
+
output.match(/Done in ([\d.]+s)/);
|
|
35
|
+
if (timeMatch) duration = timeMatch[1];
|
|
36
|
+
|
|
37
|
+
const status: "success" | "failure" = errors > 0 ? "failure" : "success";
|
|
38
|
+
|
|
39
|
+
return { status, warnings, errors, duration };
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const npmInstallParser: Parser<NpmInstallResult> = {
|
|
44
|
+
name: "npm-install",
|
|
45
|
+
|
|
46
|
+
detect(command: string, _output: string): boolean {
|
|
47
|
+
return /\b(npm|bun|pnpm|yarn)\s+(install|add|i)\b/.test(command);
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
parse(_command: string, output: string): NpmInstallResult {
|
|
51
|
+
let installed = 0, vulnerabilities = 0, duration: string | undefined;
|
|
52
|
+
|
|
53
|
+
// npm: added 47 packages in 3.2s
|
|
54
|
+
const npmMatch = output.match(/added\s+(\d+)\s+packages?\s+in\s+([\d.]+s)/);
|
|
55
|
+
if (npmMatch) {
|
|
56
|
+
installed = parseInt(npmMatch[1]);
|
|
57
|
+
duration = npmMatch[2];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// bun: 47 packages installed [1.2s]
|
|
61
|
+
const bunMatch = output.match(/(\d+)\s+packages?\s+installed.*?\[([\d.]+[ms]*s)\]/);
|
|
62
|
+
if (!npmMatch && bunMatch) {
|
|
63
|
+
installed = parseInt(bunMatch[1]);
|
|
64
|
+
duration = bunMatch[2];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Vulnerabilities
|
|
68
|
+
const vulnMatch = output.match(/(\d+)\s+vulnerabilit/);
|
|
69
|
+
if (vulnMatch) vulnerabilities = parseInt(vulnMatch[1]);
|
|
70
|
+
|
|
71
|
+
return { installed, vulnerabilities, duration };
|
|
72
|
+
},
|
|
73
|
+
};
|