@hasna/terminal 3.4.1 → 3.6.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.
@@ -7,6 +7,7 @@ import { compress, stripAnsi } from "../compression.js";
7
7
  import { stripNoise } from "../noise-filter.js";
8
8
  import { estimateTokens } from "../tokens.js";
9
9
  import { processOutput } from "../output-processor.js";
10
+ import { getOutputProvider } from "../providers/index.js";
10
11
  import { searchFiles, searchContent, semanticSearch } from "../search/index.js";
11
12
  import { listRecipes, listCollections, getRecipe, createRecipe } from "../recipes/storage.js";
12
13
  import { substituteVariables } from "../recipes/model.js";
@@ -545,12 +546,42 @@ export function createServer() {
545
546
  };
546
547
  });
547
548
  // ── symbols: file structure outline ───────────────────────────────────────
548
- server.tool("symbols", "Get a structured outline of a source file — functions, classes, interfaces, exports with line numbers. Replaces the common grep pattern: grep -n '^export|class|function' file.", {
549
+ server.tool("symbols", "Get a structured outline of any source file — functions, classes, methods, interfaces, exports with line numbers. Works for ALL languages (TypeScript, Python, Go, Rust, Java, C#, Ruby, PHP, etc.). AI-powered, not regex.", {
549
550
  path: z.string().describe("File path to extract symbols from"),
550
551
  }, async ({ path: filePath }) => {
551
- const { extractSymbolsFromFile } = await import("../search/semantic.js");
552
- const symbols = extractSymbolsFromFile(filePath).filter(s => s.kind !== "import");
553
- logCall("symbols", { command: filePath, outputTokens: symbols.length * 5, durationMs: 0 });
552
+ const start = Date.now();
553
+ const result = cachedRead(filePath, {});
554
+ if (!result.content || result.content.startsWith("Error:")) {
555
+ return { content: [{ type: "text", text: JSON.stringify({ error: `Cannot read ${filePath}` }) }] };
556
+ }
557
+ // AI extracts symbols — works for ANY language
558
+ const provider = getOutputProvider();
559
+ const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
560
+ const content = result.content.length > 6000 ? result.content.slice(0, 6000) : result.content;
561
+ const summary = await provider.complete(`File: ${filePath}\n\n${content}`, {
562
+ model: outputModel,
563
+ system: `Extract all symbols from this source file. Return ONLY a JSON array, no explanation.
564
+
565
+ Each symbol: {"name": "symbolName", "kind": "function|class|method|interface|type|variable|export", "line": lineNumber, "signature": "brief signature"}
566
+
567
+ For class methods, use "ClassName.methodName" as name with kind "method".
568
+ Include: functions, classes, methods, interfaces, types, exported constants.
569
+ Exclude: imports, local variables, comments.
570
+ Line numbers must be accurate (count from 1).`,
571
+ maxTokens: 1000,
572
+ temperature: 0,
573
+ });
574
+ // Parse AI response
575
+ let symbols = [];
576
+ try {
577
+ const jsonMatch = summary.match(/\[[\s\S]*\]/);
578
+ if (jsonMatch)
579
+ symbols = JSON.parse(jsonMatch[0]);
580
+ }
581
+ catch { }
582
+ const outputTokens = estimateTokens(result.content);
583
+ const symbolTokens = estimateTokens(JSON.stringify(symbols));
584
+ logCall("symbols", { command: filePath, outputTokens, tokensSaved: Math.max(0, outputTokens - symbolTokens), durationMs: Date.now() - start, aiProcessed: true });
554
585
  return {
555
586
  content: [{ type: "text", text: JSON.stringify(symbols) }],
556
587
  };
@@ -560,22 +591,169 @@ export function createServer() {
560
591
  path: z.string().describe("Source file path"),
561
592
  name: z.string().describe("Symbol name (function, class, interface)"),
562
593
  }, async ({ path: filePath, name }) => {
563
- const { extractBlock, extractSymbolsFromFile } = await import("../search/semantic.js");
564
- const block = extractBlock(filePath, name);
565
- if (!block) {
566
- // Return available symbols so the agent can pick the right one
567
- const symbols = extractSymbolsFromFile(filePath);
568
- const names = symbols.filter(s => s.kind !== "import").map(s => `${s.kind}: ${s.name} (L${s.line})`);
569
- return { content: [{ type: "text", text: JSON.stringify({
570
- error: `Symbol '${name}' not found`,
571
- available: names.slice(0, 20),
572
- }) }] };
594
+ const start = Date.now();
595
+ const result = cachedRead(filePath, {});
596
+ if (!result.content || result.content.startsWith("Error:")) {
597
+ return { content: [{ type: "text", text: JSON.stringify({ error: `Cannot read ${filePath}` }) }] };
598
+ }
599
+ // AI extracts the specific symbol works for ANY language
600
+ const provider = getOutputProvider();
601
+ const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
602
+ const summary = await provider.complete(`File: ${filePath}\nSymbol to extract: ${name}\n\n${result.content.slice(0, 8000)}`, {
603
+ model: outputModel,
604
+ system: `Extract the complete code block for the symbol "${name}" from this file. Return ONLY a JSON object:
605
+ {"name": "${name}", "code": "the complete code block", "startLine": N, "endLine": N}
606
+
607
+ If the symbol is not found, return: {"error": "not found", "available": ["list", "of", "symbol", "names"]}
608
+
609
+ Match by function name, class name, method name (including ClassName.method), interface, type, or variable name.`,
610
+ maxTokens: 2000,
611
+ temperature: 0,
612
+ });
613
+ let parsed = {};
614
+ try {
615
+ const jsonMatch = summary.match(/\{[\s\S]*\}/);
616
+ if (jsonMatch)
617
+ parsed = JSON.parse(jsonMatch[0]);
618
+ }
619
+ catch { }
620
+ logCall("read_symbol", { command: `${filePath}:${name}`, outputTokens: estimateTokens(result.content), tokensSaved: Math.max(0, estimateTokens(result.content) - estimateTokens(JSON.stringify(parsed))), durationMs: Date.now() - start, aiProcessed: true });
621
+ return { content: [{ type: "text", text: JSON.stringify(parsed) }] };
622
+ });
623
+ // ── Intent-level tools — agents express WHAT, we handle HOW ───────────────
624
+ server.tool("commit", "Commit and optionally push. Agent says what to commit, we handle git add/commit/push. Saves ~400 tokens vs raw git commands.", {
625
+ message: z.string().describe("Commit message"),
626
+ files: z.array(z.string()).optional().describe("Files to stage (default: all changed)"),
627
+ push: z.boolean().optional().describe("Push after commit (default: false)"),
628
+ cwd: z.string().optional().describe("Working directory"),
629
+ }, async ({ message, files, push, cwd }) => {
630
+ const start = Date.now();
631
+ const workDir = cwd ?? process.cwd();
632
+ const addCmd = files && files.length > 0 ? `git add ${files.map(f => `"${f}"`).join(" ")}` : "git add -A";
633
+ const commitCmd = `${addCmd} && git commit -m ${JSON.stringify(message)}`;
634
+ const fullCmd = push ? `${commitCmd} && git push` : commitCmd;
635
+ const result = await exec(fullCmd, workDir, 30000);
636
+ const output = (result.stdout + result.stderr).trim();
637
+ logCall("commit", { command: `commit: ${message.slice(0, 80)}`, durationMs: Date.now() - start, exitCode: result.exitCode });
638
+ invalidateBootCache();
639
+ return { content: [{ type: "text", text: JSON.stringify({
640
+ exitCode: result.exitCode,
641
+ output: stripAnsi(output).split("\n").filter(l => l.trim()).slice(0, 5).join("\n"),
642
+ pushed: push ?? false,
643
+ }) }] };
644
+ });
645
+ 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.", {
646
+ task: z.enum(["test", "build", "lint", "dev", "start", "typecheck", "format", "check"]).describe("What to run"),
647
+ args: z.string().optional().describe("Extra arguments (e.g., '--watch', 'src/foo.test.ts')"),
648
+ cwd: z.string().optional().describe("Working directory"),
649
+ }, async ({ task, args, cwd }) => {
650
+ const start = Date.now();
651
+ const workDir = cwd ?? process.cwd();
652
+ // Detect toolchain from project files
653
+ const { existsSync } = await import("fs");
654
+ const { join } = await import("path");
655
+ let runner = "npm run";
656
+ if (existsSync(join(workDir, "bun.lockb")) || existsSync(join(workDir, "bun.lock")))
657
+ runner = "bun run";
658
+ else if (existsSync(join(workDir, "pnpm-lock.yaml")))
659
+ runner = "pnpm run";
660
+ else if (existsSync(join(workDir, "yarn.lock")))
661
+ runner = "yarn";
662
+ else if (existsSync(join(workDir, "Cargo.toml")))
663
+ runner = "cargo";
664
+ else if (existsSync(join(workDir, "go.mod")))
665
+ runner = "go";
666
+ else if (existsSync(join(workDir, "Makefile")))
667
+ runner = "make";
668
+ // Map intent to command
669
+ let cmd;
670
+ if (runner === "cargo") {
671
+ cmd = `cargo ${task}${args ? ` ${args}` : ""}`;
672
+ }
673
+ else if (runner === "go") {
674
+ const goMap = { test: "go test ./...", build: "go build ./...", lint: "golangci-lint run", format: "gofmt -w .", check: "go vet ./..." };
675
+ cmd = goMap[task] ?? `go ${task}`;
676
+ }
677
+ else if (runner === "make") {
678
+ cmd = `make ${task}${args ? ` ${args}` : ""}`;
679
+ }
680
+ else {
681
+ // JS/TS ecosystem
682
+ const jsMap = { test: "test", build: "build", lint: "lint", dev: "dev", start: "start", typecheck: "typecheck", format: "format", check: "check" };
683
+ cmd = `${runner} ${jsMap[task] ?? task}${args ? ` ${args}` : ""}`;
573
684
  }
685
+ const result = await exec(cmd, workDir, 120000);
686
+ const output = (result.stdout + result.stderr).trim();
687
+ const processed = await processOutput(cmd, output);
688
+ logCall("run", { command: `${task}${args ? ` ${args}` : ""}`, outputTokens: estimateTokens(output), tokensSaved: processed.tokensSaved, durationMs: Date.now() - start, exitCode: result.exitCode, aiProcessed: processed.aiProcessed });
574
689
  return { content: [{ type: "text", text: JSON.stringify({
575
- name, code: block.code, startLine: block.startLine, endLine: block.endLine,
576
- lines: block.endLine - block.startLine + 1,
690
+ exitCode: result.exitCode,
691
+ task,
692
+ runner,
693
+ summary: processed.summary,
694
+ tokensSaved: processed.tokensSaved,
577
695
  }) }] };
578
696
  });
697
+ server.tool("edit", "Find and replace text in a file. Agent says what to change, no sed/awk/python needed. Saves ~200 tokens vs constructing shell commands.", {
698
+ file: z.string().describe("File path"),
699
+ find: z.string().describe("Text to find (exact match)"),
700
+ replace: z.string().describe("Replacement text"),
701
+ all: z.boolean().optional().describe("Replace all occurrences (default: first only)"),
702
+ }, async ({ file, find, replace, all }) => {
703
+ const start = Date.now();
704
+ const { readFileSync, writeFileSync } = await import("fs");
705
+ try {
706
+ let content = readFileSync(file, "utf8");
707
+ const count = (content.match(new RegExp(find.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g")) || []).length;
708
+ if (count === 0) {
709
+ return { content: [{ type: "text", text: JSON.stringify({ error: "Text not found", file }) }] };
710
+ }
711
+ if (all) {
712
+ content = content.split(find).join(replace);
713
+ }
714
+ else {
715
+ content = content.replace(find, replace);
716
+ }
717
+ writeFileSync(file, content);
718
+ logCall("edit", { command: `edit ${file}`, durationMs: Date.now() - start });
719
+ return { content: [{ type: "text", text: JSON.stringify({ ok: true, file, replacements: all ? count : 1 }) }] };
720
+ }
721
+ catch (e) {
722
+ return { content: [{ type: "text", text: JSON.stringify({ error: e.message }) }] };
723
+ }
724
+ });
725
+ server.tool("lookup", "Search for specific items in a file by name or pattern. Agent says what to find, not how to grep. Saves ~300 tokens vs constructing grep pipelines.", {
726
+ file: z.string().describe("File path to search in"),
727
+ items: z.array(z.string()).describe("Names or patterns to look up"),
728
+ context: z.number().optional().describe("Lines of context around each match (default: 3)"),
729
+ }, async ({ file, items, context }) => {
730
+ const start = Date.now();
731
+ const { readFileSync } = await import("fs");
732
+ try {
733
+ const content = readFileSync(file, "utf8");
734
+ const lines = content.split("\n");
735
+ const ctx = context ?? 3;
736
+ const results = {};
737
+ for (const item of items) {
738
+ results[item] = [];
739
+ const pattern = new RegExp(item.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
740
+ for (let i = 0; i < lines.length; i++) {
741
+ if (pattern.test(lines[i])) {
742
+ results[item].push({
743
+ line: i + 1,
744
+ text: lines[i].trim(),
745
+ context: lines.slice(Math.max(0, i - ctx), i + ctx + 1).map(l => l.trimEnd()),
746
+ });
747
+ }
748
+ }
749
+ }
750
+ logCall("lookup", { command: `lookup ${file} [${items.join(",")}]`, durationMs: Date.now() - start });
751
+ return { content: [{ type: "text", text: JSON.stringify(results) }] };
752
+ }
753
+ catch (e) {
754
+ return { content: [{ type: "text", text: JSON.stringify({ error: e.message }) }] };
755
+ }
756
+ });
579
757
  return server;
580
758
  }
581
759
  // ── main: start MCP server via stdio ─────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/terminal",
3
- "version": "3.4.1",
3
+ "version": "3.6.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
@@ -8,6 +8,7 @@ import { compress, stripAnsi } from "../compression.js";
8
8
  import { stripNoise } from "../noise-filter.js";
9
9
  import { estimateTokens } from "../tokens.js";
10
10
  import { processOutput } from "../output-processor.js";
11
+ import { getOutputProvider } from "../providers/index.js";
11
12
  import { searchFiles, searchContent, semanticSearch } from "../search/index.js";
12
13
  import { listRecipes, listCollections, getRecipe, createRecipe } from "../recipes/storage.js";
13
14
  import { substituteVariables } from "../recipes/model.js";
@@ -762,14 +763,49 @@ export function createServer(): McpServer {
762
763
 
763
764
  server.tool(
764
765
  "symbols",
765
- "Get a structured outline of a source file — functions, classes, interfaces, exports with line numbers. Replaces the common grep pattern: grep -n '^export|class|function' file.",
766
+ "Get a structured outline of any source file — functions, classes, methods, interfaces, exports with line numbers. Works for ALL languages (TypeScript, Python, Go, Rust, Java, C#, Ruby, PHP, etc.). AI-powered, not regex.",
766
767
  {
767
768
  path: z.string().describe("File path to extract symbols from"),
768
769
  },
769
770
  async ({ path: filePath }) => {
770
- const { extractSymbolsFromFile } = await import("../search/semantic.js");
771
- const symbols = extractSymbolsFromFile(filePath).filter(s => s.kind !== "import");
772
- logCall("symbols", { command: filePath, outputTokens: symbols.length * 5, durationMs: 0 });
771
+ const start = Date.now();
772
+ const result = cachedRead(filePath, {});
773
+ if (!result.content || result.content.startsWith("Error:")) {
774
+ return { content: [{ type: "text" as const, text: JSON.stringify({ error: `Cannot read ${filePath}` }) }] };
775
+ }
776
+
777
+ // AI extracts symbols — works for ANY language
778
+ const provider = getOutputProvider();
779
+ const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
780
+ const content = result.content.length > 6000 ? result.content.slice(0, 6000) : result.content;
781
+ const summary = await provider.complete(
782
+ `File: ${filePath}\n\n${content}`,
783
+ {
784
+ model: outputModel,
785
+ system: `Extract all symbols from this source file. Return ONLY a JSON array, no explanation.
786
+
787
+ Each symbol: {"name": "symbolName", "kind": "function|class|method|interface|type|variable|export", "line": lineNumber, "signature": "brief signature"}
788
+
789
+ For class methods, use "ClassName.methodName" as name with kind "method".
790
+ Include: functions, classes, methods, interfaces, types, exported constants.
791
+ Exclude: imports, local variables, comments.
792
+ Line numbers must be accurate (count from 1).`,
793
+ maxTokens: 1000,
794
+ temperature: 0,
795
+ }
796
+ );
797
+
798
+ // Parse AI response
799
+ let symbols: any[] = [];
800
+ try {
801
+ const jsonMatch = summary.match(/\[[\s\S]*\]/);
802
+ if (jsonMatch) symbols = JSON.parse(jsonMatch[0]);
803
+ } catch {}
804
+
805
+ const outputTokens = estimateTokens(result.content);
806
+ const symbolTokens = estimateTokens(JSON.stringify(symbols));
807
+ logCall("symbols", { command: filePath, outputTokens, tokensSaved: Math.max(0, outputTokens - symbolTokens), durationMs: Date.now() - start, aiProcessed: true });
808
+
773
809
  return {
774
810
  content: [{ type: "text" as const, text: JSON.stringify(symbols) }],
775
811
  };
@@ -786,24 +822,197 @@ export function createServer(): McpServer {
786
822
  name: z.string().describe("Symbol name (function, class, interface)"),
787
823
  },
788
824
  async ({ path: filePath, name }) => {
789
- const { extractBlock, extractSymbolsFromFile } = await import("../search/semantic.js");
790
- const block = extractBlock(filePath, name);
791
- if (!block) {
792
- // Return available symbols so the agent can pick the right one
793
- const symbols = extractSymbolsFromFile(filePath);
794
- const names = symbols.filter(s => s.kind !== "import").map(s => `${s.kind}: ${s.name} (L${s.line})`);
795
- return { content: [{ type: "text" as const, text: JSON.stringify({
796
- error: `Symbol '${name}' not found`,
797
- available: names.slice(0, 20),
798
- }) }] };
825
+ const start = Date.now();
826
+ const result = cachedRead(filePath, {});
827
+ if (!result.content || result.content.startsWith("Error:")) {
828
+ return { content: [{ type: "text" as const, text: JSON.stringify({ error: `Cannot read ${filePath}` }) }] };
799
829
  }
830
+
831
+ // AI extracts the specific symbol — works for ANY language
832
+ const provider = getOutputProvider();
833
+ const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
834
+ const summary = await provider.complete(
835
+ `File: ${filePath}\nSymbol to extract: ${name}\n\n${result.content.slice(0, 8000)}`,
836
+ {
837
+ model: outputModel,
838
+ system: `Extract the complete code block for the symbol "${name}" from this file. Return ONLY a JSON object:
839
+ {"name": "${name}", "code": "the complete code block", "startLine": N, "endLine": N}
840
+
841
+ If the symbol is not found, return: {"error": "not found", "available": ["list", "of", "symbol", "names"]}
842
+
843
+ Match by function name, class name, method name (including ClassName.method), interface, type, or variable name.`,
844
+ maxTokens: 2000,
845
+ temperature: 0,
846
+ }
847
+ );
848
+
849
+ let parsed: any = {};
850
+ try {
851
+ const jsonMatch = summary.match(/\{[\s\S]*\}/);
852
+ if (jsonMatch) parsed = JSON.parse(jsonMatch[0]);
853
+ } catch {}
854
+
855
+ logCall("read_symbol", { command: `${filePath}:${name}`, outputTokens: estimateTokens(result.content), tokensSaved: Math.max(0, estimateTokens(result.content) - estimateTokens(JSON.stringify(parsed))), durationMs: Date.now() - start, aiProcessed: true });
856
+
857
+ return { content: [{ type: "text" as const, text: JSON.stringify(parsed) }] };
858
+ }
859
+ );
860
+
861
+ // ── Intent-level tools — agents express WHAT, we handle HOW ───────────────
862
+
863
+ server.tool(
864
+ "commit",
865
+ "Commit and optionally push. Agent says what to commit, we handle git add/commit/push. Saves ~400 tokens vs raw git commands.",
866
+ {
867
+ message: z.string().describe("Commit message"),
868
+ files: z.array(z.string()).optional().describe("Files to stage (default: all changed)"),
869
+ push: z.boolean().optional().describe("Push after commit (default: false)"),
870
+ cwd: z.string().optional().describe("Working directory"),
871
+ },
872
+ async ({ message, files, push, cwd }) => {
873
+ const start = Date.now();
874
+ const workDir = cwd ?? process.cwd();
875
+ const addCmd = files && files.length > 0 ? `git add ${files.map(f => `"${f}"`).join(" ")}` : "git add -A";
876
+ const commitCmd = `${addCmd} && git commit -m ${JSON.stringify(message)}`;
877
+ const fullCmd = push ? `${commitCmd} && git push` : commitCmd;
878
+
879
+ const result = await exec(fullCmd, workDir, 30000);
880
+ const output = (result.stdout + result.stderr).trim();
881
+ logCall("commit", { command: `commit: ${message.slice(0, 80)}`, durationMs: Date.now() - start, exitCode: result.exitCode });
882
+ invalidateBootCache();
883
+
800
884
  return { content: [{ type: "text" as const, text: JSON.stringify({
801
- name, code: block.code, startLine: block.startLine, endLine: block.endLine,
802
- lines: block.endLine - block.startLine + 1,
885
+ exitCode: result.exitCode,
886
+ output: stripAnsi(output).split("\n").filter(l => l.trim()).slice(0, 5).join("\n"),
887
+ pushed: push ?? false,
803
888
  }) }] };
804
889
  }
805
890
  );
806
891
 
892
+ server.tool(
893
+ "run",
894
+ "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.",
895
+ {
896
+ task: z.enum(["test", "build", "lint", "dev", "start", "typecheck", "format", "check"]).describe("What to run"),
897
+ args: z.string().optional().describe("Extra arguments (e.g., '--watch', 'src/foo.test.ts')"),
898
+ cwd: z.string().optional().describe("Working directory"),
899
+ },
900
+ async ({ task, args, cwd }) => {
901
+ const start = Date.now();
902
+ const workDir = cwd ?? process.cwd();
903
+
904
+ // Detect toolchain from project files
905
+ const { existsSync } = await import("fs");
906
+ const { join } = await import("path");
907
+ let runner = "npm run";
908
+ if (existsSync(join(workDir, "bun.lockb")) || existsSync(join(workDir, "bun.lock"))) runner = "bun run";
909
+ else if (existsSync(join(workDir, "pnpm-lock.yaml"))) runner = "pnpm run";
910
+ else if (existsSync(join(workDir, "yarn.lock"))) runner = "yarn";
911
+ else if (existsSync(join(workDir, "Cargo.toml"))) runner = "cargo";
912
+ else if (existsSync(join(workDir, "go.mod"))) runner = "go";
913
+ else if (existsSync(join(workDir, "Makefile"))) runner = "make";
914
+
915
+ // Map intent to command
916
+ let cmd: string;
917
+ if (runner === "cargo") {
918
+ cmd = `cargo ${task}${args ? ` ${args}` : ""}`;
919
+ } else if (runner === "go") {
920
+ const goMap: Record<string, string> = { test: "go test ./...", build: "go build ./...", lint: "golangci-lint run", format: "gofmt -w .", check: "go vet ./..." };
921
+ cmd = goMap[task] ?? `go ${task}`;
922
+ } else if (runner === "make") {
923
+ cmd = `make ${task}${args ? ` ${args}` : ""}`;
924
+ } else {
925
+ // JS/TS ecosystem
926
+ const jsMap: Record<string, string> = { test: "test", build: "build", lint: "lint", dev: "dev", start: "start", typecheck: "typecheck", format: "format", check: "check" };
927
+ cmd = `${runner} ${jsMap[task] ?? task}${args ? ` ${args}` : ""}`;
928
+ }
929
+
930
+ const result = await exec(cmd, workDir, 120000);
931
+ const output = (result.stdout + result.stderr).trim();
932
+ const processed = await processOutput(cmd, output);
933
+ logCall("run", { command: `${task}${args ? ` ${args}` : ""}`, outputTokens: estimateTokens(output), tokensSaved: processed.tokensSaved, durationMs: Date.now() - start, exitCode: result.exitCode, aiProcessed: processed.aiProcessed });
934
+
935
+ return { content: [{ type: "text" as const, text: JSON.stringify({
936
+ exitCode: result.exitCode,
937
+ task,
938
+ runner,
939
+ summary: processed.summary,
940
+ tokensSaved: processed.tokensSaved,
941
+ }) }] };
942
+ }
943
+ );
944
+
945
+ server.tool(
946
+ "edit",
947
+ "Find and replace text in a file. Agent says what to change, no sed/awk/python needed. Saves ~200 tokens vs constructing shell commands.",
948
+ {
949
+ file: z.string().describe("File path"),
950
+ find: z.string().describe("Text to find (exact match)"),
951
+ replace: z.string().describe("Replacement text"),
952
+ all: z.boolean().optional().describe("Replace all occurrences (default: first only)"),
953
+ },
954
+ async ({ file, find, replace, all }) => {
955
+ const start = Date.now();
956
+ const { readFileSync, writeFileSync } = await import("fs");
957
+ try {
958
+ let content = readFileSync(file, "utf8");
959
+ const count = (content.match(new RegExp(find.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g")) || []).length;
960
+ if (count === 0) {
961
+ return { content: [{ type: "text" as const, text: JSON.stringify({ error: "Text not found", file }) }] };
962
+ }
963
+ if (all) {
964
+ content = content.split(find).join(replace);
965
+ } else {
966
+ content = content.replace(find, replace);
967
+ }
968
+ writeFileSync(file, content);
969
+ logCall("edit", { command: `edit ${file}`, durationMs: Date.now() - start });
970
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, file, replacements: all ? count : 1 }) }] };
971
+ } catch (e: any) {
972
+ return { content: [{ type: "text" as const, text: JSON.stringify({ error: e.message }) }] };
973
+ }
974
+ }
975
+ );
976
+
977
+ server.tool(
978
+ "lookup",
979
+ "Search for specific items in a file by name or pattern. Agent says what to find, not how to grep. Saves ~300 tokens vs constructing grep pipelines.",
980
+ {
981
+ file: z.string().describe("File path to search in"),
982
+ items: z.array(z.string()).describe("Names or patterns to look up"),
983
+ context: z.number().optional().describe("Lines of context around each match (default: 3)"),
984
+ },
985
+ async ({ file, items, context }) => {
986
+ const start = Date.now();
987
+ const { readFileSync } = await import("fs");
988
+ try {
989
+ const content = readFileSync(file, "utf8");
990
+ const lines = content.split("\n");
991
+ const ctx = context ?? 3;
992
+ const results: Record<string, { line: number; text: string; context: string[] }[]> = {};
993
+
994
+ for (const item of items) {
995
+ results[item] = [];
996
+ const pattern = new RegExp(item.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
997
+ for (let i = 0; i < lines.length; i++) {
998
+ if (pattern.test(lines[i])) {
999
+ results[item].push({
1000
+ line: i + 1,
1001
+ text: lines[i].trim(),
1002
+ context: lines.slice(Math.max(0, i - ctx), i + ctx + 1).map(l => l.trimEnd()),
1003
+ });
1004
+ }
1005
+ }
1006
+ }
1007
+
1008
+ logCall("lookup", { command: `lookup ${file} [${items.join(",")}]`, durationMs: Date.now() - start });
1009
+ return { content: [{ type: "text" as const, text: JSON.stringify(results) }] };
1010
+ } catch (e: any) {
1011
+ return { content: [{ type: "text" as const, text: JSON.stringify({ error: e.message }) }] };
1012
+ }
1013
+ }
1014
+ );
1015
+
807
1016
  return server;
808
1017
  }
809
1018