@hasna/terminal 3.8.1 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -882,6 +882,332 @@ Rules:
882
882
  ok: results.every(r => r.ok),
883
883
  }) }] };
884
884
  });
885
+ // ── watch: run task on file change ─────────────────────────────────────────
886
+ const watchHandles = new Map();
887
+ server.tool("watch", "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.", {
888
+ task: z.enum(["test", "build", "lint", "typecheck"]).describe("Task to run on change"),
889
+ path: z.string().optional().describe("File or directory to watch (default: src/)"),
890
+ cwd: z.string().optional().describe("Working directory"),
891
+ }, async ({ task, path: watchPath, cwd }) => {
892
+ const { watch } = await import("fs");
893
+ const workDir = cwd ?? process.cwd();
894
+ const target = resolvePath(watchPath ?? "src/", workDir);
895
+ const watchId = `${task}:${target}`;
896
+ // Run once immediately
897
+ const { existsSync } = await import("fs");
898
+ const { join } = await import("path");
899
+ let runner = "npm run";
900
+ if (existsSync(join(workDir, "bun.lockb")) || existsSync(join(workDir, "bun.lock")))
901
+ runner = "bun run";
902
+ else if (existsSync(join(workDir, "Cargo.toml")))
903
+ runner = "cargo";
904
+ const cmd = runner === "cargo" ? `cargo ${task}` : `${runner} ${task}`;
905
+ const result = await exec(cmd, workDir, 60000);
906
+ const output = (result.stdout + result.stderr).trim();
907
+ const processed = await processOutput(cmd, output);
908
+ // Store initial result for diffing
909
+ const detailKey = storeOutput(`watch:${task}`, output);
910
+ logCall("watch", { command: `watch ${task} ${target}`, exitCode: result.exitCode, durationMs: 0, aiProcessed: processed.aiProcessed });
911
+ return { content: [{ type: "text", text: JSON.stringify({
912
+ watchId,
913
+ task,
914
+ watching: target,
915
+ initialRun: { exitCode: result.exitCode, summary: processed.summary, tokensSaved: processed.tokensSaved },
916
+ hint: "File watching active. Call execute_diff with the same command to get changes on next run.",
917
+ }) }] };
918
+ });
919
+ // ── batch tools: read_files, symbols_dir ──────────────────────────────────
920
+ server.tool("read_files", "Read multiple files in one call. Use summarize=true for AI outlines (~90% fewer tokens per file). Saves N-1 round trips vs separate read_file calls.", {
921
+ files: z.array(z.string()).describe("File paths (relative or absolute)"),
922
+ summarize: z.boolean().optional().describe("AI summary instead of full content"),
923
+ }, async ({ files, summarize }) => {
924
+ const start = Date.now();
925
+ const results = {};
926
+ for (const f of files.slice(0, 10)) { // max 10 files per call
927
+ const filePath = resolvePath(f);
928
+ const result = cachedRead(filePath, {});
929
+ if (summarize && result.content.length > 500) {
930
+ const provider = getOutputProvider();
931
+ const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
932
+ const content = result.content.length > 8000 ? result.content.slice(0, 8000) : result.content;
933
+ const summary = await provider.complete(`File: ${filePath}\n\n${content}`, {
934
+ model: outputModel,
935
+ system: `Describe what this source file does in 2-4 lines. Include: main class/module name, key methods/functions, what it exports, and its purpose. Be specific.`,
936
+ maxTokens: 300, temperature: 0.2,
937
+ });
938
+ results[f] = { summary, lines: result.content.split("\n").length };
939
+ }
940
+ else {
941
+ results[f] = { content: result.content, lines: result.content.split("\n").length };
942
+ }
943
+ }
944
+ logCall("read_files", { command: `${files.length} files`, durationMs: Date.now() - start, aiProcessed: !!summarize });
945
+ return { content: [{ type: "text", text: JSON.stringify(results) }] };
946
+ });
947
+ server.tool("symbols_dir", "Get symbols for all source files in a directory. AI-powered, works for any language. One call replaces N separate symbols calls.", {
948
+ path: z.string().optional().describe("Directory (default: src/)"),
949
+ maxFiles: z.number().optional().describe("Max files to scan (default: 10)"),
950
+ }, async ({ path: dirPath, maxFiles }) => {
951
+ const start = Date.now();
952
+ const dir = resolvePath(dirPath ?? "src/");
953
+ const limit = maxFiles ?? 10;
954
+ // Find source files
955
+ const findResult = await exec(`find "${dir}" -maxdepth 3 -type f \\( -name "*.ts" -o -name "*.js" -o -name "*.py" -o -name "*.go" -o -name "*.rs" -o -name "*.java" -o -name "*.rb" -o -name "*.php" \\) -not -path "*/node_modules/*" -not -path "*/dist/*" -not -name "*.test.*" -not -name "*.spec.*" | head -${limit}`, process.cwd(), 5000);
956
+ const files = findResult.stdout.split("\n").filter(l => l.trim());
957
+ const allSymbols = {};
958
+ const provider = getOutputProvider();
959
+ const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
960
+ for (const file of files) {
961
+ const result = cachedRead(file, {});
962
+ if (!result.content || result.content.startsWith("Error:"))
963
+ continue;
964
+ try {
965
+ const content = result.content.length > 6000 ? result.content.slice(0, 6000) : result.content;
966
+ const summary = await provider.complete(`File: ${file}\n\n${content}`, {
967
+ model: outputModel,
968
+ system: `Extract all symbols. Return ONLY a JSON array. Each: {"name":"x","kind":"function|class|method|interface|type","line":N,"signature":"brief"}. For class methods use "Class.method". Exclude imports.`,
969
+ maxTokens: 1500, temperature: 0,
970
+ });
971
+ const jsonMatch = summary.match(/\[[\s\S]*\]/);
972
+ if (jsonMatch)
973
+ allSymbols[file] = JSON.parse(jsonMatch[0]);
974
+ }
975
+ catch { }
976
+ }
977
+ logCall("symbols_dir", { command: `${files.length} files in ${dir}`, durationMs: Date.now() - start, aiProcessed: true });
978
+ return { content: [{ type: "text", text: JSON.stringify({ directory: dir, files: files.length, symbols: allSymbols }) }] };
979
+ });
980
+ // ── review: AI code review ────────────────────────────────────────────────
981
+ server.tool("review", "AI code review of recent changes or specific files. Returns: bugs, security issues, suggestions. One call replaces git diff + manual reading.", {
982
+ since: z.string().optional().describe("Git ref to diff against (e.g., 'HEAD~3', 'main')"),
983
+ files: z.array(z.string()).optional().describe("Specific files to review"),
984
+ cwd: z.string().optional().describe("Working directory"),
985
+ }, async ({ since, files, cwd }) => {
986
+ const start = Date.now();
987
+ const workDir = cwd ?? process.cwd();
988
+ let content;
989
+ if (files && files.length > 0) {
990
+ const fileContents = files.map(f => {
991
+ const result = cachedRead(resolvePath(f, workDir), {});
992
+ return `=== ${f} ===\n${result.content.slice(0, 4000)}`;
993
+ });
994
+ content = fileContents.join("\n\n");
995
+ }
996
+ else {
997
+ const ref = since ?? "HEAD~1";
998
+ const diff = await exec(`git diff ${ref} --no-color`, workDir, 15000);
999
+ content = diff.stdout.slice(0, 12000);
1000
+ }
1001
+ const provider = getOutputProvider();
1002
+ const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
1003
+ const review = await provider.complete(`Review this code:\n\n${content}`, {
1004
+ model: outputModel,
1005
+ system: `You are a senior code reviewer. Review concisely:
1006
+ - Bugs or logic errors
1007
+ - Security issues (injection, auth, secrets)
1008
+ - Missing error handling
1009
+ - Performance concerns
1010
+ - Style/naming issues (only if significant)
1011
+
1012
+ Format: list issues as "- [severity] file:line description". If clean, say "No issues found."
1013
+ Be specific, not generic. Only flag real problems.`,
1014
+ maxTokens: 800, temperature: 0.2,
1015
+ });
1016
+ logCall("review", { command: `review ${since ?? files?.join(",") ?? "HEAD~1"}`, durationMs: Date.now() - start, aiProcessed: true });
1017
+ return { content: [{ type: "text", text: JSON.stringify({ review, scope: since ?? files }) }] };
1018
+ });
1019
+ // ── secrets vault ─────────────────────────────────────────────────────────
1020
+ server.tool("store_secret", "Store a secret for use in commands. Agent uses $NAME in commands, we resolve at execution and redact in output.", {
1021
+ name: z.string().describe("Secret name (e.g., JIRA_TOKEN)"),
1022
+ value: z.string().describe("Secret value"),
1023
+ }, async ({ name, value }) => {
1024
+ const { existsSync, readFileSync, writeFileSync, chmodSync } = await import("fs");
1025
+ const { join } = await import("path");
1026
+ const secretsFile = join(process.env.HOME ?? "~", ".terminal", "secrets.json");
1027
+ let secrets = {};
1028
+ if (existsSync(secretsFile)) {
1029
+ try {
1030
+ secrets = JSON.parse(readFileSync(secretsFile, "utf8"));
1031
+ }
1032
+ catch { }
1033
+ }
1034
+ secrets[name] = value;
1035
+ writeFileSync(secretsFile, JSON.stringify(secrets, null, 2));
1036
+ try {
1037
+ chmodSync(secretsFile, 0o600);
1038
+ }
1039
+ catch { }
1040
+ logCall("store_secret", { command: `store ${name}` });
1041
+ return { content: [{ type: "text", text: JSON.stringify({ stored: name, hint: `Use $${name} in commands. Value will be resolved at execution and redacted in output.` }) }] };
1042
+ });
1043
+ server.tool("list_secrets", "List stored secret names (never values).", async () => {
1044
+ const { existsSync, readFileSync } = await import("fs");
1045
+ const { join } = await import("path");
1046
+ const secretsFile = join(process.env.HOME ?? "~", ".terminal", "secrets.json");
1047
+ let names = [];
1048
+ if (existsSync(secretsFile)) {
1049
+ try {
1050
+ names = Object.keys(JSON.parse(readFileSync(secretsFile, "utf8")));
1051
+ }
1052
+ catch { }
1053
+ }
1054
+ // Also show env vars that look like secrets
1055
+ const envSecrets = Object.keys(process.env).filter(k => /API_KEY|TOKEN|SECRET|PASSWORD/i.test(k));
1056
+ return { content: [{ type: "text", text: JSON.stringify({ stored: names, environment: envSecrets }) }] };
1057
+ });
1058
+ // ── project memory ────────────────────────────────────────────────────────
1059
+ server.tool("project_note", "Save or recall notes about the current project. Persists across sessions. Agents pick up where they left off.", {
1060
+ save: z.string().optional().describe("Note to save"),
1061
+ recall: z.boolean().optional().describe("Return all saved notes"),
1062
+ clear: z.boolean().optional().describe("Clear all notes"),
1063
+ }, async ({ save, recall, clear }) => {
1064
+ const { existsSync, readFileSync, writeFileSync, mkdirSync } = await import("fs");
1065
+ const { join } = await import("path");
1066
+ const notesDir = join(process.cwd(), ".terminal");
1067
+ const notesFile = join(notesDir, "notes.json");
1068
+ let notes = [];
1069
+ if (existsSync(notesFile)) {
1070
+ try {
1071
+ notes = JSON.parse(readFileSync(notesFile, "utf8"));
1072
+ }
1073
+ catch { }
1074
+ }
1075
+ if (clear) {
1076
+ notes = [];
1077
+ if (!existsSync(notesDir))
1078
+ mkdirSync(notesDir, { recursive: true });
1079
+ writeFileSync(notesFile, "[]");
1080
+ return { content: [{ type: "text", text: JSON.stringify({ cleared: true }) }] };
1081
+ }
1082
+ if (save) {
1083
+ notes.push({ text: save, timestamp: new Date().toISOString() });
1084
+ if (!existsSync(notesDir))
1085
+ mkdirSync(notesDir, { recursive: true });
1086
+ writeFileSync(notesFile, JSON.stringify(notes, null, 2));
1087
+ logCall("project_note", { command: `save: ${save.slice(0, 80)}` });
1088
+ return { content: [{ type: "text", text: JSON.stringify({ saved: true, total: notes.length }) }] };
1089
+ }
1090
+ return { content: [{ type: "text", text: JSON.stringify({ notes, total: notes.length }) }] };
1091
+ });
1092
+ // ── diff: show what changed ────────────────────────────────────────────────
1093
+ server.tool("diff", "Show what changed — git diff with AI summary. One call replaces constructing git diff commands.", {
1094
+ ref: z.string().optional().describe("Diff against this ref (default: unstaged changes). Examples: HEAD~1, main, abc123"),
1095
+ file: z.string().optional().describe("Diff a specific file only"),
1096
+ stat: z.boolean().optional().describe("Show file-level stats only, not full diff (default: false)"),
1097
+ cwd: z.string().optional().describe("Working directory"),
1098
+ }, async ({ ref, file, stat, cwd }) => {
1099
+ const start = Date.now();
1100
+ const workDir = cwd ?? process.cwd();
1101
+ let cmd = "git diff";
1102
+ if (ref)
1103
+ cmd += ` ${ref}`;
1104
+ if (stat)
1105
+ cmd += " --stat";
1106
+ if (file)
1107
+ cmd += ` -- ${file}`;
1108
+ const result = await exec(cmd, workDir, 15000);
1109
+ const output = (result.stdout + result.stderr).trim();
1110
+ if (!output) {
1111
+ return { content: [{ type: "text", text: JSON.stringify({ clean: true, message: "No changes" }) }] };
1112
+ }
1113
+ const processed = await processOutput(cmd, output);
1114
+ logCall("diff", { command: cmd, outputTokens: estimateTokens(output), tokensSaved: processed.tokensSaved, durationMs: Date.now() - start, aiProcessed: processed.aiProcessed });
1115
+ return { content: [{ type: "text", text: JSON.stringify({
1116
+ summary: processed.summary,
1117
+ lines: output.split("\n").length,
1118
+ tokensSaved: processed.tokensSaved,
1119
+ }) }] };
1120
+ });
1121
+ // ── install: add packages, auto-detect package manager ────────────────────
1122
+ server.tool("install", "Install packages — auto-detects bun/npm/pnpm/yarn/pip/cargo. Agent says what to install, we figure out how.", {
1123
+ packages: z.array(z.string()).describe("Package names to install"),
1124
+ dev: z.boolean().optional().describe("Install as dev dependency (default: false)"),
1125
+ cwd: z.string().optional().describe("Working directory"),
1126
+ }, async ({ packages, dev, cwd }) => {
1127
+ const start = Date.now();
1128
+ const workDir = cwd ?? process.cwd();
1129
+ const { existsSync } = await import("fs");
1130
+ const { join } = await import("path");
1131
+ let cmd;
1132
+ const pkgs = packages.join(" ");
1133
+ const devFlag = dev ? " -D" : "";
1134
+ if (existsSync(join(workDir, "bun.lockb")) || existsSync(join(workDir, "bun.lock"))) {
1135
+ cmd = `bun add${devFlag} ${pkgs}`;
1136
+ }
1137
+ else if (existsSync(join(workDir, "pnpm-lock.yaml"))) {
1138
+ cmd = `pnpm add${devFlag} ${pkgs}`;
1139
+ }
1140
+ else if (existsSync(join(workDir, "yarn.lock"))) {
1141
+ cmd = `yarn add${dev ? " --dev" : ""} ${pkgs}`;
1142
+ }
1143
+ else if (existsSync(join(workDir, "package.json"))) {
1144
+ cmd = `npm install${dev ? " --save-dev" : ""} ${pkgs}`;
1145
+ }
1146
+ else if (existsSync(join(workDir, "requirements.txt")) || existsSync(join(workDir, "pyproject.toml"))) {
1147
+ cmd = `pip install ${pkgs}`;
1148
+ }
1149
+ else if (existsSync(join(workDir, "Cargo.toml"))) {
1150
+ cmd = `cargo add ${pkgs}`;
1151
+ }
1152
+ else {
1153
+ cmd = `npm install${dev ? " --save-dev" : ""} ${pkgs}`;
1154
+ }
1155
+ const result = await exec(cmd, workDir, 60000);
1156
+ const output = (result.stdout + result.stderr).trim();
1157
+ const processed = await processOutput(cmd, output);
1158
+ logCall("install", { command: cmd, exitCode: result.exitCode, durationMs: Date.now() - start, aiProcessed: processed.aiProcessed });
1159
+ return { content: [{ type: "text", text: JSON.stringify({
1160
+ exitCode: result.exitCode,
1161
+ command: cmd,
1162
+ summary: processed.summary,
1163
+ }) }] };
1164
+ });
1165
+ // ── help: tool discoverability ────────────────────────────────────────────
1166
+ server.tool("help", "Get recommendations for which terminal tool to use. Describe what you want to do and get the best tool + usage example.", {
1167
+ goal: z.string().optional().describe("What you're trying to do (e.g., 'run tests', 'find where login is defined', 'commit my changes')"),
1168
+ }, async ({ goal }) => {
1169
+ if (!goal) {
1170
+ return { content: [{ type: "text", text: JSON.stringify({
1171
+ tools: {
1172
+ "execute / execute_smart": "Run any command. Smart = AI summary (80% fewer tokens)",
1173
+ "run({task})": "Run test/build/lint — auto-detects toolchain",
1174
+ "commit / bulk_commit / smart_commit": "Git commit — single, multi, or AI-grouped",
1175
+ "diff({ref})": "Show what changed with AI summary",
1176
+ "install({packages})": "Add packages — auto-detects bun/npm/pip/cargo",
1177
+ "search_content({pattern})": "Grep with structured results",
1178
+ "search_files({pattern})": "Find files by glob",
1179
+ "symbols({path})": "AI file outline — any language",
1180
+ "read_symbol({path, name})": "Read one function/class by name",
1181
+ "read_file({path, summarize})": "Read or AI-summarize a file",
1182
+ "read_files({files, summarize})": "Multi-file read in one call",
1183
+ "symbols_dir({path})": "Symbols for entire directory",
1184
+ "review({since})": "AI code review",
1185
+ "lookup({file, items})": "Find items in a file by name",
1186
+ "edit({file, find, replace})": "Find-replace in file",
1187
+ "repo_state": "Git branch + status + log in one call",
1188
+ "boot": "Full project context on session start",
1189
+ "watch({task})": "Run task on file change",
1190
+ "store_secret / list_secrets": "Secrets vault",
1191
+ "project_note({save/recall})": "Persistent project notes",
1192
+ },
1193
+ tips: [
1194
+ "Use relative paths — 'src/foo.ts' not '/Users/.../src/foo.ts'",
1195
+ "Use your native Read/Write/Edit for file operations when you don't need AI summary",
1196
+ "Use search_content for text patterns, symbols for code structure",
1197
+ "Use commit for single, bulk_commit for multiple, smart_commit for AI-grouped",
1198
+ ],
1199
+ }) }] };
1200
+ }
1201
+ // AI recommends the best tool for the goal
1202
+ const provider = getOutputProvider();
1203
+ const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
1204
+ const recommendation = await provider.complete(`Agent wants to: ${goal}\n\nAvailable tools: execute, execute_smart, run, commit, bulk_commit, smart_commit, diff, install, search_content, search_files, symbols, read_symbol, read_file, read_files, symbols_dir, review, lookup, edit, repo_state, boot, watch, store_secret, list_secrets, project_note, help`, {
1205
+ model: outputModel,
1206
+ system: `Recommend the best terminal MCP tool for this goal. Return JSON: {"tool": "name", "example": {params}, "why": "one line"}. If multiple tools work, list top 2.`,
1207
+ maxTokens: 200, temperature: 0,
1208
+ });
1209
+ return { content: [{ type: "text", text: recommendation }] };
1210
+ });
885
1211
  return server;
