@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,87 @@
|
|
|
1
|
+
// Diff-aware output caching — when same command runs again, return only what changed
|
|
2
|
+
import { estimateTokens } from "./parsers/index.js";
|
|
3
|
+
const cache = new Map();
|
|
4
|
+
function cacheKey(command, cwd) {
|
|
5
|
+
return `${cwd}:${command}`;
|
|
6
|
+
}
|
|
7
|
+
/** Compute a simple line diff between two outputs */
|
|
8
|
+
function lineDiff(prev, curr) {
|
|
9
|
+
const prevLines = new Set(prev.split("\n"));
|
|
10
|
+
const currLines = curr.split("\n");
|
|
11
|
+
const added = [];
|
|
12
|
+
const removed = [];
|
|
13
|
+
let unchanged = 0;
|
|
14
|
+
for (const line of currLines) {
|
|
15
|
+
if (prevLines.has(line)) {
|
|
16
|
+
unchanged++;
|
|
17
|
+
prevLines.delete(line);
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
added.push(line);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
for (const line of prevLines) {
|
|
24
|
+
removed.push(line);
|
|
25
|
+
}
|
|
26
|
+
return { added, removed, unchanged };
|
|
27
|
+
}
|
|
28
|
+
/** Generate a human-readable diff summary */
|
|
29
|
+
function summarizeDiff(diff) {
|
|
30
|
+
const parts = [];
|
|
31
|
+
if (diff.added.length > 0)
|
|
32
|
+
parts.push(`+${diff.added.length} new lines`);
|
|
33
|
+
if (diff.removed.length > 0)
|
|
34
|
+
parts.push(`-${diff.removed.length} removed lines`);
|
|
35
|
+
parts.push(`${diff.unchanged} unchanged`);
|
|
36
|
+
return parts.join(", ");
|
|
37
|
+
}
|
|
38
|
+
/** Run diffing on command output. Caches the output for next comparison. */
|
|
39
|
+
export function diffOutput(command, cwd, output) {
|
|
40
|
+
const key = cacheKey(command, cwd);
|
|
41
|
+
const prev = cache.get(key);
|
|
42
|
+
// Store current for next time
|
|
43
|
+
cache.set(key, { command, cwd, output, timestamp: Date.now() });
|
|
44
|
+
if (!prev) {
|
|
45
|
+
return {
|
|
46
|
+
full: output,
|
|
47
|
+
hasPrevious: false,
|
|
48
|
+
added: [],
|
|
49
|
+
removed: [],
|
|
50
|
+
diffSummary: "first run",
|
|
51
|
+
unchanged: false,
|
|
52
|
+
tokensSaved: 0,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (prev.output === output) {
|
|
56
|
+
const fullTokens = estimateTokens(output);
|
|
57
|
+
return {
|
|
58
|
+
full: output,
|
|
59
|
+
hasPrevious: true,
|
|
60
|
+
added: [],
|
|
61
|
+
removed: [],
|
|
62
|
+
diffSummary: "identical to previous run",
|
|
63
|
+
unchanged: true,
|
|
64
|
+
tokensSaved: fullTokens - 10, // ~10 tokens for the "unchanged" message
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const diff = lineDiff(prev.output, output);
|
|
68
|
+
const diffContent = [
|
|
69
|
+
...diff.added.map(l => `+ ${l}`),
|
|
70
|
+
...diff.removed.map(l => `- ${l}`),
|
|
71
|
+
].join("\n");
|
|
72
|
+
const fullTokens = estimateTokens(output);
|
|
73
|
+
const diffTokens = estimateTokens(diffContent);
|
|
74
|
+
return {
|
|
75
|
+
full: output,
|
|
76
|
+
hasPrevious: true,
|
|
77
|
+
added: diff.added,
|
|
78
|
+
removed: diff.removed,
|
|
79
|
+
diffSummary: summarizeDiff(diff),
|
|
80
|
+
unchanged: false,
|
|
81
|
+
tokensSaved: Math.max(0, fullTokens - diffTokens),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/** Clear the diff cache */
|
|
85
|
+
export function clearDiffCache() {
|
|
86
|
+
cache.clear();
|
|
87
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { diffOutput, clearDiffCache } from "./diff-cache.js";
|
|
3
|
+
describe("diffOutput", () => {
|
|
4
|
+
it("returns first run with no previous", () => {
|
|
5
|
+
clearDiffCache();
|
|
6
|
+
const result = diffOutput("npm test", "/tmp", "PASS\n5 passed");
|
|
7
|
+
expect(result.hasPrevious).toBe(false);
|
|
8
|
+
expect(result.diffSummary).toBe("first run");
|
|
9
|
+
expect(result.tokensSaved).toBe(0);
|
|
10
|
+
});
|
|
11
|
+
it("detects identical output", () => {
|
|
12
|
+
clearDiffCache();
|
|
13
|
+
diffOutput("npm test", "/tmp/id", "PASS\n5 passed");
|
|
14
|
+
const result = diffOutput("npm test", "/tmp/id", "PASS\n5 passed");
|
|
15
|
+
expect(result.unchanged).toBe(true);
|
|
16
|
+
expect(result.diffSummary).toBe("identical to previous run");
|
|
17
|
+
});
|
|
18
|
+
it("computes diff for changed output", () => {
|
|
19
|
+
clearDiffCache();
|
|
20
|
+
diffOutput("npm test", "/tmp/diff", "PASS test1\nPASS test2\nFAIL test3");
|
|
21
|
+
const result = diffOutput("npm test", "/tmp/diff", "PASS test1\nPASS test2\nPASS test3");
|
|
22
|
+
expect(result.hasPrevious).toBe(true);
|
|
23
|
+
expect(result.unchanged).toBe(false);
|
|
24
|
+
expect(result.added).toContain("PASS test3");
|
|
25
|
+
expect(result.removed).toContain("FAIL test3");
|
|
26
|
+
});
|
|
27
|
+
});
|
package/dist/economy.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Token economy tracker — tracks token savings across all interactions
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
const DIR = join(homedir(), ".terminal");
|
|
6
|
+
const ECONOMY_FILE = join(DIR, "economy.json");
|
|
7
|
+
let stats = null;
|
|
8
|
+
function ensureDir() {
|
|
9
|
+
if (!existsSync(DIR))
|
|
10
|
+
mkdirSync(DIR, { recursive: true });
|
|
11
|
+
}
|
|
12
|
+
function loadStats() {
|
|
13
|
+
if (stats)
|
|
14
|
+
return stats;
|
|
15
|
+
ensureDir();
|
|
16
|
+
if (existsSync(ECONOMY_FILE)) {
|
|
17
|
+
try {
|
|
18
|
+
const saved = JSON.parse(readFileSync(ECONOMY_FILE, "utf8"));
|
|
19
|
+
stats = {
|
|
20
|
+
totalTokensSaved: saved.totalTokensSaved ?? 0,
|
|
21
|
+
totalTokensUsed: saved.totalTokensUsed ?? 0,
|
|
22
|
+
savingsByFeature: {
|
|
23
|
+
structured: saved.savingsByFeature?.structured ?? 0,
|
|
24
|
+
compressed: saved.savingsByFeature?.compressed ?? 0,
|
|
25
|
+
diff: saved.savingsByFeature?.diff ?? 0,
|
|
26
|
+
cache: saved.savingsByFeature?.cache ?? 0,
|
|
27
|
+
search: saved.savingsByFeature?.search ?? 0,
|
|
28
|
+
},
|
|
29
|
+
sessionStart: Date.now(),
|
|
30
|
+
sessionSaved: 0,
|
|
31
|
+
sessionUsed: 0,
|
|
32
|
+
};
|
|
33
|
+
return stats;
|
|
34
|
+
}
|
|
35
|
+
catch { }
|
|
36
|
+
}
|
|
37
|
+
stats = {
|
|
38
|
+
totalTokensSaved: 0,
|
|
39
|
+
totalTokensUsed: 0,
|
|
40
|
+
savingsByFeature: { structured: 0, compressed: 0, diff: 0, cache: 0, search: 0 },
|
|
41
|
+
sessionStart: Date.now(),
|
|
42
|
+
sessionSaved: 0,
|
|
43
|
+
sessionUsed: 0,
|
|
44
|
+
};
|
|
45
|
+
return stats;
|
|
46
|
+
}
|
|
47
|
+
function saveStats() {
|
|
48
|
+
ensureDir();
|
|
49
|
+
if (stats) {
|
|
50
|
+
writeFileSync(ECONOMY_FILE, JSON.stringify(stats, null, 2));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Record token savings from a feature */
|
|
54
|
+
export function recordSaving(feature, tokensSaved) {
|
|
55
|
+
const s = loadStats();
|
|
56
|
+
s.totalTokensSaved += tokensSaved;
|
|
57
|
+
s.sessionSaved += tokensSaved;
|
|
58
|
+
s.savingsByFeature[feature] += tokensSaved;
|
|
59
|
+
saveStats();
|
|
60
|
+
}
|
|
61
|
+
/** Record tokens used (for AI calls) */
|
|
62
|
+
export function recordUsage(tokens) {
|
|
63
|
+
const s = loadStats();
|
|
64
|
+
s.totalTokensUsed += tokens;
|
|
65
|
+
s.sessionUsed += tokens;
|
|
66
|
+
saveStats();
|
|
67
|
+
}
|
|
68
|
+
/** Get current economy stats */
|
|
69
|
+
export function getEconomyStats() {
|
|
70
|
+
return { ...loadStats() };
|
|
71
|
+
}
|
|
72
|
+
/** Format token count for display */
|
|
73
|
+
export function formatTokens(n) {
|
|
74
|
+
if (n >= 1_000_000)
|
|
75
|
+
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
76
|
+
if (n >= 1_000)
|
|
77
|
+
return `${(n / 1_000).toFixed(1)}K`;
|
|
78
|
+
return `${n}`;
|
|
79
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { formatTokens } from "./economy.js";
|
|
3
|
+
describe("formatTokens", () => {
|
|
4
|
+
it("formats small numbers", () => {
|
|
5
|
+
expect(formatTokens(42)).toBe("42");
|
|
6
|
+
});
|
|
7
|
+
it("formats thousands", () => {
|
|
8
|
+
expect(formatTokens(1500)).toBe("1.5K");
|
|
9
|
+
});
|
|
10
|
+
it("formats millions", () => {
|
|
11
|
+
expect(formatTokens(2500000)).toBe("2.5M");
|
|
12
|
+
});
|
|
13
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// MCP installation helper — register open-terminal as MCP server for various agents
|
|
2
|
+
import { execSync } from "child_process";
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
4
|
+
import { homedir } from "os";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
const TERMINAL_BIN = "terminal"; // the CLI binary name
|
|
7
|
+
function which(cmd) {
|
|
8
|
+
try {
|
|
9
|
+
return execSync(`which ${cmd}`, { encoding: "utf8" }).trim();
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function installClaude() {
|
|
16
|
+
try {
|
|
17
|
+
execSync(`claude mcp add --transport stdio --scope user open-terminal -- ${which(TERMINAL_BIN) ?? "npx"} ${which(TERMINAL_BIN) ? "mcp serve" : "@hasna/terminal mcp serve"}`, { stdio: "inherit" });
|
|
18
|
+
console.log("✓ Installed open-terminal MCP server for Claude Code");
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
catch (e) {
|
|
22
|
+
console.error("Failed to install for Claude Code:", e);
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function installCodex() {
|
|
27
|
+
const configPath = join(homedir(), ".codex", "config.toml");
|
|
28
|
+
try {
|
|
29
|
+
let content = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
|
|
30
|
+
if (content.includes("[mcp_servers.open-terminal]")) {
|
|
31
|
+
console.log("✓ open-terminal already configured for Codex");
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
const bin = which(TERMINAL_BIN) ?? "npx @hasna/terminal";
|
|
35
|
+
content += `\n[mcp_servers.open-terminal]\ncommand = "${bin}"\nargs = ["mcp", "serve"]\n`;
|
|
36
|
+
writeFileSync(configPath, content);
|
|
37
|
+
console.log("✓ Installed open-terminal MCP server for Codex");
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
console.error("Failed to install for Codex:", e);
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function installGemini() {
|
|
46
|
+
const configPath = join(homedir(), ".gemini", "settings.json");
|
|
47
|
+
try {
|
|
48
|
+
let config = {};
|
|
49
|
+
if (existsSync(configPath)) {
|
|
50
|
+
config = JSON.parse(readFileSync(configPath, "utf8"));
|
|
51
|
+
}
|
|
52
|
+
if (!config.mcpServers)
|
|
53
|
+
config.mcpServers = {};
|
|
54
|
+
const bin = which(TERMINAL_BIN) ?? "npx";
|
|
55
|
+
const args = which(TERMINAL_BIN) ? ["mcp", "serve"] : ["@hasna/terminal", "mcp", "serve"];
|
|
56
|
+
config.mcpServers["open-terminal"] = { command: bin, args };
|
|
57
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
58
|
+
console.log("✓ Installed open-terminal MCP server for Gemini");
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
console.error("Failed to install for Gemini:", e);
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export function installAll() {
|
|
67
|
+
installClaude();
|
|
68
|
+
installCodex();
|
|
69
|
+
installGemini();
|
|
70
|
+
}
|
|
71
|
+
export function handleMcpInstall(args) {
|
|
72
|
+
const flags = new Set(args);
|
|
73
|
+
if (flags.has("--all")) {
|
|
74
|
+
installAll();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (flags.has("--claude")) {
|
|
78
|
+
installClaude();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (flags.has("--codex")) {
|
|
82
|
+
installCodex();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (flags.has("--gemini")) {
|
|
86
|
+
installGemini();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
console.log("Usage: t mcp install [--claude|--codex|--gemini|--all]");
|
|
90
|
+
console.log("");
|
|
91
|
+
console.log("Install open-terminal as an MCP server for AI coding agents.");
|
|
92
|
+
console.log("");
|
|
93
|
+
console.log("Options:");
|
|
94
|
+
console.log(" --claude Install for Claude Code");
|
|
95
|
+
console.log(" --codex Install for OpenAI Codex");
|
|
96
|
+
console.log(" --gemini Install for Gemini CLI");
|
|
97
|
+
console.log(" --all Install for all agents");
|
|
98
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// MCP Server for open-terminal — exposes terminal capabilities to AI agents
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { spawn } from "child_process";
|
|
6
|
+
import { compress, stripAnsi } from "../compression.js";
|
|
7
|
+
import { parseOutput, tokenSavings, estimateTokens } from "../parsers/index.js";
|
|
8
|
+
import { summarizeOutput } from "../ai.js";
|
|
9
|
+
import { searchFiles, searchContent } from "../search/index.js";
|
|
10
|
+
import { listRecipes, listCollections, getRecipe, createRecipe } from "../recipes/storage.js";
|
|
11
|
+
import { substituteVariables } from "../recipes/model.js";
|
|
12
|
+
import { bgStart, bgStatus, bgStop, bgLogs, bgWaitPort } from "../supervisor.js";
|
|
13
|
+
import { diffOutput } from "../diff-cache.js";
|
|
14
|
+
import { getEconomyStats, recordSaving } from "../economy.js";
|
|
15
|
+
import { captureSnapshot } from "../snapshots.js";
|
|
16
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
17
|
+
function exec(command, cwd, timeout) {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
const start = Date.now();
|
|
20
|
+
const proc = spawn("/bin/zsh", ["-c", command], {
|
|
21
|
+
cwd: cwd ?? process.cwd(),
|
|
22
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
23
|
+
});
|
|
24
|
+
let stdout = "";
|
|
25
|
+
let stderr = "";
|
|
26
|
+
proc.stdout?.on("data", (d) => { stdout += d.toString(); });
|
|
27
|
+
proc.stderr?.on("data", (d) => { stderr += d.toString(); });
|
|
28
|
+
const timer = timeout ? setTimeout(() => { try {
|
|
29
|
+
proc.kill("SIGTERM");
|
|
30
|
+
}
|
|
31
|
+
catch { } }, timeout) : null;
|
|
32
|
+
proc.on("close", (code) => {
|
|
33
|
+
if (timer)
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
resolve({ exitCode: code ?? 0, stdout, stderr, duration: Date.now() - start });
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
// ── server ───────────────────────────────────────────────────────────────────
|
|
40
|
+
export function createServer() {
|
|
41
|
+
const server = new McpServer({
|
|
42
|
+
name: "open-terminal",
|
|
43
|
+
version: "0.2.0",
|
|
44
|
+
});
|
|
45
|
+
// ── execute: run a command, return structured result ──────────────────────
|
|
46
|
+
server.tool("execute", "Run a shell command and return the result. Supports structured output parsing (json), token compression (compressed), and AI summarization (summary).", {
|
|
47
|
+
command: z.string().describe("Shell command to execute"),
|
|
48
|
+
cwd: z.string().optional().describe("Working directory (default: server cwd)"),
|
|
49
|
+
timeout: z.number().optional().describe("Timeout in ms (default: 30000)"),
|
|
50
|
+
format: z.enum(["raw", "json", "compressed", "summary"]).optional().describe("Output format"),
|
|
51
|
+
maxTokens: z.number().optional().describe("Token budget for compressed/summary format"),
|
|
52
|
+
}, async ({ command, cwd, timeout, format, maxTokens }) => {
|
|
53
|
+
const result = await exec(command, cwd, timeout ?? 30000);
|
|
54
|
+
const output = (result.stdout + result.stderr).trim();
|
|
55
|
+
// Raw mode
|
|
56
|
+
if (!format || format === "raw") {
|
|
57
|
+
const clean = stripAnsi(output);
|
|
58
|
+
return {
|
|
59
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
60
|
+
exitCode: result.exitCode, output: clean, duration: result.duration, tokens: estimateTokens(clean),
|
|
61
|
+
}) }],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
// JSON mode — structured parsing
|
|
65
|
+
if (format === "json") {
|
|
66
|
+
const parsed = parseOutput(command, output);
|
|
67
|
+
if (parsed) {
|
|
68
|
+
const savings = tokenSavings(output, parsed.data);
|
|
69
|
+
return {
|
|
70
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
71
|
+
exitCode: result.exitCode, parsed: parsed.data, parser: parsed.parser,
|
|
72
|
+
duration: result.duration, tokensSaved: savings.saved, savingsPercent: savings.percent,
|
|
73
|
+
}) }],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Compressed mode (also fallback for json when no parser matches)
|
|
78
|
+
if (format === "compressed" || format === "json") {
|
|
79
|
+
const compressed = compress(command, output, { maxTokens, format: "json" });
|
|
80
|
+
return {
|
|
81
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
82
|
+
exitCode: result.exitCode, output: compressed.content, format: compressed.format,
|
|
83
|
+
duration: result.duration, tokensSaved: compressed.tokensSaved, savingsPercent: compressed.savingsPercent,
|
|
84
|
+
}) }],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
// Summary mode — AI-powered
|
|
88
|
+
if (format === "summary") {
|
|
89
|
+
try {
|
|
90
|
+
const summary = await summarizeOutput(command, output, maxTokens ?? 200);
|
|
91
|
+
const rawTokens = estimateTokens(output);
|
|
92
|
+
const summaryTokens = estimateTokens(summary);
|
|
93
|
+
return {
|
|
94
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
95
|
+
exitCode: result.exitCode, summary, duration: result.duration,
|
|
96
|
+
tokensSaved: rawTokens - summaryTokens,
|
|
97
|
+
}) }],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
const compressed = compress(command, output, { maxTokens });
|
|
102
|
+
return {
|
|
103
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
104
|
+
exitCode: result.exitCode, output: compressed.content, duration: result.duration,
|
|
105
|
+
tokensSaved: compressed.tokensSaved,
|
|
106
|
+
}) }],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { content: [{ type: "text", text: output }] };
|
|
111
|
+
});
|
|
112
|
+
// ── browse: list files/dirs as structured JSON ────────────────────────────
|
|
113
|
+
server.tool("browse", "List files and directories as structured JSON. Auto-filters node_modules, .git, dist by default.", {
|
|
114
|
+
path: z.string().optional().describe("Directory path (default: cwd)"),
|
|
115
|
+
recursive: z.boolean().optional().describe("List recursively (default: false)"),
|
|
116
|
+
maxDepth: z.number().optional().describe("Max depth for recursive listing (default: 2)"),
|
|
117
|
+
includeHidden: z.boolean().optional().describe("Include hidden files (default: false)"),
|
|
118
|
+
}, async ({ path, recursive, maxDepth, includeHidden }) => {
|
|
119
|
+
const target = path ?? process.cwd();
|
|
120
|
+
const depth = maxDepth ?? 2;
|
|
121
|
+
let command;
|
|
122
|
+
if (recursive) {
|
|
123
|
+
command = `find "${target}" -maxdepth ${depth} -not -path '*/node_modules/*' -not -path '*/.git/*' -not -path '*/dist/*' -not -path '*/.next/*'`;
|
|
124
|
+
if (!includeHidden)
|
|
125
|
+
command += " -not -name '.*'";
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
command = includeHidden ? `ls -la "${target}"` : `ls -l "${target}"`;
|
|
129
|
+
}
|
|
130
|
+
const result = await exec(command);
|
|
131
|
+
const parsed = parseOutput(command, result.stdout);
|
|
132
|
+
if (parsed) {
|
|
133
|
+
return {
|
|
134
|
+
content: [{ type: "text", text: JSON.stringify({ cwd: target, ...parsed.data, parser: parsed.parser }) }],
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const files = result.stdout.split("\n").filter(l => l.trim());
|
|
138
|
+
return { content: [{ type: "text", text: JSON.stringify({ cwd: target, files }) }] };
|
|
139
|
+
});
|
|
140
|
+
// ── explain_error: structured error diagnosis ─────────────────────────────
|
|
141
|
+
server.tool("explain_error", "Parse error output and return structured diagnosis with root cause and fix suggestion.", {
|
|
142
|
+
error: z.string().describe("Error output text"),
|
|
143
|
+
command: z.string().optional().describe("The command that produced the error"),
|
|
144
|
+
}, async ({ error, command }) => {
|
|
145
|
+
const { errorParser } = await import("../parsers/errors.js");
|
|
146
|
+
if (errorParser.detect(command ?? "", error)) {
|
|
147
|
+
const info = errorParser.parse(command ?? "", error);
|
|
148
|
+
return { content: [{ type: "text", text: JSON.stringify(info) }] };
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
152
|
+
type: "unknown", message: error.split("\n")[0]?.trim() ?? "Unknown error",
|
|
153
|
+
}) }],
|
|
154
|
+
};
|
|
155
|
+
});
|
|
156
|
+
// ── status: show server info ──────────────────────────────────────────────
|
|
157
|
+
server.tool("status", "Get open-terminal server status, capabilities, and available parsers.", async () => {
|
|
158
|
+
return {
|
|
159
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
160
|
+
name: "open-terminal", version: "0.2.0", cwd: process.cwd(),
|
|
161
|
+
parsers: ["ls", "find", "test", "git-log", "git-status", "build", "npm-install", "error"],
|
|
162
|
+
features: ["structured-output", "token-compression", "ai-summary", "error-diagnosis"],
|
|
163
|
+
}) }],
|
|
164
|
+
};
|
|
165
|
+
});
|
|
166
|
+
// ── search_files: smart file search with auto-filtering ────────────────────
|
|
167
|
+
server.tool("search_files", "Search for files by name pattern. Auto-filters node_modules, .git, dist. Returns categorized results (source, config, other) with token savings.", {
|
|
168
|
+
pattern: z.string().describe("Glob pattern (e.g., '*hooks*', '*.test.ts')"),
|
|
169
|
+
path: z.string().optional().describe("Search root (default: cwd)"),
|
|
170
|
+
includeNodeModules: z.boolean().optional().describe("Include node_modules (default: false)"),
|
|
171
|
+
maxResults: z.number().optional().describe("Max results per category (default: 50)"),
|
|
172
|
+
}, async ({ pattern, path, includeNodeModules, maxResults }) => {
|
|
173
|
+
const result = await searchFiles(pattern, path ?? process.cwd(), { includeNodeModules, maxResults });
|
|
174
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
175
|
+
});
|
|
176
|
+
// ── search_content: smart grep with grouping ──────────────────────────────
|
|
177
|
+
server.tool("search_content", "Search file contents by regex pattern. Groups matches by file, sorted by relevance. Auto-filters excluded directories.", {
|
|
178
|
+
pattern: z.string().describe("Search pattern (regex)"),
|
|
179
|
+
path: z.string().optional().describe("Search root (default: cwd)"),
|
|
180
|
+
fileType: z.string().optional().describe("File type filter (e.g., 'ts', 'py')"),
|
|
181
|
+
maxResults: z.number().optional().describe("Max files to return (default: 30)"),
|
|
182
|
+
contextLines: z.number().optional().describe("Context lines around matches (default: 0)"),
|
|
183
|
+
}, async ({ pattern, path, fileType, maxResults, contextLines }) => {
|
|
184
|
+
const result = await searchContent(pattern, path ?? process.cwd(), { fileType, maxResults, contextLines });
|
|
185
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
186
|
+
});
|
|
187
|
+
// ── list_recipes: list saved command recipes ──────────────────────────────
|
|
188
|
+
server.tool("list_recipes", "List saved command recipes. Optionally filter by collection or project.", {
|
|
189
|
+
collection: z.string().optional().describe("Filter by collection name"),
|
|
190
|
+
project: z.string().optional().describe("Project path for project-scoped recipes"),
|
|
191
|
+
}, async ({ collection, project }) => {
|
|
192
|
+
let recipes = listRecipes(project);
|
|
193
|
+
if (collection)
|
|
194
|
+
recipes = recipes.filter(r => r.collection === collection);
|
|
195
|
+
return { content: [{ type: "text", text: JSON.stringify(recipes) }] };
|
|
196
|
+
});
|
|
197
|
+
// ── run_recipe: execute a saved recipe ────────────────────────────────────
|
|
198
|
+
server.tool("run_recipe", "Run a saved recipe by name with optional variable substitution.", {
|
|
199
|
+
name: z.string().describe("Recipe name"),
|
|
200
|
+
variables: z.record(z.string(), z.string()).optional().describe("Variable values: {port: '3000'}"),
|
|
201
|
+
cwd: z.string().optional().describe("Working directory"),
|
|
202
|
+
format: z.enum(["raw", "json", "compressed"]).optional().describe("Output format"),
|
|
203
|
+
}, async ({ name, variables, cwd, format }) => {
|
|
204
|
+
const recipe = getRecipe(name, cwd);
|
|
205
|
+
if (!recipe) {
|
|
206
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: `Recipe '${name}' not found` }) }] };
|
|
207
|
+
}
|
|
208
|
+
const command = variables ? substituteVariables(recipe.command, variables) : recipe.command;
|
|
209
|
+
const result = await exec(command, cwd, 30000);
|
|
210
|
+
const output = (result.stdout + result.stderr).trim();
|
|
211
|
+
if (format === "json") {
|
|
212
|
+
const parsed = parseOutput(command, output);
|
|
213
|
+
if (parsed) {
|
|
214
|
+
return { content: [{ type: "text", text: JSON.stringify({
|
|
215
|
+
recipe: name, exitCode: result.exitCode, parsed: parsed.data, duration: result.duration,
|
|
216
|
+
}) }] };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (format === "compressed") {
|
|
220
|
+
const compressed = compress(command, output, { format: "json" });
|
|
221
|
+
return { content: [{ type: "text", text: JSON.stringify({
|
|
222
|
+
recipe: name, exitCode: result.exitCode, output: compressed.content, duration: result.duration,
|
|
223
|
+
tokensSaved: compressed.tokensSaved,
|
|
224
|
+
}) }] };
|
|
225
|
+
}
|
|
226
|
+
return { content: [{ type: "text", text: JSON.stringify({
|
|
227
|
+
recipe: name, exitCode: result.exitCode, output: stripAnsi(output), duration: result.duration,
|
|
228
|
+
}) }] };
|
|
229
|
+
});
|
|
230
|
+
// ── save_recipe: save a new recipe ────────────────────────────────────────
|
|
231
|
+
server.tool("save_recipe", "Save a reusable command recipe. Variables in commands use {name} syntax.", {
|
|
232
|
+
name: z.string().describe("Recipe name"),
|
|
233
|
+
command: z.string().describe("Shell command (use {var} for variables)"),
|
|
234
|
+
description: z.string().optional().describe("Description"),
|
|
235
|
+
collection: z.string().optional().describe("Collection to add to"),
|
|
236
|
+
project: z.string().optional().describe("Project path (for project-scoped recipe)"),
|
|
237
|
+
tags: z.array(z.string()).optional().describe("Tags"),
|
|
238
|
+
}, async ({ name, command, description, collection, project, tags }) => {
|
|
239
|
+
const recipe = createRecipe({ name, command, description, collection, project, tags });
|
|
240
|
+
return { content: [{ type: "text", text: JSON.stringify(recipe) }] };
|
|
241
|
+
});
|
|
242
|
+
// ── list_collections: list recipe collections ─────────────────────────────
|
|
243
|
+
server.tool("list_collections", "List recipe collections.", {
|
|
244
|
+
project: z.string().optional().describe("Project path"),
|
|
245
|
+
}, async ({ project }) => {
|
|
246
|
+
const collections = listCollections(project);
|
|
247
|
+
return { content: [{ type: "text", text: JSON.stringify(collections) }] };
|
|
248
|
+
});
|
|
249
|
+
// ── bg_start: start a background process ───────────────────────────────────
|
|
250
|
+
server.tool("bg_start", "Start a background process (e.g., dev server). Auto-detects port from command.", {
|
|
251
|
+
command: z.string().describe("Command to run in background"),
|
|
252
|
+
cwd: z.string().optional().describe("Working directory"),
|
|
253
|
+
}, async ({ command, cwd }) => {
|
|
254
|
+
const result = bgStart(command, cwd);
|
|
255
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
256
|
+
});
|
|
257
|
+
// ── bg_status: list background processes ──────────────────────────────────
|
|
258
|
+
server.tool("bg_status", "List all managed background processes with status, ports, and recent output.", async () => {
|
|
259
|
+
return { content: [{ type: "text", text: JSON.stringify(bgStatus()) }] };
|
|
260
|
+
});
|
|
261
|
+
// ── bg_stop: stop a background process ────────────────────────────────────
|
|
262
|
+
server.tool("bg_stop", "Stop a managed background process by PID.", { pid: z.number().describe("Process ID to stop") }, async ({ pid }) => {
|
|
263
|
+
const ok = bgStop(pid);
|
|
264
|
+
return { content: [{ type: "text", text: JSON.stringify({ stopped: ok, pid }) }] };
|
|
265
|
+
});
|
|
266
|
+
// ── bg_logs: get process output ───────────────────────────────────────────
|
|
267
|
+
server.tool("bg_logs", "Get recent output lines from a background process.", {
|
|
268
|
+
pid: z.number().describe("Process ID"),
|
|
269
|
+
tail: z.number().optional().describe("Number of lines (default: 20)"),
|
|
270
|
+
}, async ({ pid, tail }) => {
|
|
271
|
+
const lines = bgLogs(pid, tail);
|
|
272
|
+
return { content: [{ type: "text", text: JSON.stringify({ pid, lines }) }] };
|
|
273
|
+
});
|
|
274
|
+
// ── bg_wait_port: wait for port to be ready ───────────────────────────────
|
|
275
|
+
server.tool("bg_wait_port", "Wait for a port to start accepting connections. Useful after starting a dev server.", {
|
|
276
|
+
port: z.number().describe("Port number to wait for"),
|
|
277
|
+
timeout: z.number().optional().describe("Timeout in ms (default: 30000)"),
|
|
278
|
+
}, async ({ port, timeout }) => {
|
|
279
|
+
const ready = await bgWaitPort(port, timeout);
|
|
280
|
+
return { content: [{ type: "text", text: JSON.stringify({ port, ready }) }] };
|
|
281
|
+
});
|
|
282
|
+
// ── execute_diff: run command with diff from last run ───────────────────────
|
|
283
|
+
server.tool("execute_diff", "Run a command and return diff from its last execution. Ideal for edit→test loops — only shows what changed.", {
|
|
284
|
+
command: z.string().describe("Shell command to execute"),
|
|
285
|
+
cwd: z.string().optional().describe("Working directory"),
|
|
286
|
+
timeout: z.number().optional().describe("Timeout in ms"),
|
|
287
|
+
}, async ({ command, cwd, timeout }) => {
|
|
288
|
+
const workDir = cwd ?? process.cwd();
|
|
289
|
+
const result = await exec(command, workDir, timeout ?? 30000);
|
|
290
|
+
const output = (result.stdout + result.stderr).trim();
|
|
291
|
+
const diff = diffOutput(command, workDir, output);
|
|
292
|
+
if (diff.tokensSaved > 0) {
|
|
293
|
+
recordSaving("diff", diff.tokensSaved);
|
|
294
|
+
}
|
|
295
|
+
if (diff.unchanged) {
|
|
296
|
+
return { content: [{ type: "text", text: JSON.stringify({
|
|
297
|
+
exitCode: result.exitCode, unchanged: true, diffSummary: diff.diffSummary,
|
|
298
|
+
duration: result.duration, tokensSaved: diff.tokensSaved,
|
|
299
|
+
}) }] };
|
|
300
|
+
}
|
|
301
|
+
if (diff.hasPrevious) {
|
|
302
|
+
return { content: [{ type: "text", text: JSON.stringify({
|
|
303
|
+
exitCode: result.exitCode, diffSummary: diff.diffSummary,
|
|
304
|
+
added: diff.added.slice(0, 50), removed: diff.removed.slice(0, 50),
|
|
305
|
+
duration: result.duration, tokensSaved: diff.tokensSaved,
|
|
306
|
+
}) }] };
|
|
307
|
+
}
|
|
308
|
+
// First run — return full output
|
|
309
|
+
const compressed = compress(command, output, { format: "json" });
|
|
310
|
+
return { content: [{ type: "text", text: JSON.stringify({
|
|
311
|
+
exitCode: result.exitCode, output: compressed.content,
|
|
312
|
+
diffSummary: "first run", duration: result.duration,
|
|
313
|
+
}) }] };
|
|
314
|
+
});
|
|
315
|
+
// ── token_stats: economy dashboard ────────────────────────────────────────
|
|
316
|
+
server.tool("token_stats", "Get token economy stats — how many tokens have been saved by structured output, compression, diffing, and caching.", async () => {
|
|
317
|
+
const stats = getEconomyStats();
|
|
318
|
+
return { content: [{ type: "text", text: JSON.stringify(stats) }] };
|
|
319
|
+
});
|
|
320
|
+
// ── snapshot: capture terminal state ──────────────────────────────────────
|
|
321
|
+
server.tool("snapshot", "Capture a compact snapshot of terminal state (cwd, env, running processes, recent commands, recipes). Useful for agent context handoff.", async () => {
|
|
322
|
+
const snap = captureSnapshot();
|
|
323
|
+
return { content: [{ type: "text", text: JSON.stringify(snap) }] };
|
|
324
|
+
});
|
|
325
|
+
return server;
|
|
326
|
+
}
|
|
327
|
+
// ── main: start MCP server via stdio ─────────────────────────────────────────
|
|
328
|
+
export async function startMcpServer() {
|
|
329
|
+
const server = createServer();
|
|
330
|
+
const transport = new StdioServerTransport();
|
|
331
|
+
await server.connect(transport);
|
|
332
|
+
console.error("open-terminal MCP server running on stdio");
|
|
333
|
+
}
|