@hasna/terminal 3.7.4 → 3.8.1

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')"),
@@ -788,6 +814,74 @@ Match by function name, class name, method name (including ClassName.method), in
788
814
  return { content: [{ type: "text", text: JSON.stringify({ error: e.message }) }] };
789
815
  }
790
816
  });
817
+ server.tool("smart_commit", "AI-powered git commit. Analyzes all changes, groups into logical commits with generated messages, stages and commits each group, optionally pushes. One call replaces the entire git workflow. Agent just says 'commit my work'.", {
818
+ push: z.boolean().optional().describe("Push after all commits (default: true)"),
819
+ hint: z.string().optional().describe("Optional context about the changes (e.g., 'fixed auth + added users endpoint')"),
820
+ cwd: z.string().optional().describe("Working directory"),
821
+ }, async ({ push, hint, cwd }) => {
822
+ const start = Date.now();
823
+ const workDir = cwd ?? process.cwd();
824
+ // 1. Get all changed files
825
+ const status = await exec("git status --porcelain", workDir, 10000);
826
+ const diffStat = await exec("git diff --stat", workDir, 10000);
827
+ const untrackedDiff = await exec("git diff HEAD --stat", workDir, 10000);
828
+ const changedFiles = status.stdout.trim();
829
+ if (!changedFiles) {
830
+ return { content: [{ type: "text", text: JSON.stringify({ message: "Nothing to commit — working tree clean" }) }] };
831
+ }
832
+ // 2. AI groups changes into logical commits
833
+ const provider = getOutputProvider();
834
+ const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
835
+ const grouping = await provider.complete(`Changed files:\n${changedFiles}\n\nDiff stats:\n${diffStat.stdout}\n${untrackedDiff.stdout}${hint ? `\n\nContext: ${hint}` : ""}`, {
836
+ model: outputModel,
837
+ system: `You are a git commit assistant. Group these changed files into logical commits. Return ONLY a JSON array:
838
+
839
+ [{"message": "conventional commit message", "files": ["file1.ts", "file2.ts"]}]
840
+
841
+ Rules:
842
+ - Group related changes (same feature, same fix, same refactor)
843
+ - Use conventional commits: feat:, fix:, refactor:, test:, docs:, chore:
844
+ - Message should explain WHY, not WHAT (the diff shows what)
845
+ - Each file appears in exactly one group
846
+ - If all changes are related, use a single commit
847
+ - Extract file paths from the status output (skip the status prefix like M, A, ??)`,
848
+ maxTokens: 1000,
849
+ temperature: 0,
850
+ });
851
+ let commits = [];
852
+ try {
853
+ const jsonMatch = grouping.match(/\[[\s\S]*\]/);
854
+ if (jsonMatch)
855
+ commits = JSON.parse(jsonMatch[0]);
856
+ }
857
+ catch { }
858
+ if (commits.length === 0) {
859
+ // Fallback: single commit with all files
860
+ commits = [{ message: hint ?? "chore: update files", files: changedFiles.split("\n").map(l => l.slice(3).trim()) }];
861
+ }
862
+ // 3. Execute each commit
863
+ const results = [];
864
+ for (const c of commits) {
865
+ const fileArgs = c.files.map(f => `"${f}"`).join(" ");
866
+ const cmd = `git add ${fileArgs} && git commit -m ${JSON.stringify(c.message)}`;
867
+ const r = await exec(cmd, workDir, 15000);
868
+ results.push({ message: c.message, files: c.files.length, ok: r.exitCode === 0 });
869
+ }
870
+ // 4. Push if requested
871
+ let pushed = false;
872
+ if (push !== false) {
873
+ const pushResult = await exec("git push", workDir, 30000);
874
+ pushed = pushResult.exitCode === 0;
875
+ }
876
+ invalidateBootCache();
877
+ logCall("smart_commit", { command: `${commits.length} commits`, durationMs: Date.now() - start, aiProcessed: true });
878
+ return { content: [{ type: "text", text: JSON.stringify({
879
+ commits: results,
880
+ pushed,
881
+ total: results.length,
882
+ ok: results.every(r => r.ok),
883
+ }) }] };
884
+ });
791
885
  return server;
792
886
  }
793
887
  // ── main: start MCP server via stdio ─────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/terminal",
3
- "version": "3.7.4",
3
+ "version": "3.8.1",
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.",
@@ -1049,6 +1085,91 @@ Match by function name, class name, method name (including ClassName.method), in
1049
1085
  }
