neuro-commit 0.1.2 → 0.2.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.
@@ -0,0 +1,311 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const {
4
+ getStagedFiles,
5
+ getStagedDiff,
6
+ getStagedNumstat,
7
+ isGitRepo,
8
+ isLockFile,
9
+ statusLabel,
10
+ getCurrentBranch,
11
+ getRecentCommits,
12
+ gitCommit,
13
+ gitPush,
14
+ } = require("./git");
15
+ const {
16
+ buildFilesInfo,
17
+ generateCommitMessage,
18
+ calculateCost,
19
+ estimateCost,
20
+ countTokens,
21
+ } = require("./ai");
22
+ const { loadConfig } = require("./config");
23
+ const {
24
+ RESET,
25
+ BOLD,
26
+ DIM,
27
+ GREEN,
28
+ YELLOW,
29
+ RED,
30
+ CYAN,
31
+ showSelectMenu,
32
+ confirm,
33
+ formatNumber,
34
+ createSpinner,
35
+ } = require("./ui");
36
+
37
+ /**
38
+ * Main AI Commit flow.
39
+ */
40
+ async function runAiCommitMode() {
41
+ if (!isGitRepo()) {
42
+ console.log(`\n${RED}✖${RESET} Not a git repository.\n`);
43
+ process.exit(1);
44
+ }
45
+
46
+ const stagedFiles = getStagedFiles();
47
+ if (stagedFiles.length === 0) {
48
+ console.log(
49
+ `\n${YELLOW}⚠${RESET} No staged changes. Run ${CYAN}git add${RESET} first.\n`,
50
+ );
51
+ process.exit(0);
52
+ }
53
+
54
+ const config = loadConfig();
55
+ const diff = getStagedDiff();
56
+ const numstat = getStagedNumstat();
57
+ const branch = getCurrentBranch();
58
+
59
+ // Show file summary
60
+ const maxLen = Math.max(...stagedFiles.map((f) => f.file.length), 0);
61
+ console.log(
62
+ `\n${BOLD}${stagedFiles.length} files${RESET} staged on ${CYAN}${branch}${RESET}\n`,
63
+ );
64
+
65
+ for (const { status, file } of stagedFiles) {
66
+ const pad = " ".repeat(Math.max(0, maxLen - file.length));
67
+ if (isLockFile(file)) {
68
+ console.log(
69
+ ` ${DIM}${statusLabel(status).padEnd(10)} ${file}${pad} (lock)${RESET}`,
70
+ );
71
+ } else {
72
+ const color = status === "A" ? GREEN : status === "D" ? RED : YELLOW;
73
+ const s = numstat.find((n) => n.file === file);
74
+ const stat = s ? ` ${DIM}+${s.added} -${s.deleted}${RESET}` : "";
75
+ console.log(
76
+ ` ${color}${statusLabel(status).padEnd(10)}${RESET} ${file}${pad}${stat}`,
77
+ );
78
+ }
79
+ }
80
+
81
+ const estimatedTokens = countTokens(diff);
82
+ const cost = estimateCost(estimatedTokens, 150);
83
+ console.log(
84
+ `\n${DIM}~${formatNumber(estimatedTokens)} tokens | est. $${cost.toFixed(4)}${RESET}\n`,
85
+ );
86
+
87
+ const proceed = await confirm("Generate commit message?", true);
88
+ if (!proceed) {
89
+ console.log(`${DIM}Cancelled.${RESET}\n`);
90
+ return;
91
+ }
92
+
93
+ // Build context
94
+ const context = { branch };
95
+ if (config.commitHistory > 0) {
96
+ context.recentCommits = getRecentCommits(config.commitHistory);
97
+ }
98
+
99
+ const filesInfo = buildFilesInfo(stagedFiles, numstat);
100
+
101
+ // Generate
102
+ let message;
103
+ try {
104
+ const result = await generate(diff, filesInfo, context, config);
105
+ message = result.message;
106
+ } catch (err) {
107
+ console.log(`\n${RED}✖${RESET} ${err.message}\n`);
108
+ if (
109
+ err.message.includes("API key") ||
110
+ err.message.includes("OPENAI") ||
111
+ err.message.includes("401")
112
+ ) {
113
+ console.log(
114
+ `${DIM}Linux/macOS: ${CYAN}export OPENAI_API_KEY="sk-..."${RESET}`,
115
+ );
116
+ console.log(
117
+ `${DIM}PowerShell: ${CYAN}$env:OPENAI_API_KEY = "sk-..."${RESET}`,
118
+ );
119
+ console.log(
120
+ `${DIM}CMD: ${CYAN}set OPENAI_API_KEY=sk-...${RESET}\n`,
121
+ );
122
+ }
123
+ return;
124
+ }
125
+
126
+ // Auto-commit if configured
127
+ if (config.autoCommit) {
128
+ const result = gitCommit(message);
129
+ if (result.success) {
130
+ console.log(
131
+ `\n${GREEN}✓${RESET} ${result.hash} ${message.split("\n")[0]}`,
132
+ );
133
+ if (config.autoPush) {
134
+ const pushResult = gitPush();
135
+ if (pushResult.success) {
136
+ console.log(`${GREEN}✓${RESET} Pushed`);
137
+ } else {
138
+ console.log(`${RED}✖${RESET} Push failed: ${pushResult.error}`);
139
+ }
140
+ }
141
+ console.log("");
142
+ } else {
143
+ console.log(`\n${RED}✖${RESET} ${result.error}\n`);
144
+ }
145
+ return;
146
+ }
147
+
148
+ // Action loop
149
+ while (true) {
150
+ console.log(`\n${message}\n`);
151
+
152
+ const action = await showSelectMenu("Action:", [
153
+ { label: "Commit" },
154
+ { label: "Edit" },
155
+ { label: "Regenerate" },
156
+ { label: "Cancel" },
157
+ ]);
158
+
159
+ switch (action) {
160
+ case 0: {
161
+ const result = gitCommit(message);
162
+ if (result.success) {
163
+ console.log(
164
+ `\n${GREEN}✓${RESET} ${result.hash} ${message.split("\n")[0]}`,
165
+ );
166
+ if (config.autoPush) {
167
+ const pushResult = gitPush();
168
+ if (pushResult.success) {
169
+ console.log(`${GREEN}✓${RESET} Pushed`);
170
+ } else {
171
+ console.log(`${RED}✖${RESET} Push failed: ${pushResult.error}`);
172
+ }
173
+ }
174
+ console.log("");
175
+ } else {
176
+ console.log(`\n${RED}✖${RESET} ${result.error}\n`);
177
+ }
178
+ return;
179
+ }
180
+
181
+ case 1: {
182
+ message = await editMessage(message);
183
+ break;
184
+ }
185
+
186
+ case 2: {
187
+ const style = await showSelectMenu("Style:", [
188
+ { label: "Different approach" },
189
+ { label: "More concise" },
190
+ { label: "More detailed" },
191
+ ]);
192
+
193
+ let extra;
194
+ if (style === 0) {
195
+ extra =
196
+ "\n\nGenerate a DIFFERENT style commit message. Try a different angle.";
197
+ } else if (style === 1) {
198
+ extra = "\n\nMake it MORE CONCISE. Fewer bullet points, shorter.";
199
+ } else if (style === 2) {
200
+ extra = "\n\nMake it MORE DETAILED. Include technical details.";
201
+ } else {
202
+ break;
203
+ }
204
+
205
+ try {
206
+ const result = await generate(
207
+ diff,
208
+ filesInfo,
209
+ context,
210
+ config,
211
+ extra,
212
+ );
213
+ message = result.message;
214
+ } catch (err) {
215
+ console.log(`\n${RED}✖${RESET} ${err.message}`);
216
+ }
217
+ break;
218
+ }
219
+
220
+ case 3:
221
+ case -1:
222
+ console.log(`${DIM}Cancelled.${RESET}\n`);
223
+ return;
224
+ }
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Call API with spinner, return { message, usage }.
230
+ */
231
+ async function generate(diff, filesInfo, context, config, extra = "") {
232
+ const spinner = createSpinner("Generating...");
233
+ spinner.start();
234
+ const startTime = Date.now();
235
+
236
+ let result;
237
+ try {
238
+ result = await generateCommitMessage(diff, filesInfo, context, extra);
239
+ } catch (err) {
240
+ spinner.stop(`${RED}✖${RESET} ${err.message}`);
241
+ throw err;
242
+ }
243
+
244
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
245
+ let statsLine = `${GREEN}✓${RESET} ${elapsed}s`;
246
+
247
+ if (result.usage) {
248
+ const stats = calculateCost(result.usage);
249
+ statsLine += ` ${DIM}| ${formatNumber(stats.inputTokens)}→${formatNumber(stats.outputTokens)} tokens | $${stats.cost.toFixed(6)}${RESET}`;
250
+ if (config.devMode) {
251
+ statsLine += ` ${DIM}[dev]${RESET}`;
252
+ }
253
+ }
254
+
255
+ spinner.stop(statsLine);
256
+ return result;
257
+ }
258
+
259
+ /**
260
+ * Open message in editor (VS Code by default).
261
+ */
262
+ async function editMessage(message) {
263
+ const os = require("os");
264
+ const tmpFile = path.join(os.tmpdir(), `neuro-commit-${Date.now()}.txt`);
265
+
266
+ fs.writeFileSync(
267
+ tmpFile,
268
+ `${message}\n\n# Edit the commit message above.\n# Lines starting with '#' are ignored.\n`,
269
+ "utf-8",
270
+ );
271
+
272
+ const editorCmd = process.env.VISUAL || process.env.EDITOR || "code --wait";
273
+
274
+ try {
275
+ const { execSync } = require("child_process");
276
+ // Use quotes for the path to handle spaces safely
277
+ const quotedFile =
278
+ process.platform === "win32" ? `"${tmpFile}"` : `'${tmpFile}'`;
279
+ execSync(`${editorCmd} ${quotedFile}`, { stdio: "inherit" });
280
+ } catch {
281
+ console.log(`${YELLOW}⚠${RESET} Editor failed. Message unchanged.`);
282
+ try {
283
+ fs.unlinkSync(tmpFile);
284
+ } catch {
285
+ // ignore
286
+ }
287
+ return message;
288
+ }
289
+
290
+ try {
291
+ const edited = fs.readFileSync(tmpFile, "utf-8");
292
+ const cleaned = edited
293
+ .split("\n")
294
+ .filter((l) => !l.startsWith("#"))
295
+ .join("\n")
296
+ .trim();
297
+
298
+ fs.unlinkSync(tmpFile);
299
+
300
+ if (cleaned) {
301
+ console.log(`${GREEN}✓${RESET} Message updated`);
302
+ return cleaned;
303
+ }
304
+ console.log(`${YELLOW}⚠${RESET} Empty message, keeping original.`);
305
+ return message;
306
+ } catch {
307
+ return message;
308
+ }
309
+ }
310
+
311
+ module.exports = { runAiCommitMode };
package/src/commit.js CHANGED
@@ -1,6 +1,5 @@
1
1
  const fs = require("fs");
2
2
  const path = require("path");
3
- const { get_encoding } = require("tiktoken");
4
3
  const {
5
4
  getStagedFiles,
6
5
  getStagedDiff,
@@ -8,198 +7,84 @@ const {
8
7
  isGitRepo,
9
8
  isLockFile,
10
9
  statusLabel,
10
+ getCurrentBranch,
11
+ getRecentCommits,
11
12
  } = require("./git");
12
-
13
- // --- ANSI helpers ---
14
- const RESET = "\x1b[0m";
15
- const BOLD = "\x1b[1m";
16
- const DIM = "\x1b[2m";
17
- const GREEN = "\x1b[32m";
18
- const YELLOW = "\x1b[33m";
19
- const RED = "\x1b[31m";
20
- const CYAN = "\x1b[36m";
13
+ const {
14
+ buildFilesInfo,
15
+ buildSystemPrompt,
16
+ buildUserPrompt,
17
+ countTokens,
18
+ } = require("./ai");
19
+ const { loadConfig } = require("./config");
20
+ const {
21
+ RESET,
22
+ BOLD,
23
+ DIM,
24
+ GREEN,
25
+ YELLOW,
26
+ RED,
27
+ CYAN,
28
+ formatNumber,
29
+ } = require("./ui");
21
30
 
22
31
  const OUTPUT_FILE = "neuro-commit.md";
23
- const encoder = get_encoding("o200k_base");
24
-
25
- // AI Prompt that will be added at the beginning of the generated file
26
- const AI_PROMPT = `You are an expert in writing commit messages for repositories. Your task is to write me a commit message based on my diff file, which I will provide to you.
27
-
28
- ### Rules for writing a commit message:
29
-
30
- 1. Write in English, strictly considering the context.
31
- 2. Return the answer using markdown formatting.
32
- 3. Use the imperative mood, as if you are giving a command to the system that corresponds to messages that create changes in the code.
33
- 4. The first line of the message (title) should be short, usually no longer than 50 characters. This makes it easier to quickly understand the changes. Do not end the title with a period.
34
- 5. Leave one empty line after the title before starting the body of the message. This separation helps Git tools to correctly display the message text.
35
- 6. Commits with messages like "Fix" or "Update" do not provide useful information. Always explain what exactly was fixed or updated.
36
- 7. **Use lowercase letters to describe change types. Use** semantic tags in message titles:
37
- - \`feat:\` — adding a new feature
38
- - \`fix:\` — bug fixes
39
- - \`docs:\` — changes in documentation
40
- - \`style:\` — changes that do not affect the code (e.g., formatting fixes)
41
- - \`refactor:\` — code change that doesn't add new functionality or fix bugs
42
- - \`test:\` — adding or changing tests
43
-
44
- ### Example of a correct commit message:
45
-
46
- \`\`\`diff
47
- refactor: update environment configuration and API connection
48
-
49
- - Edited \`.env\` file to support different environments (production, development) and API connection modes (docker, local, remote).
50
- - Updated \`config.py\` to load tokens and URLs depending on the environment and API mode.
51
- - Removed logic for determining the operating system.
52
- - Updated \`api_client.py\` to use BASE_API_URL instead of OS-specific URLs.
53
- - Reduced the number of retries in \`_make_request\`.
54
- \`\`\`
55
-
56
- ---
57
-
58
- `;
59
32
 
60
33
  /**
61
- * Build the markdown content for neuro-commit.md
62
- */
63
- function buildMarkdown(stagedFiles, diff, numstat) {
64
- const lines = [];
65
-
66
- // Start with the AI prompt
67
- lines.push(AI_PROMPT);
68
-
69
- const lockFiles = stagedFiles.filter((f) => isLockFile(f.file));
70
- const regularFiles = stagedFiles.filter((f) => !isLockFile(f.file));
71
-
72
- // Build a lookup: filename -> { added, deleted }
73
- const statMap = new Map();
74
- for (const entry of numstat) {
75
- statMap.set(entry.file, entry);
76
- }
77
-
78
- // --- Header ---
79
- lines.push("# Staged Changes Summary");
80
- lines.push("");
81
-
82
- // --- Combined file list with stats ---
83
- lines.push("## Changed Files");
84
- lines.push("");
85
- lines.push("```");
86
-
87
- // Compute column widths for alignment
88
- const allFiles = [...regularFiles, ...lockFiles];
89
- const maxLen = Math.max(...allFiles.map((f) => f.file.length), 0);
90
-
91
- for (const { status, file } of regularFiles) {
92
- const s = statMap.get(file);
93
- const stat = s ? ` | +${s.added} -${s.deleted}` : "";
94
- const pad = " ".repeat(Math.max(0, maxLen - file.length));
95
- lines.push(`${status} ${file}${pad}${stat}`);
96
- }
97
-
98
- for (const { status, file } of lockFiles) {
99
- const pad = " ".repeat(Math.max(0, maxLen - file.length));
100
- lines.push(`${status} ${file}${pad} | lock file (diff omitted)`);
101
- }
102
-
103
- lines.push("```");
104
-
105
- // Summary line
106
- const totalAdded = numstat.reduce((sum, e) => sum + e.added, 0);
107
- const totalDeleted = numstat.reduce((sum, e) => sum + e.deleted, 0);
108
- lines.push("");
109
- lines.push(
110
- `> **${allFiles.length}** files changed, **${totalAdded}** insertions(+), **${totalDeleted}** deletions(-)`,
111
- );
112
- lines.push("");
113
-
114
- // --- Diff ---
115
- if (diff) {
116
- lines.push("## Diff");
117
- lines.push("");
118
- lines.push("```diff");
119
- lines.push(diff);
120
- lines.push("```");
121
- lines.push("");
122
- }
123
-
124
- return lines.join("\n");
125
- }
126
-
127
- function estimateTokens(text) {
128
- return encoder.encode(text).length;
129
- }
130
-
131
- function formatNumber(num) {
132
- return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " ");
133
- }
134
-
135
- function printSummary(files, content, outputFile) {
136
- const totalFiles = files.length;
137
- const totalChars = content.length;
138
- const totalTokens = estimateTokens(content);
139
-
140
- console.log("");
141
- console.log(
142
- `${CYAN}✓${RESET} Generated ${BOLD}${outputFile}${RESET} ${DIM}(${formatNumber(totalFiles)} files, ${formatNumber(totalTokens)} tokens, ${formatNumber(totalChars)} chars)${RESET}`,
143
- );
144
- console.log("");
145
- console.log(`${BOLD}Next steps:${RESET}`);
146
- console.log(` 1. Open ${CYAN}${outputFile}${RESET}`);
147
- console.log(
148
- ` 2. Copy the ${BOLD}entire content${RESET} (AI prompt is already included!)`,
149
- );
150
- console.log(` 3. Paste into your LLM (ChatGPT, Claude, etc.)`);
151
- console.log(` 4. Get your perfect commit message! 🎉`);
152
- console.log("");
153
- }
154
-
155
- /**
156
- * Run the commit mode — gather staged info and write neuro-commit.md
34
+ * Manual mode — builds the same prompt as AI mode and saves to .md file.
157
35
  */
158
36
  function runCommitMode() {
159
- // Pre-flight checks
160
37
  if (!isGitRepo()) {
161
- console.log(
162
- `\n${RED}✖ Error:${RESET} not a git repository. Run this inside a git project.\n`,
163
- );
38
+ console.log(`\n${RED}✖${RESET} Not a git repository.\n`);
164
39
  process.exit(1);
165
40
  }
166
41
 
167
42
  const stagedFiles = getStagedFiles();
168
-
169
43
  if (stagedFiles.length === 0) {
170
44
  console.log(
171
- `\n${YELLOW} No staged changes found.${RESET} Stage files with ${CYAN}git add${RESET} first.\n`,
45
+ `\n${YELLOW}⚠${RESET} No staged changes. Run ${CYAN}git add${RESET} first.\n`,
172
46
  );
173
47
  process.exit(0);
174
48
  }
175
49
 
176
- console.log(`\n${BOLD}Commit Mode${RESET}`);
177
- console.log(`${DIM}Analyzing staged changes...${RESET}\n`);
178
-
50
+ const config = loadConfig();
179
51
  const diff = getStagedDiff();
180
52
  const numstat = getStagedNumstat();
53
+ const branch = getCurrentBranch();
181
54
 
182
- const lockFiles = stagedFiles.filter((f) => isLockFile(f.file));
183
- const regularFiles = stagedFiles.filter((f) => !isLockFile(f.file));
184
-
185
- // Print file list
186
- for (const { status, file } of regularFiles) {
187
- const color = status === "A" ? GREEN : status === "D" ? RED : YELLOW;
188
- console.log(` ${color}${statusLabel(status)}${RESET} ${file}`);
55
+ const context = { branch };
56
+ if (config.commitHistory > 0) {
57
+ context.recentCommits = getRecentCommits(config.commitHistory);
189
58
  }
190
59
 
191
- if (lockFiles.length > 0) {
192
- for (const { file } of lockFiles) {
193
- console.log(` ${DIM}${statusLabel("M")} ${file} (lock file)${RESET}`);
194
- }
195
- }
60
+ const filesInfo = buildFilesInfo(stagedFiles, numstat);
61
+ const systemPrompt = buildSystemPrompt(config, context);
62
+ const userPrompt = buildUserPrompt(filesInfo, diff);
196
63
 
197
- // Generate markdown
198
- const md = buildMarkdown(stagedFiles, diff, numstat);
64
+ const md = `${systemPrompt}\n\n---\n\n${userPrompt}`;
199
65
  const outPath = path.resolve(process.cwd(), OUTPUT_FILE);
200
66
  fs.writeFileSync(outPath, md, "utf-8");
201
67
 
202
- printSummary(stagedFiles, md, OUTPUT_FILE);
68
+ // Print file list
69
+ console.log("");
70
+ for (const { status, file } of stagedFiles) {
71
+ if (isLockFile(file)) {
72
+ console.log(
73
+ ` ${DIM}${statusLabel(status).padEnd(10)} ${file} (lock)${RESET}`,
74
+ );
75
+ } else {
76
+ const color = status === "A" ? GREEN : status === "D" ? RED : YELLOW;
77
+ console.log(
78
+ ` ${color}${statusLabel(status).padEnd(10)}${RESET} ${file}`,
79
+ );
80
+ }
81
+ }
82
+
83
+ const tokens = countTokens(md);
84
+ console.log(
85
+ `\n${GREEN}✓${RESET} Saved to ${BOLD}${OUTPUT_FILE}${RESET} (~${formatNumber(tokens)} tokens)`,
86
+ );
87
+ console.log(`${DIM}Paste into ChatGPT, Claude, etc.${RESET}\n`);
203
88
  }
204
89
 
205
90
  module.exports = { runCommitMode };
package/src/config.js ADDED
@@ -0,0 +1,68 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const os = require("os");
4
+
5
+ const CONFIG_DIR = path.join(os.homedir(), ".neurocommit");
6
+ const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
7
+
8
+ const DEFAULT_CONFIG = {
9
+ model: "gpt-5-nano",
10
+ language: "en",
11
+ maxLength: 72,
12
+ autoCommit: false,
13
+ autoPush: false,
14
+ commitHistory: 5,
15
+ devMode: false,
16
+ };
17
+
18
+ function ensureConfigDir() {
19
+ if (!fs.existsSync(CONFIG_DIR)) {
20
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
21
+ }
22
+ }
23
+
24
+ function loadConfig() {
25
+ ensureConfigDir();
26
+ if (!fs.existsSync(CONFIG_FILE)) {
27
+ saveConfig(DEFAULT_CONFIG);
28
+ return { ...DEFAULT_CONFIG };
29
+ }
30
+ try {
31
+ const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
32
+ const saved = JSON.parse(raw);
33
+ return { ...DEFAULT_CONFIG, ...saved };
34
+ } catch {
35
+ return { ...DEFAULT_CONFIG };
36
+ }
37
+ }
38
+
39
+ function saveConfig(config) {
40
+ ensureConfigDir();
41
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
42
+ }
43
+
44
+ function updateConfig(key, value) {
45
+ const config = loadConfig();
46
+ config[key] = value;
47
+ saveConfig(config);
48
+ return config;
49
+ }
50
+
51
+ function getApiKey() {
52
+ return process.env.OPENAI_API_KEY || null;
53
+ }
54
+
55
+ function isAiAvailable() {
56
+ return !!getApiKey();
57
+ }
58
+
59
+ module.exports = {
60
+ CONFIG_DIR,
61
+ CONFIG_FILE,
62
+ DEFAULT_CONFIG,
63
+ loadConfig,
64
+ saveConfig,
65
+ updateConfig,
66
+ getApiKey,
67
+ isAiAvailable,
68
+ };