@hasna/terminal 3.7.3 → 3.8.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.
@@ -436,7 +436,13 @@ export function createServer() {
436
436
  // ── boot: session start context (replaces first 5 agent commands) ──────────
437
437
  server.tool("boot", "Get everything an agent needs on session start in ONE call — git state, project info, source structure. Replaces: git status + git log + cat package.json + ls src/. Cached for the session.", async () => {
438
438
  const ctx = await getBootContext(process.cwd());
439
- return { content: [{ type: "text", text: JSON.stringify(ctx) }] };
439
+ return { content: [{ type: "text", text: JSON.stringify({
440
+ ...ctx,
441
+ hints: {
442
+ cwd: process.cwd(),
443
+ tip: "All terminal tools support relative paths. Use 'src/foo.ts' not the full absolute path. Use commit({message, push:true}) instead of raw git commands. Use run({task:'test'}) instead of bun/npm test. Use lookup({file, items}) instead of grep pipelines.",
444
+ },
445
+ }) }] };
440
446
  });
441
447
  // ── project_overview: orient agent in one call ─────────────────────────────
442
448
  server.tool("project_overview", "Get project overview in one call — package.json info, source structure, config files. Replaces: cat package.json + ls src/ + cat tsconfig.json.", {
@@ -782,6 +788,74 @@ Match by function name, class name, method name (including ClassName.method), in
782
788
  return { content: [{ type: "text", text: JSON.stringify({ error: e.message }) }] };
783
789
  }
784
790
  });
791
+ 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'.", {
792
+ push: z.boolean().optional().describe("Push after all commits (default: true)"),
793
+ hint: z.string().optional().describe("Optional context about the changes (e.g., 'fixed auth + added users endpoint')"),
794
+ cwd: z.string().optional().describe("Working directory"),
795
+ }, async ({ push, hint, cwd }) => {
796
+ const start = Date.now();
797
+ const workDir = cwd ?? process.cwd();
798
+ // 1. Get all changed files
799
+ const status = await exec("git status --porcelain", workDir, 10000);
800
+ const diffStat = await exec("git diff --stat", workDir, 10000);
801
+ const untrackedDiff = await exec("git diff HEAD --stat", workDir, 10000);
802
+ const changedFiles = status.stdout.trim();
803
+ if (!changedFiles) {
804
+ return { content: [{ type: "text", text: JSON.stringify({ message: "Nothing to commit — working tree clean" }) }] };
805
+ }
806
+ // 2. AI groups changes into logical commits
807
+ const provider = getOutputProvider();
808
+ const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
809
+ const grouping = await provider.complete(`Changed files:\n${changedFiles}\n\nDiff stats:\n${diffStat.stdout}\n${untrackedDiff.stdout}${hint ? `\n\nContext: ${hint}` : ""}`, {
810
+ model: outputModel,
811
+ system: `You are a git commit assistant. Group these changed files into logical commits. Return ONLY a JSON array:
812
+
813
+ [{"message": "conventional commit message", "files": ["file1.ts", "file2.ts"]}]
814
+
815
+ Rules:
816
+ - Group related changes (same feature, same fix, same refactor)
817
+ - Use conventional commits: feat:, fix:, refactor:, test:, docs:, chore:
818
+ - Message should explain WHY, not WHAT (the diff shows what)
819
+ - Each file appears in exactly one group
820
+ - If all changes are related, use a single commit
821
+ - Extract file paths from the status output (skip the status prefix like M, A, ??)`,
822
+ maxTokens: 1000,
823
+ temperature: 0,
824
+ });
825
+ let commits = [];
826
+ try {
827
+ const jsonMatch = grouping.match(/\[[\s\S]*\]/);
828
+ if (jsonMatch)
829
+ commits = JSON.parse(jsonMatch[0]);
830
+ }
831
+ catch { }
832
+ if (commits.length === 0) {
833
+ // Fallback: single commit with all files
834
+ commits = [{ message: hint ?? "chore: update files", files: changedFiles.split("\n").map(l => l.slice(3).trim()) }];
835
+ }
836
+ // 3. Execute each commit
837
+ const results = [];
838
+ for (const c of commits) {
839
+ const fileArgs = c.files.map(f => `"${f}"`).join(" ");
840
+ const cmd = `git add ${fileArgs} && git commit -m ${JSON.stringify(c.message)}`;
841
+ const r = await exec(cmd, workDir, 15000);
842
+ results.push({ message: c.message, files: c.files.length, ok: r.exitCode === 0 });
843
+ }
844
+ // 4. Push if requested
845
+ let pushed = false;
846
+ if (push !== false) {
847
+ const pushResult = await exec("git push", workDir, 30000);
848
+ pushed = pushResult.exitCode === 0;
849
+ }
850
+ invalidateBootCache();
851
+ logCall("smart_commit", { command: `${commits.length} commits`, durationMs: Date.now() - start, aiProcessed: true });
852
+ return { content: [{ type: "text", text: JSON.stringify({
853
+ commits: results,
854
+ pushed,
855
+ total: results.length,
856
+ ok: results.every(r => r.ok),
857
+ }) }] };
858
+ });
785
859
  return server;
786
860
  }
787
861
  // ── 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.3",
