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.
- package/README.md +54 -18
- package/bin/neuro-commit.js +79 -125
- package/package.json +4 -2
- package/src/ai.js +234 -0
- package/src/aiCommit.js +311 -0
- package/src/commit.js +51 -166
- package/src/config.js +68 -0
- package/src/git.js +98 -18
- package/src/settings.js +119 -0
- package/src/ui.js +164 -0
package/src/aiCommit.js
ADDED
|
@@ -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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
|
|
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
|
-
*
|
|
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}
|
|
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
|
-
|
|
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
|
|
183
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
}
|
|
195
|
-
}
|
|
60
|
+
const filesInfo = buildFilesInfo(stagedFiles, numstat);
|
|
61
|
+
const systemPrompt = buildSystemPrompt(config, context);
|
|
62
|
+
const userPrompt = buildUserPrompt(filesInfo, diff);
|
|
196
63
|
|
|
197
|
-
|
|
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
|
-
|
|
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
|
+
};
|