@hasna/terminal 3.8.0 → 4.0.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.
@@ -674,6 +674,32 @@ Match by function name, class name, method name (including ClassName.method), in
674
674
  pushed: push ?? false,
675
675
  }) }] };
676
676
  });
677
+ server.tool("bulk_commit", "Multiple logical commits in one call. Agent decides which files go in which commit, we handle all git commands. No AI cost. Use smart_commit instead if you want AI to decide the grouping.", {
678
+ commits: z.array(z.object({
679
+ message: z.string().describe("Commit message"),
680
+ files: z.array(z.string()).describe("Files to stage for this commit"),
681
+ })).describe("Array of logical commits"),
682
+ push: z.boolean().optional().describe("Push after all commits (default: true)"),
683
+ cwd: z.string().optional().describe("Working directory"),
684
+ }, async ({ commits, push, cwd }) => {
685
+ const start = Date.now();
686
+ const workDir = cwd ?? process.cwd();
687
+ const results = [];
688
+ for (const c of commits) {
689
+ const fileArgs = c.files.map(f => `"${f}"`).join(" ");
690
+ const cmd = `git add ${fileArgs} && git commit -m ${JSON.stringify(c.message)}`;
691
+ const r = await exec(cmd, workDir, 15000);
692
+ results.push({ message: c.message, files: c.files.length, ok: r.exitCode === 0 });
693
+ }
694
+ let pushed = false;
695
+ if (push !== false) {
696
+ const pushResult = await exec("git push", workDir, 30000);
697
+ pushed = pushResult.exitCode === 0;
698
+ }
699
+ invalidateBootCache();
700
+ logCall("bulk_commit", { command: `${commits.length} commits`, durationMs: Date.now() - start });
701
+ return { content: [{ type: "text", text: JSON.stringify({ commits: results, pushed, total: results.length }) }] };
702
+ });
677
703
  server.tool("run", "Run a project task by intent — test, build, lint, dev, typecheck, format. Auto-detects toolchain (bun/npm/pnpm/yarn/cargo/go/make). Saves ~100 tokens vs raw commands.", {
678
704
  task: z.enum(["test", "build", "lint", "dev", "start", "typecheck", "format", "check"]).describe("What to run"),
679
705
  args: z.string().optional().describe("Extra arguments (e.g., '--watch', 'src/foo.test.ts')"),
@@ -856,6 +882,213 @@ Rules:
856
882
  ok: results.every(r => r.ok),
857
883
  }) }] };
858
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
+ });
859
1092
  return server;
860
1093
  }
861
1094
  // ── 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.0",
3
+ "version": "4.0.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
@@ -923,6 +923,42 @@ Match by function name, class name, method name (including ClassName.method), in
923
923
  }
924
924
  );
925
925
 
926
+ server.tool(
927
+ "bulk_commit",
928
+ "Multiple logical commits in one call. Agent decides which files go in which commit, we handle all git commands. No AI cost. Use smart_commit instead if you want AI to decide the grouping.",
929
+ {
930
+ commits: z.array(z.object({
931
+ message: z.string().describe("Commit message"),
932
+ files: z.array(z.string()).describe("Files to stage for this commit"),
933
+ })).describe("Array of logical commits"),
934
+ push: z.boolean().optional().describe("Push after all commits (default: true)"),
935
+ cwd: z.string().optional().describe("Working directory"),
936
+ },
937
+ async ({ commits, push, cwd }) => {
938
+ const start = Date.now();
939
+ const workDir = cwd ?? process.cwd();
940
+ const results: { message: string; files: number; ok: boolean }[] = [];
941
+
942
+ for (const c of commits) {
943
+ const fileArgs = c.files.map(f => `"${f}"`).join(" ");
944
+ const cmd = `git add ${fileArgs} && git commit -m ${JSON.stringify(c.message)}`;
945
+ const r = await exec(cmd, workDir, 15000);
946
+ results.push({ message: c.message, files: c.files.length, ok: r.exitCode === 0 });
947
+ }
948
+
949
+ let pushed = false;
950
+ if (push !== false) {
951
+ const pushResult = await exec("git push", workDir, 30000);
952
+ pushed = pushResult.exitCode === 0;
953
+ }
954
+
955
+ invalidateBootCache();
956
+ logCall("bulk_commit", { command: `${commits.length} commits`, durationMs: Date.now() - start });
957
+
958
+ return { content: [{ type: "text" as const, text: JSON.stringify({ commits: results, pushed, total: results.length }) }] };
959
+ }
960
+ );
961
+
926
962
  server.tool(
927
963
  "run",
928
964
  "Run a project task by intent — test, build, lint, dev, typecheck, format. Auto-detects toolchain (bun/npm/pnpm/yarn/cargo/go/make). Saves ~100 tokens vs raw commands.",
@@ -1134,6 +1170,262 @@ Rules:
1134
1170
  }
1135
1171
  );
1136
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
+
1137
1429
  return server;
1138
1430
  }
1139
1431