3
+ "version": "3.8.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
@@ -615,7 +615,13 @@ export function createServer(): McpServer {
615
615
  "Get everything an agent needs on session start in ONE call — git state, project info, source structure. Replaces: git status + git log + cat package.json + ls src/. Cached for the session.",
616
616
  async () => {
617
617
  const ctx = await getBootContext(process.cwd());
618
- return { content: [{ type: "text" as const, text: JSON.stringify(ctx) }] };
618
+ return { content: [{ type: "text" as const, text: JSON.stringify({
619
+ ...ctx,
620
+ hints: {
621
+ cwd: process.cwd(),
622
+ tip: "All terminal tools support relative paths. Use 'src/foo.ts' not the full absolute path. Use commit({message, push:true}) instead of raw git commands. Use run({task:'test'}) instead of bun/npm test. Use lookup({file, items}) instead of grep pipelines.",
623
+ },
624
+ }) }] };
619
625
  }
620
626
  );
621
627
 
@@ -1043,6 +1049,91 @@ Match by function name, class name, method name (including ClassName.method), in
1043
1049
  }
1044
1050
  );
1045
1051
 
1052
+ server.tool(
1053
+ "smart_commit",
1054
+ "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'.",
1055
+ {
1056
+ push: z.boolean().optional().describe("Push after all commits (default: true)"),
1057
+ hint: z.string().optional().describe("Optional context about the changes (e.g., 'fixed auth + added users endpoint')"),
1058
+ cwd: z.string().optional().describe("Working directory"),
1059
+ },
1060
+ async ({ push, hint, cwd }) => {
1061
+ const start = Date.now();
1062
+ const workDir = cwd ?? process.cwd();
1063
+
1064
+ // 1. Get all changed files
1065
+ const status = await exec("git status --porcelain", workDir, 10000);
1066
+ const diffStat = await exec("git diff --stat", workDir, 10000);
1067
+ const untrackedDiff = await exec("git diff HEAD --stat", workDir, 10000);
1068
+
1069
+ const changedFiles = status.stdout.trim();
1070
+ if (!changedFiles) {
1071
+ return { content: [{ type: "text" as const, text: JSON.stringify({ message: "Nothing to commit — working tree clean" }) }] };
1072
+ }
1073
+
1074
+ // 2. AI groups changes into logical commits
1075
+ const provider = getOutputProvider();
1076
+ const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
1077
+
1078
+ const grouping = await provider.complete(
1079
+ `Changed files:\n${changedFiles}\n\nDiff stats:\n${diffStat.stdout}\n${untrackedDiff.stdout}${hint ? `\n\nContext: ${hint}` : ""}`,
1080
+ {
1081
+ model: outputModel,
1082
+ system: `You are a git commit assistant. Group these changed files into logical commits. Return ONLY a JSON array:
1083
+
1084
+ [{"message": "conventional commit message", "files": ["file1.ts", "file2.ts"]}]
1085
+
1086
+ Rules:
1087
+ - Group related changes (same feature, same fix, same refactor)
1088
+ - Use conventional commits: feat:, fix:, refactor:, test:, docs:, chore:
1089
+ - Message should explain WHY, not WHAT (the diff shows what)
1090
+ - Each file appears in exactly one group
1091
+ - If all changes are related, use a single commit
1092
+ - Extract file paths from the status output (skip the status prefix like M, A, ??)`,
1093
+ maxTokens: 1000,
1094
+ temperature: 0,
1095
+ }
1096
+ );
1097
+
1098
+ let commits: { message: string; files: string[] }[] = [];
1099
+ try {
1100
+ const jsonMatch = grouping.match(/\[[\s\S]*\]/);
1101
+ if (jsonMatch) commits = JSON.parse(jsonMatch[0]);
1102
+ } catch {}
1103
+
1104
+ if (commits.length === 0) {
1105
+ // Fallback: single commit with all files
1106
+ commits = [{ message: hint ?? "chore: update files", files: changedFiles.split("\n").map(l => l.slice(3).trim()) }];
1107
+ }
1108
+
1109
+ // 3. Execute each commit
1110
+ const results: { message: string; files: number; ok: boolean }[] = [];
1111
+ for (const c of commits) {
1112
+ const fileArgs = c.files.map(f => `"${f}"`).join(" ");
1113
+ const cmd = `git add ${fileArgs} && git commit -m ${JSON.stringify(c.message)}`;
1114
+ const r = await exec(cmd, workDir, 15000);
1115
+ results.push({ message: c.message, files: c.files.length, ok: r.exitCode === 0 });
1116
+ }
1117
+
1118
+ // 4. Push if requested
1119
+ let pushed = false;
1120
+ if (push !== false) {
1121
+ const pushResult = await exec("git push", workDir, 30000);
1122
+ pushed = pushResult.exitCode === 0;
1123
+ }
1124
+
1125
+ invalidateBootCache();
1126
+ logCall("smart_commit", { command: `${commits.length} commits`, durationMs: Date.now() - start, aiProcessed: true });
1127
+
1128
+ return { content: [{ type: "text" as const, text: JSON.stringify({
1129
+ commits: results,
1130
+ pushed,
1131
+ total: results.length,
1132
+ ok: results.every(r => r.ok),
1133
+ }) }] };
1134
+ }
1135
+ );
1136
+
1046
1137
  return server;
1047
1138
  }
1048
1139