886
1212
  }
887
1213
  // ── main: start MCP server via stdio ─────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/terminal",
3
- "version": "3.8.1",
3
+ "version": "4.1.0",
4
4
  "description": "Smart terminal wrapper for AI agents and humans — structured output, token compression, MCP server, natural language",
5
5
  "type": "module",
6
6
  "files": [
package/src/mcp/server.ts CHANGED
@@ -1170,6 +1170,406 @@ Rules:
1170
1170
  }
1171
1171
  );
1172
1172
 
1173
+ // ── watch: run task on file change ─────────────────────────────────────────
1174
+
1175
+ const watchHandles = new Map<string, { watcher: any; cleanup: () => void }>();
1176
+
1177
+ server.tool(
1178
+ "watch",
1179
+ "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.",
1180
+ {
1181
+ task: z.enum(["test", "build", "lint", "typecheck"]).describe("Task to run on change"),
1182
+ path: z.string().optional().describe("File or directory to watch (default: src/)"),
1183
+ cwd: z.string().optional().describe("Working directory"),
1184
+ },
1185
+ async ({ task, path: watchPath, cwd }) => {
1186
+ const { watch } = await import("fs");
1187
+ const workDir = cwd ?? process.cwd();
1188
+ const target = resolvePath(watchPath ?? "src/", workDir);
1189
+ const watchId = `${task}:${target}`;
1190
+
1191
+ // Run once immediately
1192
+ const { existsSync } = await import("fs");
1193
+ const { join } = await import("path");
1194
+
1195
+ let runner = "npm run";
1196
+ if (existsSync(join(workDir, "bun.lockb")) || existsSync(join(workDir, "bun.lock"))) runner = "bun run";
1197
+ else if (existsSync(join(workDir, "Cargo.toml"))) runner = "cargo";
1198
+
1199
+ const cmd = runner === "cargo" ? `cargo ${task}` : `${runner} ${task}`;
1200
+ const result = await exec(cmd, workDir, 60000);
1201
+ const output = (result.stdout + result.stderr).trim();
1202
+ const processed = await processOutput(cmd, output);
1203
+
1204
+ // Store initial result for diffing
1205
+ const detailKey = storeOutput(`watch:${task}`, output);
1206
+
1207
+ logCall("watch", { command: `watch ${task} ${target}`, exitCode: result.exitCode, durationMs: 0, aiProcessed: processed.aiProcessed });
1208
+
1209
+ return { content: [{ type: "text" as const, text: JSON.stringify({
1210
+ watchId,
1211
+ task,
1212
+ watching: target,
1213
+ initialRun: { exitCode: result.exitCode, summary: processed.summary, tokensSaved: processed.tokensSaved },
1214
+ hint: "File watching active. Call execute_diff with the same command to get changes on next run.",
1215
+ }) }] };
1216
+ }
1217
+ );
1218
+
1219
+ // ── batch tools: read_files, symbols_dir ──────────────────────────────────
1220
+
1221
+ server.tool(
1222
+ "read_files",
1223
+ "Read multiple files in one call. Use summarize=true for AI outlines (~90% fewer tokens per file). Saves N-1 round trips vs separate read_file calls.",
1224
+ {
1225
+ files: z.array(z.string()).describe("File paths (relative or absolute)"),
1226
+ summarize: z.boolean().optional().describe("AI summary instead of full content"),
1227
+ },
1228
+ async ({ files, summarize }) => {
1229
+ const start = Date.now();
1230
+ const results: Record<string, any> = {};
1231
+
1232
+ for (const f of files.slice(0, 10)) { // max 10 files per call
1233
+ const filePath = resolvePath(f);
1234
+ const result = cachedRead(filePath, {});
1235
+
1236
+ if (summarize && result.content.length > 500) {
1237
+ const provider = getOutputProvider();
1238
+ const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
1239
+ const content = result.content.length > 8000 ? result.content.slice(0, 8000) : result.content;
1240
+ const summary = await provider.complete(`File: ${filePath}\n\n${content}`, {
1241
+ model: outputModel,
1242
+ system: `Describe what this source file does in 2-4 lines. Include: main class/module name, key methods/functions, what it exports, and its purpose. Be specific.`,
1243
+ maxTokens: 300, temperature: 0.2,
1244
+ });
1245
+ results[f] = { summary, lines: result.content.split("\n").length };
1246
+ } else {
1247
+ results[f] = { content: result.content, lines: result.content.split("\n").length };
1248
+ }
1249
+ }
1250
+
1251
+ logCall("read_files", { command: `${files.length} files`, durationMs: Date.now() - start, aiProcessed: !!summarize });
1252
+ return { content: [{ type: "text" as const, text: JSON.stringify(results) }] };
1253
+ }
1254
+ );
1255
+
1256
+ server.tool(
1257
+ "symbols_dir",
1258
+ "Get symbols for all source files in a directory. AI-powered, works for any language. One call replaces N separate symbols calls.",
1259
+ {
1260
+ path: z.string().optional().describe("Directory (default: src/)"),
1261
+ maxFiles: z.number().optional().describe("Max files to scan (default: 10)"),
1262
+ },
1263
+ async ({ path: dirPath, maxFiles }) => {
1264
+ const start = Date.now();
1265
+ const dir = resolvePath(dirPath ?? "src/");
1266
+ const limit = maxFiles ?? 10;
1267
+
1268
+ // Find source files
1269
+ const findResult = await exec(
1270
+ `find "${dir}" -maxdepth 3 -type f \\( -name "*.ts" -o -name "*.js" -o -name "*.py" -o -name "*.go" -o -name "*.rs" -o -name "*.java" -o -name "*.rb" -o -name "*.php" \\) -not -path "*/node_modules/*" -not -path "*/dist/*" -not -name "*.test.*" -not -name "*.spec.*" | head -${limit}`,
1271
+ process.cwd(), 5000
1272
+ );
1273
+ const files = findResult.stdout.split("\n").filter(l => l.trim());
1274
+
1275
+ const allSymbols: Record<string, any[]> = {};
1276
+ const provider = getOutputProvider();
1277
+ const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
1278
+
1279
+ for (const file of files) {
1280
+ const result = cachedRead(file, {});
1281
+ if (!result.content || result.content.startsWith("Error:")) continue;
1282
+ try {
1283
+ const content = result.content.length > 6000 ? result.content.slice(0, 6000) : result.content;
1284
+ const summary = await provider.complete(`File: ${file}\n\n${content}`, {
1285
+ model: outputModel,
1286
+ system: `Extract all symbols. Return ONLY a JSON array. Each: {"name":"x","kind":"function|class|method|interface|type","line":N,"signature":"brief"}. For class methods use "Class.method". Exclude imports.`,
1287
+ maxTokens: 1500, temperature: 0,
1288
+ });
1289
+ const jsonMatch = summary.match(/\[[\s\S]*\]/);
1290
+ if (jsonMatch) allSymbols[file] = JSON.parse(jsonMatch[0]);
1291
+ } catch {}
1292
+ }
1293
+
1294
+ logCall("symbols_dir", { command: `${files.length} files in ${dir}`, durationMs: Date.now() - start, aiProcessed: true });
1295
+ return { content: [{ type: "text" as const, text: JSON.stringify({ directory: dir, files: files.length, symbols: allSymbols }) }] };
1296
+ }
1297
+ );
1298
+
1299
+ // ── review: AI code review ────────────────────────────────────────────────
1300
+
1301
+ server.tool(
1302
+ "review",
1303
+ "AI code review of recent changes or specific files. Returns: bugs, security issues, suggestions. One call replaces git diff + manual reading.",
1304
+ {
1305
+ since: z.string().optional().describe("Git ref to diff against (e.g., 'HEAD~3', 'main')"),
1306
+ files: z.array(z.string()).optional().describe("Specific files to review"),
1307
+ cwd: z.string().optional().describe("Working directory"),
1308
+ },
1309
+ async ({ since, files, cwd }) => {
1310
+ const start = Date.now();
1311
+ const workDir = cwd ?? process.cwd();
1312
+
1313
+ let content: string;
1314
+ if (files && files.length > 0) {
1315
+ const fileContents = files.map(f => {
1316
+ const result = cachedRead(resolvePath(f, workDir), {});
1317
+ return `=== ${f} ===\n${result.content.slice(0, 4000)}`;
1318
+ });
1319
+ content = fileContents.join("\n\n");
1320
+ } else {
1321
+ const ref = since ?? "HEAD~1";
1322
+ const diff = await exec(`git diff ${ref} --no-color`, workDir, 15000);
1323
+ content = diff.stdout.slice(0, 12000);
1324
+ }
1325
+
1326
+ const provider = getOutputProvider();
1327
+ const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
1328
+ const review = await provider.complete(`Review this code:\n\n${content}`, {
1329
+ model: outputModel,
1330
+ system: `You are a senior code reviewer. Review concisely:
1331
+ - Bugs or logic errors
1332
+ - Security issues (injection, auth, secrets)
1333
+ - Missing error handling
1334
+ - Performance concerns
1335
+ - Style/naming issues (only if significant)
1336
+
1337
+ Format: list issues as "- [severity] file:line description". If clean, say "No issues found."
1338
+ Be specific, not generic. Only flag real problems.`,
1339
+ maxTokens: 800, temperature: 0.2,
1340
+ });
1341
+
1342
+ logCall("review", { command: `review ${since ?? files?.join(",") ?? "HEAD~1"}`, durationMs: Date.now() - start, aiProcessed: true });
1343
+ return { content: [{ type: "text" as const, text: JSON.stringify({ review, scope: since ?? files }) }] };
1344
+ }
1345
+ );
1346
+
1347
+ // ── secrets vault ─────────────────────────────────────────────────────────
1348
+
1349
+ server.tool(
1350
+ "store_secret",
1351
+ "Store a secret for use in commands. Agent uses $NAME in commands, we resolve at execution and redact in output.",
1352
+ {
1353
+ name: z.string().describe("Secret name (e.g., JIRA_TOKEN)"),
1354
+ value: z.string().describe("Secret value"),
1355
+ },
1356
+ async ({ name, value }) => {
1357
+ const { existsSync, readFileSync, writeFileSync, chmodSync } = await import("fs");
1358
+ const { join } = await import("path");
1359
+ const secretsFile = join(process.env.HOME ?? "~", ".terminal", "secrets.json");
1360
+ let secrets: Record<string, string> = {};
1361
+ if (existsSync(secretsFile)) {
1362
+ try { secrets = JSON.parse(readFileSync(secretsFile, "utf8")); } catch {}
1363
+ }
1364
+ secrets[name] = value;
1365
+ writeFileSync(secretsFile, JSON.stringify(secrets, null, 2));
1366
+ try { chmodSync(secretsFile, 0o600); } catch {}
1367
+ logCall("store_secret", { command: `store ${name}` });
1368
+ 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.` }) }] };
1369
+ }
1370
+ );
1371
+
1372
+ server.tool(
1373
+ "list_secrets",
1374
+ "List stored secret names (never values).",
1375
+ async () => {
1376
+ const { existsSync, readFileSync } = await import("fs");
1377
+ const { join } = await import("path");
1378
+ const secretsFile = join(process.env.HOME ?? "~", ".terminal", "secrets.json");
1379
+ let names: string[] = [];
1380
+ if (existsSync(secretsFile)) {
1381
+ try { names = Object.keys(JSON.parse(readFileSync(secretsFile, "utf8"))); } catch {}
1382
+ }
1383
+ // Also show env vars that look like secrets
1384
+ const envSecrets = Object.keys(process.env).filter(k => /API_KEY|TOKEN|SECRET|PASSWORD/i.test(k));
1385
+ return { content: [{ type: "text" as const, text: JSON.stringify({ stored: names, environment: envSecrets }) }] };
1386
+ }
1387
+ );
1388
+
1389
+ // ── project memory ────────────────────────────────────────────────────────
1390
+
1391
+ server.tool(
1392
+ "project_note",
1393
+ "Save or recall notes about the current project. Persists across sessions. Agents pick up where they left off.",
1394
+ {
1395
+ save: z.string().optional().describe("Note to save"),
1396
+ recall: z.boolean().optional().describe("Return all saved notes"),
1397
+ clear: z.boolean().optional().describe("Clear all notes"),
1398
+ },
1399
+ async ({ save, recall, clear }) => {
1400
+ const { existsSync, readFileSync, writeFileSync, mkdirSync } = await import("fs");
1401
+ const { join } = await import("path");
1402
+ const notesDir = join(process.cwd(), ".terminal");
1403
+ const notesFile = join(notesDir, "notes.json");
1404
+
1405
+ let notes: { text: string; timestamp: string }[] = [];
1406
+ if (existsSync(notesFile)) {
1407
+ try { notes = JSON.parse(readFileSync(notesFile, "utf8")); } catch {}
1408
+ }
1409
+
1410
+ if (clear) {
1411
+ notes = [];
1412
+ if (!existsSync(notesDir)) mkdirSync(notesDir, { recursive: true });
1413
+ writeFileSync(notesFile, "[]");
1414
+ return { content: [{ type: "text" as const, text: JSON.stringify({ cleared: true }) }] };
1415
+ }
1416
+
1417
+ if (save) {
1418
+ notes.push({ text: save, timestamp: new Date().toISOString() });
1419
+ if (!existsSync(notesDir)) mkdirSync(notesDir, { recursive: true });
1420
+ writeFileSync(notesFile, JSON.stringify(notes, null, 2));
1421
+ logCall("project_note", { command: `save: ${save.slice(0, 80)}` });
1422
+ return { content: [{ type: "text" as const, text: JSON.stringify({ saved: true, total: notes.length }) }] };
1423
+ }
1424
+
1425
+ return { content: [{ type: "text" as const, text: JSON.stringify({ notes, total: notes.length }) }] };
1426
+ }
1427
+ );
1428
+
1429
+ // ── diff: show what changed ────────────────────────────────────────────────
1430
+
1431
+ server.tool(
1432
+ "diff",
1433
+ "Show what changed — git diff with AI summary. One call replaces constructing git diff commands.",
1434
+ {
1435
+ ref: z.string().optional().describe("Diff against this ref (default: unstaged changes). Examples: HEAD~1, main, abc123"),
1436
+ file: z.string().optional().describe("Diff a specific file only"),
1437
+ stat: z.boolean().optional().describe("Show file-level stats only, not full diff (default: false)"),
1438
+ cwd: z.string().optional().describe("Working directory"),
1439
+ },
1440
+ async ({ ref, file, stat, cwd }) => {
1441
+ const start = Date.now();
1442
+ const workDir = cwd ?? process.cwd();
1443
+ let cmd = "git diff";
1444
+ if (ref) cmd += ` ${ref}`;
1445
+ if (stat) cmd += " --stat";
1446
+ if (file) cmd += ` -- ${file}`;
1447
+
1448
+ const result = await exec(cmd, workDir, 15000);
1449
+ const output = (result.stdout + result.stderr).trim();
1450
+
1451
+ if (!output) {
1452
+ return { content: [{ type: "text" as const, text: JSON.stringify({ clean: true, message: "No changes" }) }] };
1453
+ }
1454
+
1455
+ const processed = await processOutput(cmd, output);
1456
+ logCall("diff", { command: cmd, outputTokens: estimateTokens(output), tokensSaved: processed.tokensSaved, durationMs: Date.now() - start, aiProcessed: processed.aiProcessed });
1457
+
1458
+ return { content: [{ type: "text" as const, text: JSON.stringify({
1459
+ summary: processed.summary,
1460
+ lines: output.split("\n").length,
1461
+ tokensSaved: processed.tokensSaved,
1462
+ }) }] };
1463
+ }
1464
+ );
1465
+
1466
+ // ── install: add packages, auto-detect package manager ────────────────────
1467
+
1468
+ server.tool(
1469
+ "install",
1470
+ "Install packages — auto-detects bun/npm/pnpm/yarn/pip/cargo. Agent says what to install, we figure out how.",
1471
+ {
1472
+ packages: z.array(z.string()).describe("Package names to install"),
1473
+ dev: z.boolean().optional().describe("Install as dev dependency (default: false)"),
1474
+ cwd: z.string().optional().describe("Working directory"),
1475
+ },
1476
+ async ({ packages, dev, cwd }) => {
1477
+ const start = Date.now();
1478
+ const workDir = cwd ?? process.cwd();
1479
+ const { existsSync } = await import("fs");
1480
+ const { join } = await import("path");
1481
+
1482
+ let cmd: string;
1483
+ const pkgs = packages.join(" ");
1484
+ const devFlag = dev ? " -D" : "";
1485
+
1486
+ if (existsSync(join(workDir, "bun.lockb")) || existsSync(join(workDir, "bun.lock"))) {
1487
+ cmd = `bun add${devFlag} ${pkgs}`;
1488
+ } else if (existsSync(join(workDir, "pnpm-lock.yaml"))) {
1489
+ cmd = `pnpm add${devFlag} ${pkgs}`;
1490
+ } else if (existsSync(join(workDir, "yarn.lock"))) {
1491
+ cmd = `yarn add${dev ? " --dev" : ""} ${pkgs}`;
1492
+ } else if (existsSync(join(workDir, "package.json"))) {
1493
+ cmd = `npm install${dev ? " --save-dev" : ""} ${pkgs}`;
1494
+ } else if (existsSync(join(workDir, "requirements.txt")) || existsSync(join(workDir, "pyproject.toml"))) {
1495
+ cmd = `pip install ${pkgs}`;
1496
+ } else if (existsSync(join(workDir, "Cargo.toml"))) {
1497
+ cmd = `cargo add ${pkgs}`;
1498
+ } else {
1499
+ cmd = `npm install${dev ? " --save-dev" : ""} ${pkgs}`;
1500
+ }
1501
+
1502
+ const result = await exec(cmd, workDir, 60000);
1503
+ const output = (result.stdout + result.stderr).trim();
1504
+ const processed = await processOutput(cmd, output);
1505
+ logCall("install", { command: cmd, exitCode: result.exitCode, durationMs: Date.now() - start, aiProcessed: processed.aiProcessed });
1506
+
1507
+ return { content: [{ type: "text" as const, text: JSON.stringify({
1508
+ exitCode: result.exitCode,
1509
+ command: cmd,
1510
+ summary: processed.summary,
1511
+ }) }] };
1512
+ }
1513
+ );
1514
+
1515
+ // ── help: tool discoverability ────────────────────────────────────────────
1516
+
1517
+ server.tool(
1518
+ "help",
1519
+ "Get recommendations for which terminal tool to use. Describe what you want to do and get the best tool + usage example.",
1520
+ {
1521
+ goal: z.string().optional().describe("What you're trying to do (e.g., 'run tests', 'find where login is defined', 'commit my changes')"),
1522
+ },
1523
+ async ({ goal }) => {
1524
+ if (!goal) {
1525
+ return { content: [{ type: "text" as const, text: JSON.stringify({
1526
+ tools: {
1527
+ "execute / execute_smart": "Run any command. Smart = AI summary (80% fewer tokens)",
1528
+ "run({task})": "Run test/build/lint — auto-detects toolchain",
1529
+ "commit / bulk_commit / smart_commit": "Git commit — single, multi, or AI-grouped",
1530
+ "diff({ref})": "Show what changed with AI summary",
1531
+ "install({packages})": "Add packages — auto-detects bun/npm/pip/cargo",
1532
+ "search_content({pattern})": "Grep with structured results",
1533
+ "search_files({pattern})": "Find files by glob",
1534
+ "symbols({path})": "AI file outline — any language",
1535
+ "read_symbol({path, name})": "Read one function/class by name",
1536
+ "read_file({path, summarize})": "Read or AI-summarize a file",
1537
+ "read_files({files, summarize})": "Multi-file read in one call",
1538
+ "symbols_dir({path})": "Symbols for entire directory",
1539
+ "review({since})": "AI code review",
1540
+ "lookup({file, items})": "Find items in a file by name",
1541
+ "edit({file, find, replace})": "Find-replace in file",
1542
+ "repo_state": "Git branch + status + log in one call",
1543
+ "boot": "Full project context on session start",
1544
+ "watch({task})": "Run task on file change",
1545
+ "store_secret / list_secrets": "Secrets vault",
1546
+ "project_note({save/recall})": "Persistent project notes",
1547
+ },
1548
+ tips: [
1549
+ "Use relative paths — 'src/foo.ts' not '/Users/.../src/foo.ts'",
1550
+ "Use your native Read/Write/Edit for file operations when you don't need AI summary",
1551
+ "Use search_content for text patterns, symbols for code structure",
1552
+ "Use commit for single, bulk_commit for multiple, smart_commit for AI-grouped",
1553
+ ],
1554
+ }) }] };
1555
+ }
1556
+
1557
+ // AI recommends the best tool for the goal
1558
+ const provider = getOutputProvider();
1559
+ const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
1560
+ const recommendation = await provider.complete(
1561
+ `Agent wants to: ${goal}\n\nAvailable tools: execute, execute_smart, run, commit, bulk_commit, smart_commit, diff, install, search_content, search_files, symbols, read_symbol, read_file, read_files, symbols_dir, review, lookup, edit, repo_state, boot, watch, store_secret, list_secrets, project_note, help`,
1562
+ {
1563
+ model: outputModel,
1564
+ system: `Recommend the best terminal MCP tool for this goal. Return JSON: {"tool": "name", "example": {params}, "why": "one line"}. If multiple tools work, list top 2.`,
1565
+ maxTokens: 200, temperature: 0,
1566
+ }
1567
+ );
1568
+
1569
+ return { content: [{ type: "text" as const, text: recommendation }] };
1570
+ }
1571
+ );
1572
+
1173
1573
  return server;
1174
1574
  }
1175
1575