@hasna/terminal 4.3.0 → 4.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Onboarding.js +1 -1
- package/dist/ai.js +9 -8
- package/dist/cache.js +2 -2
- package/dist/cli.js +0 -0
- package/dist/economy.js +3 -3
- package/dist/history.js +2 -2
- package/dist/mcp/server.js +26 -1345
- package/dist/mcp/tools/batch.js +111 -0
- package/dist/mcp/tools/execute.js +194 -0
- package/dist/mcp/tools/files.js +290 -0
- package/dist/mcp/tools/git.js +233 -0
- package/dist/mcp/tools/helpers.js +63 -0
- package/dist/mcp/tools/memory.js +151 -0
- package/dist/mcp/tools/meta.js +138 -0
- package/dist/mcp/tools/process.js +50 -0
- package/dist/mcp/tools/project.js +251 -0
- package/dist/mcp/tools/search.js +86 -0
- package/dist/output-store.js +2 -1
- package/dist/paths.js +28 -0
- package/dist/recipes/storage.js +3 -3
- package/dist/session-context.js +2 -2
- package/dist/sessions-db.js +15 -6
- package/dist/snapshots.js +2 -2
- package/dist/tool-profiles.js +4 -3
- package/dist/usage-cache.js +2 -2
- package/package.json +5 -3
- package/src/Onboarding.tsx +1 -1
- package/src/ai.ts +9 -8
- package/src/cache.ts +2 -2
- package/src/economy.ts +3 -3
- package/src/history.ts +2 -2
- package/src/mcp/server.ts +28 -1704
- 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 +172 -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-store.ts +2 -1
- package/src/paths.ts +32 -0
- package/src/recipes/storage.ts +3 -3
- package/src/session-context.ts +2 -2
- package/src/sessions-db.ts +15 -4
- package/src/snapshots.ts +2 -2
- package/src/tool-profiles.ts +4 -3
- package/src/usage-cache.ts +2 -2
- package/dist/output-router.js +0 -41
- 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
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Shared helpers for all MCP tools
|
|
2
|
+
|
|
3
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { spawn } from "child_process";
|
|
5
|
+
import { stripNoise } from "../../noise-filter.js";
|
|
6
|
+
import { rewriteCommand } from "../../command-rewriter.js";
|
|
7
|
+
import { invalidateBootCache } from "../../session-boot.js";
|
|
8
|
+
import { logInteraction } from "../../sessions-db.js";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
|
|
11
|
+
export { z } from "zod";
|
|
12
|
+
|
|
13
|
+
export interface ExecResult {
|
|
14
|
+
exitCode: number;
|
|
15
|
+
stdout: string;
|
|
16
|
+
stderr: string;
|
|
17
|
+
duration: number;
|
|
18
|
+
rewritten?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface LogCallData {
|
|
22
|
+
command?: string;
|
|
23
|
+
outputTokens?: number;
|
|
24
|
+
tokensSaved?: number;
|
|
25
|
+
durationMs?: number;
|
|
26
|
+
exitCode?: number;
|
|
27
|
+
aiProcessed?: boolean;
|
|
28
|
+
model?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ToolHelpers {
|
|
32
|
+
exec: (command: string, cwd?: string, timeout?: number, allowRewrite?: boolean) => Promise<ExecResult>;
|
|
33
|
+
resolvePath: (p: string, cwd?: string) => string;
|
|
34
|
+
logCall: (tool: string, data: LogCallData) => void;
|
|
35
|
+
sessionId: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Create shared helpers for tool modules */
|
|
39
|
+
export function createHelpers(sessionId: string): ToolHelpers {
|
|
40
|
+
function exec(command: string, cwd?: string, timeout?: number, allowRewrite: boolean = false): Promise<ExecResult> {
|
|
41
|
+
const rw = allowRewrite ? rewriteCommand(command) : { changed: false, rewritten: command };
|
|
42
|
+
const actualCommand = rw.changed ? rw.rewritten : command;
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
const start = Date.now();
|
|
45
|
+
const proc = spawn("/bin/zsh", ["-c", actualCommand], {
|
|
46
|
+
cwd: cwd ?? process.cwd(),
|
|
47
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
let stdout = "";
|
|
51
|
+
let stderr = "";
|
|
52
|
+
|
|
53
|
+
proc.stdout?.on("data", (d: Buffer) => { stdout += d.toString(); });
|
|
54
|
+
proc.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); });
|
|
55
|
+
|
|
56
|
+
const timer = timeout ? setTimeout(() => { try { proc.kill("SIGTERM"); } catch {} }, timeout) : null;
|
|
57
|
+
|
|
58
|
+
proc.on("close", (code) => {
|
|
59
|
+
if (timer) clearTimeout(timer);
|
|
60
|
+
const cleanStdout = stripNoise(stdout).cleaned;
|
|
61
|
+
const cleanStderr = stripNoise(stderr).cleaned;
|
|
62
|
+
if (/\bgit\s+(commit|checkout|branch|merge|reset|push|pull|rebase|stash)\b/.test(actualCommand)) {
|
|
63
|
+
invalidateBootCache();
|
|
64
|
+
}
|
|
65
|
+
resolve({ exitCode: code ?? 0, stdout: cleanStdout, stderr: cleanStderr, duration: Date.now() - start, rewritten: rw.changed ? rw.rewritten : undefined });
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function resolvePath(p: string, cwd?: string): string {
|
|
71
|
+
if (!p) return cwd ?? process.cwd();
|
|
72
|
+
if (p.startsWith("/") || p.startsWith("~")) return p;
|
|
73
|
+
return join(cwd ?? process.cwd(), p);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function logCall(tool: string, data: LogCallData) {
|
|
77
|
+
try {
|
|
78
|
+
logInteraction(sessionId, {
|
|
79
|
+
nl: `[mcp:${tool}]${data.command ? ` ${data.command.slice(0, 200)}` : ""}`,
|
|
80
|
+
command: data.command?.slice(0, 500),
|
|
81
|
+
exitCode: data.exitCode,
|
|
82
|
+
tokensUsed: data.aiProcessed ? (data.outputTokens ?? 0) : 0,
|
|
83
|
+
tokensSaved: data.tokensSaved ?? 0,
|
|
84
|
+
durationMs: data.durationMs,
|
|
85
|
+
model: data.model,
|
|
86
|
+
cached: false,
|
|
87
|
+
});
|
|
88
|
+
} catch {}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { exec, resolvePath, logCall, sessionId };
|
|
92
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Memory tools: remember, recall, project_note, store_secret, list_secrets
|
|
2
|
+
|
|
3
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { z, type ToolHelpers } from "./helpers.js";
|
|
5
|
+
|
|
6
|
+
// Fallback memory store when mementos SDK not available
|
|
7
|
+
function getLocalMemoryFile(): string {
|
|
8
|
+
const { join } = require("path");
|
|
9
|
+
return join(process.cwd(), ".terminal", "memories.json");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function loadLocalMemories(): { key: string; value: string; importance: number }[] {
|
|
13
|
+
const { existsSync, readFileSync } = require("fs");
|
|
14
|
+
const file = getLocalMemoryFile();
|
|
15
|
+
if (!existsSync(file)) return [];
|
|
16
|
+
try { return JSON.parse(readFileSync(file, "utf8")); } catch { return []; }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function saveLocalMemory(key: string, value: string, importance: number): void {
|
|
20
|
+
const { existsSync, writeFileSync, mkdirSync } = require("fs");
|
|
21
|
+
const { dirname } = require("path");
|
|
22
|
+
const file = getLocalMemoryFile();
|
|
23
|
+
const dir = dirname(file);
|
|
24
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
25
|
+
const memories = loadLocalMemories().filter(m => m.key !== key); // dedup by key
|
|
26
|
+
memories.push({ key, value, importance });
|
|
27
|
+
writeFileSync(file, JSON.stringify(memories, null, 2));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function registerMemoryTools(server: McpServer, h: ToolHelpers): void {
|
|
31
|
+
|
|
32
|
+
// ── remember ──────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
server.tool(
|
|
35
|
+
"remember",
|
|
36
|
+
"Save a learning about this project for future sessions. Persists across restarts. Use for: project patterns, conventions, toolchain quirks, architectural decisions.",
|
|
37
|
+
{
|
|
38
|
+
key: z.string().describe("Short key (e.g., 'test-command', 'deploy-process', 'auth-pattern')"),
|
|
39
|
+
value: z.string().describe("What to remember"),
|
|
40
|
+
importance: z.number().optional().describe("1-10, default 7"),
|
|
41
|
+
},
|
|
42
|
+
async ({ key, value, importance }) => {
|
|
43
|
+
const imp = importance ?? 7;
|
|
44
|
+
// Try mementos SDK first, fall back to local file
|
|
45
|
+
try {
|
|
46
|
+
const mementos = require("@hasna/mementos");
|
|
47
|
+
mementos.createMemory({ key, value, scope: "shared", category: "knowledge", importance: imp });
|
|
48
|
+
} catch {
|
|
49
|
+
saveLocalMemory(key, value, imp);
|
|
50
|
+
}
|
|
51
|
+
h.logCall("remember", { command: `remember: ${key}` });
|
|
52
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ saved: key }) }] };
|
|
53
|
+
}
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// ── recall ────────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
server.tool(
|
|
59
|
+
"recall",
|
|
60
|
+
"Recall project memories from previous sessions. Returns all saved learnings, patterns, and decisions for this project.",
|
|
61
|
+
{
|
|
62
|
+
search: z.string().optional().describe("Search query to filter memories"),
|
|
63
|
+
limit: z.number().optional().describe("Max memories to return (default: 20)"),
|
|
64
|
+
},
|
|
65
|
+
async ({ search, limit }) => {
|
|
66
|
+
let items: { key: string; value: string; importance: number }[] = [];
|
|
67
|
+
// Try mementos SDK first, fall back to local file
|
|
68
|
+
try {
|
|
69
|
+
const mementos = require("@hasna/mementos");
|
|
70
|
+
const memories = search
|
|
71
|
+
? mementos.searchMemories(search, { limit: limit ?? 20 })
|
|
72
|
+
: mementos.listMemories({ scope: "shared", limit: limit ?? 20 });
|
|
73
|
+
items = (memories ?? []).map((m: any) => ({ key: m.key, value: m.value, importance: m.importance }));
|
|
74
|
+
} catch {
|
|
75
|
+
let local = loadLocalMemories();
|
|
76
|
+
if (search) {
|
|
77
|
+
const q = search.toLowerCase();
|
|
78
|
+
local = local.filter(m => m.key.toLowerCase().includes(q) || m.value.toLowerCase().includes(q));
|
|
79
|
+
}
|
|
80
|
+
items = local.slice(0, limit ?? 20);
|
|
81
|
+
}
|
|
82
|
+
h.logCall("recall", { command: `recall${search ? `: ${search}` : ""}` });
|
|
83
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ memories: items, total: items.length }) }] };
|
|
84
|
+
}
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// ── project_note ──────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
server.tool(
|
|
90
|
+
"project_note",
|
|
91
|
+
"Save or recall notes about the current project. Persists across sessions. Agents pick up where they left off.",
|
|
92
|
+
{
|
|
93
|
+
save: z.string().optional().describe("Note to save"),
|
|
94
|
+
recall: z.boolean().optional().describe("Return all saved notes"),
|
|
95
|
+
clear: z.boolean().optional().describe("Clear all notes"),
|
|
96
|
+
},
|
|
97
|
+
async ({ save, recall, clear }) => {
|
|
98
|
+
const { existsSync, readFileSync, writeFileSync, mkdirSync } = await import("fs");
|
|
99
|
+
const { join } = await import("path");
|
|
100
|
+
const notesDir = join(process.cwd(), ".terminal");
|
|
101
|
+
const notesFile = join(notesDir, "notes.json");
|
|
102
|
+
|
|
103
|
+
let notes: { text: string; timestamp: string }[] = [];
|
|
104
|
+
if (existsSync(notesFile)) {
|
|
105
|
+
try { notes = JSON.parse(readFileSync(notesFile, "utf8")); } catch {}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (clear) {
|
|
109
|
+
notes = [];
|
|
110
|
+
if (!existsSync(notesDir)) mkdirSync(notesDir, { recursive: true });
|
|
111
|
+
writeFileSync(notesFile, "[]");
|
|
112
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ cleared: true }) }] };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (save) {
|
|
116
|
+
notes.push({ text: save, timestamp: new Date().toISOString() });
|
|
117
|
+
if (!existsSync(notesDir)) mkdirSync(notesDir, { recursive: true });
|
|
118
|
+
writeFileSync(notesFile, JSON.stringify(notes, null, 2));
|
|
119
|
+
h.logCall("project_note", { command: `save: ${save.slice(0, 80)}` });
|
|
120
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ saved: true, total: notes.length }) }] };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ notes, total: notes.length }) }] };
|
|
124
|
+
}
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// ── store_secret ──────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
server.tool(
|
|
130
|
+
"store_secret",
|
|
131
|
+
"Store a secret for use in commands. Agent uses $NAME in commands, we resolve at execution and redact in output.",
|
|
132
|
+
{
|
|
133
|
+
name: z.string().describe("Secret name (e.g., JIRA_TOKEN)"),
|
|
134
|
+
value: z.string().describe("Secret value"),
|
|
135
|
+
},
|
|
136
|
+
async ({ name, value }) => {
|
|
137
|
+
const { existsSync, readFileSync, writeFileSync, chmodSync } = await import("fs");
|
|
138
|
+
const { join } = await import("path");
|
|
139
|
+
const { getTerminalDir } = await import("../../paths.js");
|
|
140
|
+
const secretsFile = join(getTerminalDir(), "secrets.json");
|
|
141
|
+
let secrets: Record<string, string> = {};
|
|
142
|
+
if (existsSync(secretsFile)) {
|
|
143
|
+
try { secrets = JSON.parse(readFileSync(secretsFile, "utf8")); } catch {}
|
|
144
|
+
}
|
|
145
|
+
secrets[name] = value;
|
|
146
|
+
writeFileSync(secretsFile, JSON.stringify(secrets, null, 2));
|
|
147
|
+
try { chmodSync(secretsFile, 0o600); } catch {}
|
|
148
|
+
h.logCall("store_secret", { command: `store ${name}` });
|
|
149
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ stored: name, hint: `Use $${name} in commands. Value will be resolved at execution and redacted in output.` }) }] };
|
|
150
|
+
}
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
// ── list_secrets ──────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
server.tool(
|
|
156
|
+
"list_secrets",
|
|
157
|
+
"List stored secret names (never values).",
|
|
158
|
+
async () => {
|
|
159
|
+
const { existsSync, readFileSync } = await import("fs");
|
|
160
|
+
const { join } = await import("path");
|
|
161
|
+
const { getTerminalDir } = await import("../../paths.js");
|
|
162
|
+
const secretsFile = join(getTerminalDir(), "secrets.json");
|
|
163
|
+
let names: string[] = [];
|
|
164
|
+
if (existsSync(secretsFile)) {
|
|
165
|
+
try { names = Object.keys(JSON.parse(readFileSync(secretsFile, "utf8"))); } catch {}
|
|
166
|
+
}
|
|
167
|
+
// Also show env vars that look like secrets
|
|
168
|
+
const envSecrets = Object.keys(process.env).filter(k => /API_KEY|TOKEN|SECRET|PASSWORD/i.test(k));
|
|
169
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ stored: names, environment: envSecrets }) }] };
|
|
170
|
+
}
|
|
171
|
+
);
|
|
172
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// Meta tools: token_stats, session_history, snapshot, watch, list_recipes, run_recipe, save_recipe, list_collections
|
|
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 { listRecipes, listCollections, getRecipe, createRecipe } from "../../recipes/storage.js";
|
|
9
|
+
import { substituteVariables } from "../../recipes/model.js";
|
|
10
|
+
import { listSessions, getSessionInteractions, getSessionStats, getSessionEconomy } from "../../sessions-db.js";
|
|
11
|
+
import { getEconomyStats } from "../../economy.js";
|
|
12
|
+
import { captureSnapshot } from "../../snapshots.js";
|
|
13
|
+
import { storeOutput } from "../../expand-store.js";
|
|
14
|
+
|
|
15
|
+
export function registerMetaTools(server: McpServer, h: ToolHelpers): void {
|
|
16
|
+
|
|
17
|
+
// ── token_stats ───────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
server.tool(
|
|
20
|
+
"token_stats",
|
|
21
|
+
"Get full token economy — savings, costs, ROI. Includes round-trip multiplier (saved tokens repeated across ~5 turns).",
|
|
22
|
+
async () => {
|
|
23
|
+
const stats = getEconomyStats();
|
|
24
|
+
const { estimateSavingsUsd } = await import("../../economy.js");
|
|
25
|
+
const opus = estimateSavingsUsd(stats.totalTokensSaved, "anthropic-opus");
|
|
26
|
+
const sonnet = estimateSavingsUsd(stats.totalTokensSaved, "anthropic-sonnet");
|
|
27
|
+
const haiku = estimateSavingsUsd(stats.totalTokensSaved, "anthropic");
|
|
28
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
29
|
+
...stats,
|
|
30
|
+
roundTrip: {
|
|
31
|
+
multiplier: 5,
|
|
32
|
+
billableTokensSaved: stats.totalTokensSaved * 5,
|
|
33
|
+
savingsUsd: { opus: opus.savingsUsd, sonnet: sonnet.savingsUsd, haiku: haiku.savingsUsd },
|
|
34
|
+
},
|
|
35
|
+
ratio: stats.totalTokensUsed > 0 ? Math.round((stats.totalTokensSaved / stats.totalTokensUsed) * 10) / 10 : 0,
|
|
36
|
+
}) }] };
|
|
37
|
+
}
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
// ── session_history ───────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
server.tool(
|
|
43
|
+
"session_history",
|
|
44
|
+
"Query terminal session history — recent sessions, specific session details, or aggregate stats.",
|
|
45
|
+
{
|
|
46
|
+
action: z.enum(["list", "detail", "stats"]).describe("list=recent sessions, detail=specific session, stats=aggregates"),
|
|
47
|
+
sessionId: z.string().optional().describe("Session ID (for detail action)"),
|
|
48
|
+
limit: z.number().optional().describe("Max sessions to return (for list, default: 20)"),
|
|
49
|
+
},
|
|
50
|
+
async ({ action, sessionId, limit }) => {
|
|
51
|
+
if (action === "stats") {
|
|
52
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(getSessionStats()) }] };
|
|
53
|
+
}
|
|
54
|
+
if (action === "detail" && sessionId) {
|
|
55
|
+
const interactions = getSessionInteractions(sessionId);
|
|
56
|
+
const economy = getSessionEconomy(sessionId);
|
|
57
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ interactions, economy }) }] };
|
|
58
|
+
}
|
|
59
|
+
const sessions = listSessions(limit ?? 20);
|
|
60
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(sessions) }] };
|
|
61
|
+
}
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
// ── snapshot ──────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
server.tool(
|
|
67
|
+
"snapshot",
|
|
68
|
+
"Capture a compact snapshot of terminal state (cwd, env, running processes, recent commands, recipes). Useful for agent context handoff.",
|
|
69
|
+
async () => {
|
|
70
|
+
const snap = captureSnapshot();
|
|
71
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(snap) }] };
|
|
72
|
+
}
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
// ── watch ─────────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
server.tool(
|
|
78
|
+
"watch",
|
|
79
|
+
"Run a task (test/build/lint/typecheck) on file change. Returns diff from last run. Agent stops polling — we push on change. Call watch_stop to end.",
|
|
80
|
+
{
|
|
81
|
+
task: z.enum(["test", "build", "lint", "typecheck"]).describe("Task to run on change"),
|
|
82
|
+
path: z.string().optional().describe("File or directory to watch (default: src/)"),
|
|
83
|
+
cwd: z.string().optional().describe("Working directory"),
|
|
84
|
+
},
|
|
85
|
+
async ({ task, path: watchPath, cwd }) => {
|
|
86
|
+
const workDir = cwd ?? process.cwd();
|
|
87
|
+
const target = h.resolvePath(watchPath ?? "src/", workDir);
|
|
88
|
+
const watchId = `${task}:${target}`;
|
|
89
|
+
|
|
90
|
+
// Run once immediately
|
|
91
|
+
const { existsSync } = await import("fs");
|
|
92
|
+
const { join } = await import("path");
|
|
93
|
+
|
|
94
|
+
let runner = "npm run";
|
|
95
|
+
if (existsSync(join(workDir, "bun.lockb")) || existsSync(join(workDir, "bun.lock"))) runner = "bun run";
|
|
96
|
+
else if (existsSync(join(workDir, "Cargo.toml"))) runner = "cargo";
|
|
97
|
+
|
|
98
|
+
const cmd = runner === "cargo" ? `cargo ${task}` : `${runner} ${task}`;
|
|
99
|
+
const result = await h.exec(cmd, workDir, 60000);
|
|
100
|
+
const output = (result.stdout + result.stderr).trim();
|
|
101
|
+
const processed = await processOutput(cmd, output);
|
|
102
|
+
|
|
103
|
+
// Store initial result for diffing
|
|
104
|
+
const detailKey = storeOutput(`watch:${task}`, output);
|
|
105
|
+
|
|
106
|
+
h.logCall("watch", { command: `watch ${task} ${target}`, exitCode: result.exitCode, durationMs: 0, aiProcessed: processed.aiProcessed });
|
|
107
|
+
|
|
108
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
109
|
+
watchId,
|
|
110
|
+
task,
|
|
111
|
+
watching: target,
|
|
112
|
+
initialRun: { exitCode: result.exitCode, summary: processed.summary, tokensSaved: processed.tokensSaved },
|
|
113
|
+
hint: "File watching active. Call execute_diff with the same command to get changes on next run.",
|
|
114
|
+
}) }] };
|
|
115
|
+
}
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// ── list_recipes ──────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
server.tool(
|
|
121
|
+
"list_recipes",
|
|
122
|
+
"List saved command recipes. Optionally filter by collection or project.",
|
|
123
|
+
{
|
|
124
|
+
collection: z.string().optional().describe("Filter by collection name"),
|
|
125
|
+
project: z.string().optional().describe("Project path for project-scoped recipes"),
|
|
126
|
+
},
|
|
127
|
+
async ({ collection, project }) => {
|
|
128
|
+
let recipes = listRecipes(project);
|
|
129
|
+
if (collection) recipes = recipes.filter(r => r.collection === collection);
|
|
130
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(recipes) }] };
|
|
131
|
+
}
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// ── run_recipe ────────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
server.tool(
|
|
137
|
+
"run_recipe",
|
|
138
|
+
"Run a saved recipe by name with optional variable substitution.",
|
|
139
|
+
{
|
|
140
|
+
name: z.string().describe("Recipe name"),
|
|
141
|
+
variables: z.record(z.string(), z.string()).optional().describe("Variable values: {port: '3000'}"),
|
|
142
|
+
cwd: z.string().optional().describe("Working directory"),
|
|
143
|
+
format: z.enum(["raw", "json", "compressed"]).optional().describe("Output format"),
|
|
144
|
+
},
|
|
145
|
+
async ({ name, variables, cwd, format }) => {
|
|
146
|
+
const recipe = getRecipe(name, cwd);
|
|
147
|
+
if (!recipe) {
|
|
148
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ error: `Recipe '${name}' not found` }) }] };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const command = variables ? substituteVariables(recipe.command, variables) : recipe.command;
|
|
152
|
+
const result = await h.exec(command, cwd, 30000);
|
|
153
|
+
const output = (result.stdout + result.stderr).trim();
|
|
154
|
+
|
|
155
|
+
if (format === "json" || format === "compressed") {
|
|
156
|
+
const processed = await processOutput(command, output);
|
|
157
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
158
|
+
recipe: name, exitCode: result.exitCode, summary: processed.summary,
|
|
159
|
+
structured: processed.structured, duration: result.duration,
|
|
160
|
+
tokensSaved: processed.tokensSaved, aiProcessed: processed.aiProcessed,
|
|
161
|
+
}) }] };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
165
|
+
recipe: name, exitCode: result.exitCode, output: stripAnsi(output), duration: result.duration,
|
|
166
|
+
}) }] };
|
|
167
|
+
}
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
// ── save_recipe ───────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
server.tool(
|
|
173
|
+
"save_recipe",
|
|
174
|
+
"Save a reusable command recipe. Variables in commands use {name} syntax.",
|
|
175
|
+
{
|
|
176
|
+
name: z.string().describe("Recipe name"),
|
|
177
|
+
command: z.string().describe("Shell command (use {var} for variables)"),
|
|
178
|
+
description: z.string().optional().describe("Description"),
|
|
179
|
+
collection: z.string().optional().describe("Collection to add to"),
|
|
180
|
+
project: z.string().optional().describe("Project path (for project-scoped recipe)"),
|
|
181
|
+
tags: z.array(z.string()).optional().describe("Tags"),
|
|
182
|
+
},
|
|
183
|
+
async ({ name, command, description, collection, project, tags }) => {
|
|
184
|
+
const recipe = createRecipe({ name, command, description, collection, project, tags });
|
|
185
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(recipe) }] };
|
|
186
|
+
}
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
// ── list_collections ──────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
server.tool(
|
|
192
|
+
"list_collections",
|
|
193
|
+
"List recipe collections.",
|
|
194
|
+
{
|
|
195
|
+
project: z.string().optional().describe("Project path"),
|
|
196
|
+
},
|
|
197
|
+
async ({ project }) => {
|
|
198
|
+
const collections = listCollections(project);
|
|
199
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(collections) }] };
|
|
200
|
+
}
|
|
201
|
+
);
|
|
202
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Process tools: bg_start, bg_stop, bg_status, bg_logs, bg_wait_port
|
|
2
|
+
|
|
3
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { z, type ToolHelpers } from "./helpers.js";
|
|
5
|
+
import { bgStart, bgStatus, bgStop, bgLogs, bgWaitPort } from "../../supervisor.js";
|
|
6
|
+
|
|
7
|
+
export function registerProcessTools(server: McpServer, h: ToolHelpers): void {
|
|
8
|
+
|
|
9
|
+
// ── bg_start ──────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
server.tool(
|
|
12
|
+
"bg_start",
|
|
13
|
+
"Start a background process (e.g., dev server). Auto-detects port from command.",
|
|
14
|
+
{
|
|
15
|
+
command: z.string().describe("Command to run in background"),
|
|
16
|
+
cwd: z.string().optional().describe("Working directory"),
|
|
17
|
+
},
|
|
18
|
+
async ({ command, cwd }) => {
|
|
19
|
+
const result = bgStart(command, cwd);
|
|
20
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(result) }] };
|
|
21
|
+
}
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
// ── bg_status ─────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
server.tool(
|
|
27
|
+
"bg_status",
|
|
28
|
+
"List all managed background processes with status, ports, and recent output.",
|
|
29
|
+
async () => {
|
|
30
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(bgStatus()) }] };
|
|
31
|
+
}
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
// ── bg_stop ───────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
server.tool(
|
|
37
|
+
"bg_stop",
|
|
38
|
+
"Stop a managed background process by PID.",
|
|
39
|
+
{ pid: z.number().describe("Process ID to stop") },
|
|
40
|
+
async ({ pid }) => {
|
|
41
|
+
const ok = bgStop(pid);
|
|
42
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ stopped: ok, pid }) }] };
|
|
43
|
+
}
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
// ── bg_logs ───────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
server.tool(
|
|
49
|
+
"bg_logs",
|
|
50
|
+
"Get recent output lines from a background process.",
|
|
51
|
+
{
|
|
52
|
+
pid: z.number().describe("Process ID"),
|
|
53
|
+
tail: z.number().optional().describe("Number of lines (default: 20)"),
|
|
54
|
+
},
|
|
55
|
+
async ({ pid, tail }) => {
|
|
56
|
+
const lines = bgLogs(pid, tail);
|
|
57
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ pid, lines }) }] };
|
|
58
|
+
}
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// ── bg_wait_port ──────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
server.tool(
|
|
64
|
+
"bg_wait_port",
|
|
65
|
+
"Wait for a port to start accepting connections. Useful after starting a dev server.",
|
|
66
|
+
{
|
|
67
|
+
port: z.number().describe("Port number to wait for"),
|
|
68
|
+
timeout: z.number().optional().describe("Timeout in ms (default: 30000)"),
|
|
69
|
+
},
|
|
70
|
+
async ({ port, timeout }) => {
|
|
71
|
+
const ready = await bgWaitPort(port, timeout);
|
|
72
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ port, ready }) }] };
|
|
73
|
+
}
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
// ── port_check ──────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
server.tool(
|
|
79
|
+
"port_check",
|
|
80
|
+
"Check if a port is in use and what process is using it.",
|
|
81
|
+
{
|
|
82
|
+
port: z.number().describe("Port number to check"),
|
|
83
|
+
},
|
|
84
|
+
async ({ port }) => {
|
|
85
|
+
const result = await h.exec(`lsof -i :${port} -P -n 2>/dev/null | head -5`, undefined, 5000);
|
|
86
|
+
const output = result.stdout.trim();
|
|
87
|
+
if (!output || result.exitCode !== 0) {
|
|
88
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ port, inUse: false }) }] };
|
|
89
|
+
}
|
|
90
|
+
const lines = output.split("\n").filter(l => l.trim());
|
|
91
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ port, inUse: true, processes: lines.slice(1).map(l => l.split(/\s+/).slice(0, 3).join(" ")) }) }] };
|
|
92
|
+
}
|
|
93
|
+
);
|
|
94
|
+
}
|