1050
1086
  );
1051
1087
 
1088
+ server.tool(
1089
+ "smart_commit",
1090
+ "AI-powered git commit. Analyzes all changes, groups into logical commits with generated messages, stages and commits each group, optionally pushes. One call replaces the entire git workflow. Agent just says 'commit my work'.",
1091
+ {
1092
+ push: z.boolean().optional().describe("Push after all commits (default: true)"),
1093
+ hint: z.string().optional().describe("Optional context about the changes (e.g., 'fixed auth + added users endpoint')"),
1094
+ cwd: z.string().optional().describe("Working directory"),
1095
+ },
1096
+ async ({ push, hint, cwd }) => {
1097
+ const start = Date.now();
1098
+ const workDir = cwd ?? process.cwd();
1099
+
1100
+ // 1. Get all changed files
1101
+ const status = await exec("git status --porcelain", workDir, 10000);
1102
+ const diffStat = await exec("git diff --stat", workDir, 10000);
1103
+ const untrackedDiff = await exec("git diff HEAD --stat", workDir, 10000);
1104
+
1105
+ const changedFiles = status.stdout.trim();
1106
+ if (!changedFiles) {
1107
+ return { content: [{ type: "text" as const, text: JSON.stringify({ message: "Nothing to commit — working tree clean" }) }] };
1108
+ }
1109
+
1110
+ // 2. AI groups changes into logical commits
1111
+ const provider = getOutputProvider();
1112
+ const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
1113
+
1114
+ const grouping = await provider.complete(
1115
+ `Changed files:\n${changedFiles}\n\nDiff stats:\n${diffStat.stdout}\n${untrackedDiff.stdout}${hint ? `\n\nContext: ${hint}` : ""}`,
1116
+ {
1117
+ model: outputModel,
1118
+ system: `You are a git commit assistant. Group these changed files into logical commits. Return ONLY a JSON array:
1119
+
1120
+ [{"message": "conventional commit message", "files": ["file1.ts", "file2.ts"]}]
1121
+
1122
+ Rules:
1123
+ - Group related changes (same feature, same fix, same refactor)
1124
+ - Use conventional commits: feat:, fix:, refactor:, test:, docs:, chore:
1125
+ - Message should explain WHY, not WHAT (the diff shows what)
1126
+ - Each file appears in exactly one group
1127
+ - If all changes are related, use a single commit
1128
+ - Extract file paths from the status output (skip the status prefix like M, A, ??)`,
1129
+ maxTokens: 1000,
1130
+ temperature: 0,
1131
+ }
1132
+ );
1133
+
1134
+ let commits: { message: string; files: string[] }[] = [];
1135
+ try {
1136
+ const jsonMatch = grouping.match(/\[[\s\S]*\]/);
1137
+ if (jsonMatch) commits = JSON.parse(jsonMatch[0]);
1138
+ } catch {}
1139
+
1140
+ if (commits.length === 0) {
1141
+ // Fallback: single commit with all files
1142
+ commits = [{ message: hint ?? "chore: update files", files: changedFiles.split("\n").map(l => l.slice(3).trim()) }];
1143
+ }
1144
+
1145
+ // 3. Execute each commit
1146
+ const results: { message: string; files: number; ok: boolean }[] = [];
1147
+ for (const c of commits) {
1148
+ const fileArgs = c.files.map(f => `"${f}"`).join(" ");
1149
+ const cmd = `git add ${fileArgs} && git commit -m ${JSON.stringify(c.message)}`;
1150
+ const r = await exec(cmd, workDir, 15000);
1151
+ results.push({ message: c.message, files: c.files.length, ok: r.exitCode === 0 });
1152
+ }
1153
+
1154
+ // 4. Push if requested
1155
+ let pushed = false;
1156
+ if (push !== false) {
1157
+ const pushResult = await exec("git push", workDir, 30000);
1158
+ pushed = pushResult.exitCode === 0;
1159
+ }
1160
+
1161
+ invalidateBootCache();
1162
+ logCall("smart_commit", { command: `${commits.length} commits`, durationMs: Date.now() - start, aiProcessed: true });
1163
+
1164
+ return { content: [{ type: "text" as const, text: JSON.stringify({
1165
+ commits: results,
1166
+ pushed,
1167
+ total: results.length,
1168
+ ok: results.every(r => r.ok),
1169
+ }) }] };
1170
+ }
1171
+ );
1172
+
1052
1173
  return server;
1053
1174
  }
1054
1175