@hasna/terminal 3.5.0 → 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.
@@ -620,6 +620,140 @@ Match by function name, class name, method name (including ClassName.method), in
620
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
621
  return { content: [{ type: "text", text: JSON.stringify(parsed) }] };
622
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}` : ""}`;
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 });
689
+ return { content: [{ type: "text", text: JSON.stringify({
690
+ exitCode: result.exitCode,
691
+ task,
692
+ runner,
693
+ summary: processed.summary,
694
+ tokensSaved: processed.tokensSaved,
695
+ }) }] };
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
+ });
623
757
  return server;
624
758
  }
625
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.5.0",
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
@@ -858,6 +858,161 @@ Match by function name, class name, method name (including ClassName.method), in
858
858
  }
859
859
  );
860
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
+
884
+ return { content: [{ type: "text" as const, text: JSON.stringify({
885
+ exitCode: result.exitCode,
886
+ output: stripAnsi(output).split("\n").filter(l => l.trim()).slice(0, 5).join("\n"),
887
+ pushed: push ?? false,
888
+ }) }] };
889
+ }
890
+ );
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
+
861
1016
  return server;
862
1017
  }
863
1018