git-mood 2.0.7 → 2.1.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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/index.js +585 -561
  3. package/package.json +45 -45
package/README.md CHANGED
@@ -10,7 +10,7 @@
10
10
  </p>
11
11
 
12
12
  <p align="center">
13
- <img src="https://img.shields.io/badge/version-2.0.7-blue?style=for-the-badge" alt="Version">
13
+ <img src="https://img.shields.io/badge/version-2.1.0-blue?style=for-the-badge" alt="Version">
14
14
  <img src="https://img.shields.io/badge/Node.js-18%2B-green?style=for-the-badge" alt="Node Version">
15
15
  <img src="https://img.shields.io/badge/License-ISC-orange?style=for-the-badge" alt="License">
16
16
  </p>
package/index.js CHANGED
@@ -1,562 +1,586 @@
1
- #!/usr/bin/env node
2
- import { program } from 'commander';
3
- import simpleGit from 'simple-git';
4
- import { GoogleGenerativeAI } from '@google/generative-ai';
5
- import chalk from 'chalk';
6
- import inquirer from 'inquirer';
7
- import Conf from 'conf';
8
- import fs from 'node:fs/promises';
9
- import path from 'node:path';
10
-
11
- const config = new Conf({ projectName: 'git-mood' });
12
- const git = simpleGit();
13
-
14
- const MODELS = [
15
- { id: 'gemini-2.5-flash-lite', name: 'Flash-Lite 2.5 (New & Lightest)' },
16
- { id: 'gemini-2.5-flash', name: 'Flash 2.5 (Fast & Balanced)' },
17
- { id: 'gemini-3-flash-preview', name: 'Flash 3 (Newest)' },
18
- ];
19
- const DEFAULT_MODEL = 'gemini-2.5-flash';
20
-
21
- function getModelId() {
22
- return config.get('model_id') ?? DEFAULT_MODEL;
23
- }
24
-
25
- function parseCommitSuggestion(text) {
26
- const trimmed = (text ?? '').trim();
27
- if (!trimmed) return { subject: '', body: '' };
28
-
29
- const firstBrace = trimmed.indexOf('{');
30
- const lastBrace = trimmed.lastIndexOf('}');
31
- if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
32
- const jsonSlice = trimmed.slice(firstBrace, lastBrace + 1);
33
- try {
34
- const parsed = JSON.parse(jsonSlice);
35
- return {
36
- subject: String(parsed.subject ?? '').trim(),
37
- body: String(parsed.body ?? '').trim(),
38
- };
39
- } catch {
40
- // fall through
41
- }
42
- }
43
-
44
- const lines = trimmed.split(/\r?\n/);
45
- const subject = (lines.shift() ?? '').trim();
46
- const body = lines.join('\n').trim();
47
- return { subject, body };
48
- }
49
-
50
- async function fileExists(filePath) {
51
- try {
52
- await fs.access(filePath);
53
- return true;
54
- } catch {
55
- return false;
56
- }
57
- }
58
-
59
- async function safeReadText(filePath, maxChars) {
60
- try {
61
- const content = await fs.readFile(filePath, 'utf8');
62
- if (typeof maxChars === 'number' && maxChars > 0 && content.length > maxChars) {
63
- return content.slice(0, maxChars);
64
- }
65
- return content;
66
- } catch {
67
- return '';
68
- }
69
- }
70
-
71
- async function buildFileTree(rootDir, options) {
72
- const ignore = new Set(options?.ignore ?? ['node_modules', '.git']);
73
- const maxDepth = options?.maxDepth ?? 6;
74
- const maxEntries = options?.maxEntries ?? 500;
75
-
76
- let entriesCount = 0;
77
- const lines = [];
78
-
79
- async function walk(currentDir, depth) {
80
- if (depth > maxDepth) return;
81
- if (entriesCount >= maxEntries) return;
82
-
83
- let dirents;
84
- try {
85
- dirents = await fs.readdir(currentDir, { withFileTypes: true });
86
- } catch {
87
- return;
88
- }
89
-
90
- dirents.sort((a, b) => a.name.localeCompare(b.name));
91
-
92
- for (const d of dirents) {
93
- if (entriesCount >= maxEntries) return;
94
- if (ignore.has(d.name)) continue;
95
-
96
- const full = path.join(currentDir, d.name);
97
- const rel = path.relative(rootDir, full).replace(/\\/g, '/');
98
-
99
- lines.push(rel + (d.isDirectory() ? '/' : ''));
100
- entriesCount += 1;
101
-
102
- if (d.isDirectory()) {
103
- await walk(full, depth + 1);
104
- }
105
- }
106
- }
107
-
108
- await walk(rootDir, 0);
109
- return lines.join('\n');
110
- }
111
-
112
- async function collectProjectContext(rootDir, scope) {
113
- const packageJsonPath = path.join(rootDir, 'package.json');
114
- const packageJson = await safeReadText(packageJsonPath, 12000);
115
- const cliCommands = `
116
- git-mood setup
117
- git-mood model
118
- git-mood commit
119
- git-mood review
120
- git-mood readme
121
- `.trim();
122
-
123
- if (scope === 'package') {
124
- return `PACKAGE.JSON:\n${packageJson}\n\nCLI COMMANDS:\n${cliCommands}`;
125
- }
126
-
127
- const tree = await buildFileTree(rootDir, { ignore: ['node_modules', '.git'], maxDepth: 6, maxEntries: 500 });
128
-
129
- if (scope === 'tree_key') {
130
- const indexPath = path.join(rootDir, 'index.js');
131
- const indexJs = await safeReadText(indexPath, 20000);
132
- return `PACKAGE.JSON:\n${packageJson}\n\nCLI COMMANDS:\n${cliCommands}\n\nFILE TREE:\n${tree}\n\nKEY SOURCE FILES:\n\n--- index.js ---\n${indexJs}`;
133
- }
134
-
135
- const readmePath = path.join(rootDir, 'README.md');
136
- const existingReadme = await safeReadText(readmePath, 20000);
137
- const packageLockPath = path.join(rootDir, 'package-lock.json');
138
- const packageLock = await safeReadText(packageLockPath, 12000);
139
- const indexPath = path.join(rootDir, 'index.js');
140
- const indexJs = await safeReadText(indexPath, 20000);
141
-
142
- return `PACKAGE.JSON:\n${packageJson}\n\nPACKAGE-LOCK.JSON (TRUNCATED):\n${packageLock}\n\nCLI COMMANDS:\n${cliCommands}\n\nFILE TREE:\n${tree}\n\nEXISTING README (IF ANY):\n${existingReadme}\n\nSOURCE FILES:\n\n--- index.js ---\n${indexJs}`;
143
- }
144
-
145
- async function generateReadme() {
146
- try {
147
- const rootDir = process.cwd();
148
- const readmePath = path.join(rootDir, 'README.md');
149
- const hasReadme = await fileExists(readmePath);
150
-
151
- const scopeAnswer = await inquirer.prompt([
152
- {
153
- type: 'select',
154
- name: 'scope',
155
- message: 'Choose context for README generation:',
156
- choices: [
157
- { name: 'Only package.json + CLI commands (fast)', value: 'package' },
158
- { name: 'File tree + key source files (recommended)', value: 'tree_key' },
159
- { name: 'Everything (can be slow / token-heavy)', value: 'all' },
160
- ],
161
- default: 0,
162
- },
163
- ]);
164
-
165
- if (hasReadme) {
166
- const overwriteAnswer = await inquirer.prompt([
167
- {
168
- type: 'confirm',
169
- name: 'overwrite',
170
- message: 'README.md already exists. Overwrite it?',
171
- default: false,
172
- },
173
- ]);
174
- if (!overwriteAnswer.overwrite) {
175
- console.log(chalk.yellow('❌ Cancelled.'));
176
- return;
177
- }
178
- }
179
-
180
- process.stdout.write(chalk.blue('🧠 Writing README...'));
181
-
182
- const model = getAI();
183
- const context = await collectProjectContext(rootDir, scopeAnswer.scope);
184
- const prompt = `
185
- You are an expert technical writer.
186
- Generate a high-quality README.md for this project in Markdown.
187
- Output Markdown ONLY.
188
-
189
- Include these sections (if applicable):
190
- - Title
191
- - Description
192
- - Features
193
- - Installation
194
- - Setup (including Gemini API key configuration)
195
- - Usage (show CLI commands and examples)
196
- - Configuration
197
- - Requirements
198
- - License
199
-
200
- Keep it concise and accurate. Do not invent features.
201
-
202
- PROJECT CONTEXT:
203
- ${context}
204
- `;
205
-
206
- const result = await model.generateContent(prompt);
207
- const markdown = (result?.response?.text?.() ?? '').trim();
208
-
209
- console.log("\r" + " ".repeat(50) + "\r");
210
-
211
- if (!markdown) {
212
- console.log(chalk.red('❌ Failed to generate README content.'));
213
- return;
214
- }
215
-
216
- await fs.writeFile(readmePath, markdown + '\n', 'utf8');
217
- console.log(chalk.green('✅ README.md generated!'));
218
- console.log(chalk.cyan('📄 Saved locally to: ') + chalk.white(readmePath));
219
- console.log(chalk.yellow('ℹ️ This only writes the file locally. It does NOT commit or push to GitHub yet.'));
220
-
221
- let isRepo = false;
222
- try {
223
- isRepo = await git.checkIsRepo();
224
- } catch {
225
- isRepo = false;
226
- }
227
-
228
- if (isRepo) {
229
- const stageAnswer = await inquirer.prompt([
230
- {
231
- type: 'confirm',
232
- name: 'stage',
233
- message: 'Stage README.md now (git add README.md)?',
234
- default: false,
235
- },
236
- ]);
237
-
238
- if (stageAnswer.stage) {
239
- await git.add(['README.md']);
240
- console.log(chalk.green('✅ Staged README.md'));
241
-
242
- const commitNowAnswer = await inquirer.prompt([
243
- {
244
- type: 'confirm',
245
- name: 'commitNow',
246
- message: 'Generate commit message and commit now?',
247
- default: false,
248
- },
249
- ]);
250
-
251
- if (commitNowAnswer.commitNow) {
252
- await generateCommit();
253
- } else {
254
- console.log(chalk.gray('Next: run `git-mood commit` when you are ready.'));
255
- }
256
- } else {
257
- console.log(chalk.gray('Next: `git add README.md` then `git-mood commit` to publish it.'));
258
- }
259
- }
260
- } catch (e) {
261
- console.error(chalk.red('Error:'), e.message);
262
- }
263
- }
264
-
265
- // --- HELPER: GET AI MODEL ---
266
- function getAI() {
267
- const apiKey = config.get('gemini_key');
268
- if (!apiKey) {
269
- console.log(chalk.red("❌ No API Key found! Run 'git-mood setup' first."));
270
- process.exit(1);
271
- }
272
- const genAI = new GoogleGenerativeAI(apiKey);
273
- const modelId = getModelId();
274
- return genAI.getGenerativeModel({ model: modelId });
275
- }
276
-
277
- // --- COMMAND 1: AUTO COMMIT & PUSH ---
278
- async function generateCommit(options = {}) {
279
- try {
280
- // 1. Check staged files
281
- const diff = await git.diff(['--staged']);
282
-
283
- if (!diff) {
284
- console.log(chalk.yellow("⚠️ No staged changes found. Did you run 'git add .'?"));
285
- return;
286
- }
287
-
288
- process.stdout.write(chalk.blue("🧠 Analyzing changes..."));
289
-
290
- const model = getAI();
291
- // Prompt asking for a conventional commit message
292
- const prompt = `
293
- You are an expert developer. Generate a git commit subject and an extended description for these changes.
294
- The subject MUST follow "Conventional Commits" format (e.g., 'feat: add login', 'fix: resolve crash').
295
- Keep the subject <= 72 characters and do not wrap it in quotes.
296
- The body should be 1-6 short lines explaining what changed and why (no code blocks).
297
- Return STRICT JSON only, with exactly these keys:
298
- {"subject":"...","body":"..."}
299
-
300
- THE DIFF:
301
- ${diff.substring(0, 5000)}
302
- `;
303
-
304
- const result = await model.generateContent(prompt);
305
- const suggestion = parseCommitSuggestion(result.response.text());
306
- const subject = suggestion.subject;
307
- const body = suggestion.body;
308
- console.log("\r" + " ".repeat(50) + "\r"); // Clear spinner
309
-
310
- console.log(chalk.bold.cyan('\n─ Suggested Commit ─\n'));
311
- console.log(chalk.green('Subject: ') + chalk.bold.white(subject));
312
- if (body) {
313
- console.log(chalk.green('Description:\n') + chalk.white(body));
314
- }
315
- console.log(chalk.gray('─'.repeat(50)));
316
-
317
- let finalSubject = subject;
318
- let finalBody = body;
319
-
320
- if (options.interactive) {
321
- const edited = await inquirer.prompt([
322
- {
323
- type: 'input',
324
- name: 'subject',
325
- message: 'Edit commit subject:',
326
- default: finalSubject,
327
- },
328
- {
329
- type: 'editor',
330
- name: 'body',
331
- message: 'Edit commit description (body):',
332
- default: finalBody,
333
- },
334
- ]);
335
- finalSubject = String(edited.subject ?? '').trim();
336
- finalBody = String(edited.body ?? '').trim();
337
- } else {
338
- const nextAction = await inquirer.prompt([
339
- {
340
- type: 'select',
341
- name: 'action',
342
- message: 'What do you want to do?',
343
- choices: [
344
- { name: 'Commit as-is', value: 'commit' },
345
- { name: 'Edit then commit', value: 'edit_commit' },
346
- { name: 'Cancel', value: 'cancel' },
347
- ],
348
- default: 0,
349
- },
350
- ]);
351
-
352
- if (nextAction.action === 'cancel') {
353
- console.log(chalk.yellow('❌ Cancelled.'));
354
- return;
355
- }
356
-
357
- if (nextAction.action === 'edit_commit') {
358
- const edited = await inquirer.prompt([
359
- {
360
- type: 'input',
361
- name: 'subject',
362
- message: 'Edit commit subject:',
363
- default: finalSubject,
364
- },
365
- {
366
- type: 'editor',
367
- name: 'body',
368
- message: 'Edit commit description (body):',
369
- default: finalBody,
370
- },
371
- ]);
372
- finalSubject = String(edited.subject ?? '').trim();
373
- finalBody = String(edited.body ?? '').trim();
374
- }
375
- }
376
-
377
- if (!finalSubject) {
378
- console.log(chalk.red('❌ Commit subject cannot be empty.'));
379
- return;
380
- }
381
-
382
- const fullMessage = finalBody ? `${finalSubject}\n\n${finalBody}` : finalSubject;
383
- await git.commit(fullMessage);
384
- console.log(chalk.green("✅ Committed locally!"));
385
-
386
- // 3. NEW STEP: Ask user to PUSH
387
- const pushAnswer = await inquirer.prompt([
388
- {
389
- type: 'confirm',
390
- name: 'shouldPush',
391
- message: '🚀 Do you want to push to GitHub now?',
392
- default: true
393
- },
394
- ]);
395
-
396
- if (pushAnswer.shouldPush) {
397
- process.stdout.write(chalk.yellow("🚀 Pushing code..."));
398
- try {
399
- await git.push();
400
- console.log("\r" + " ".repeat(50) + "\r");
401
- console.log(chalk.green.bold("🎉 Pushed to GitHub successfully!"));
402
- } catch (pushError) {
403
- // Check if the error is because we need to pull
404
- if (pushError.message.includes('fetch first') || pushError.message.includes('rejected')) {
405
- console.log(chalk.yellow("\n⚠️ GitHub is ahead of your computer."));
406
-
407
- const pullAnswer = await inquirer.prompt([
408
- {
409
- type: 'confirm',
410
- name: 'shouldPull',
411
- message: 'Do you want to PULL (download) changes and try pushing again?',
412
- default: true
413
- },
414
- ]);
415
-
416
- if (pullAnswer.shouldPull) {
417
- try {
418
- console.log(chalk.blue("⬇️ Pulling changes..."));
419
- await git.pull();
420
- console.log(chalk.blue("⬆️ Pushing again..."));
421
- await git.push();
422
- console.log(chalk.green.bold("🎉 Pushed to GitHub successfully!"));
423
- } catch (pullError) {
424
- console.error(chalk.red("\n❌ Auto-fix failed. You likely have merge conflicts. Fix them manually."));
425
- }
426
- }
427
- } else {
428
- console.error(chalk.red("\n❌ Push failed:"), pushError.message);
429
- }
430
- }
431
- }
432
-
433
- } catch (e) {
434
- console.error(chalk.red("Error:"), e.message);
435
- }
436
- }
437
-
438
- // --- COMMAND 2: CODE REVIEW ---
439
- async function codeReview() {
440
- try {
441
- // Look at unstaged AND staged changes
442
- const diff = await git.diff();
443
-
444
- if (!diff) {
445
- console.log(chalk.green("✨ No changes to review. Working directory clean."));
446
- return;
447
- }
448
-
449
- process.stdout.write(chalk.magenta("🕵️ Scanning code for bugs and smell..."));
450
-
451
- const model = getAI();
452
- const prompt = `
453
- Review this code diff like a Senior Engineer.
454
- 1. Identify potential bugs (logic errors, memory leaks).
455
- 2. Point out security risks (exposed keys, unsafe inputs).
456
- 3. Suggest 1 clean code improvement.
457
-
458
- Format output as a bulleted list. Be helpful but strict.
459
-
460
- THE DIFF:
461
- ${diff.substring(0, 8000)}
462
- `;
463
-
464
- const result = await model.generateContent(prompt);
465
- console.log("\r" + " ".repeat(50) + "\r");
466
-
467
- console.log(chalk.bold.magenta("\n🛡️ AI CODE REVIEW REPORT 🛡️"));
468
- console.log(result.response.text());
469
-
470
- } catch (e) {
471
- console.error(chalk.red("Error:"), e.message);
472
- }
473
- }
474
-
475
- // --- COMMAND 3: SETUP ---
476
- async function setupCLI() {
477
- const answers = await inquirer.prompt([
478
- {
479
- type: 'input',
480
- name: 'apiKey',
481
- message: 'Paste your Google Gemini API Key:',
482
- },
483
- {
484
- type: 'select',
485
- name: 'modelId',
486
- message: 'Choose Gemini model (↑/↓ arrows, Enter to select):',
487
- choices: MODELS.map((m) => ({ name: m.name, value: m.id })),
488
- default: Math.max(0, MODELS.findIndex((m) => m.id === getModelId())),
489
- },
490
- ]);
491
- config.set('gemini_key', answers.apiKey);
492
- config.set('model_id', answers.modelId);
493
- console.log(chalk.green("✅ API Key and model saved."));
494
- }
495
-
496
- // --- COMMAND 4: MODEL (change model) ---
497
- async function modelCLI() {
498
- const answer = await inquirer.prompt([
499
- {
500
- type: 'select',
501
- name: 'modelId',
502
- message: 'Choose Gemini model (↑/↓ arrows, Enter to select):',
503
- choices: MODELS.map((m) => ({ name: m.name, value: m.id })),
504
- default: Math.max(0, MODELS.findIndex((m) => m.id === getModelId())),
505
- },
506
- ]);
507
- config.set('model_id', answer.modelId);
508
- const label = MODELS.find((m) => m.id === answer.modelId)?.name ?? answer.modelId;
509
- console.log(chalk.green("✅ Model set to: " + label));
510
- }
511
-
512
- // --- CLI CONFIG ---
513
- program
514
- .name('git-mood')
515
- .description('AI-Powered Git Assistant — conventional commits & code review')
516
- .version('2.0.7');
517
-
518
- program.command('setup').description('Set Gemini API key and model').action(setupCLI);
519
- program.command('model').description('Change Gemini model').action(modelCLI);
520
-
521
- program
522
- .command('commit')
523
- .description('Generates a commit message from your staged changes and commits it')
524
- .option('-i, --interactive', 'Edit subject/body before committing')
525
- .action((options) => generateCommit(options));
526
-
527
- program
528
- .command('review')
529
- .description('Scans your current changes for bugs before you commit')
530
- .action(codeReview);
531
-
532
- program
533
- .command('readme')
534
- .description('Generates a README.md for your current project using AI')
535
- .action(generateReadme);
536
-
537
- function cleanupAndExit(code) {
538
- try {
539
- if (process.stdin.isTTY) {
540
- try {
541
- process.stdin.setRawMode(false);
542
- } catch {
543
- // ignore
544
- }
545
- }
546
- process.stdin.pause();
547
- if (typeof process.stdin.unref === 'function') {
548
- process.stdin.unref();
549
- }
550
- } catch {
551
- // ignore
552
- }
553
- process.exit(code);
554
- }
555
-
556
- await program
557
- .parseAsync(process.argv)
558
- .then(() => cleanupAndExit(0))
559
- .catch((err) => {
560
- console.error(chalk.red('Error:'), err?.message ?? String(err));
561
- cleanupAndExit(1);
1
+ #!/usr/bin/env node
2
+ import { program } from 'commander';
3
+ import simpleGit from 'simple-git';
4
+ import { GoogleGenerativeAI } from '@google/generative-ai';
5
+ import chalk from 'chalk';
6
+ import inquirerPkg from 'inquirer';
7
+ import Conf from 'conf';
8
+ import fs from 'node:fs/promises';
9
+ import path from 'node:path';
10
+
11
+ const config = new Conf({ projectName: 'git-mood' });
12
+ const git = simpleGit();
13
+
14
+ // Inquirer has changed module shapes across versions (ESM/CJS interop).
15
+ // Normalize to a single `prompt()` function to avoid runtime "prompt is not a function".
16
+ const inquirer = (inquirerPkg && typeof inquirerPkg === 'object' && 'default' in inquirerPkg)
17
+ ? inquirerPkg.default
18
+ : inquirerPkg;
19
+ const inqPrompt = (inquirer && typeof inquirer === 'object' && typeof inquirer.prompt === 'function')
20
+ ? inquirer.prompt.bind(inquirer)
21
+ : (typeof inquirer === 'function' ? inquirer : undefined);
22
+ if (typeof inqPrompt !== 'function') {
23
+ throw new Error("Inquirer failed to load: expected a 'prompt' function.");
24
+ }
25
+
26
+ const MODELS = [
27
+ // Gemini 3.5 Series (Latest Frontier Models)
28
+ { id: 'gemini-3.5-flash', name: 'Gemini 3.5 Flash (Agentic & High-Speed)' },
29
+
30
+ // Gemini 3.1 Series (Advanced Core Performance)
31
+ { id: 'gemini-3.1-pro-preview', name: 'Gemini 3.1 Pro (Flagship Reasoning - Paid)' },
32
+ { id: 'gemini-3.1-flash-lite', name: 'Gemini 3.1 Flash-Lite (High-Volume Automation)' },
33
+
34
+ // Gemini 3 Series (Stable Infrastructure)
35
+ { id: 'gemini-3-flash', name: 'Gemini 3 Flash (Fast Modulated Reasoning)' },
36
+ { id: 'gemini-3-pro', name: 'Gemini 3 Pro (Paid Complex Reasoning)' },
37
+
38
+ // Gemini 2.5 Series (Stable Long-Context)
39
+ { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro (Paid Deep Reasoning)' },
40
+ { id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash (Balanced Price-Performance)' },
41
+ { id: 'gemini-2.5-flash-lite', name: 'Gemini 2.5 Flash-Lite (Low Latency)' },
42
+ ];
43
+ const DEFAULT_MODEL = 'gemini-3.5-flash';
44
+
45
+ function getModelId() {
46
+ return config.get('model_id') ?? DEFAULT_MODEL;
47
+ }
48
+
49
+ function parseCommitSuggestion(text) {
50
+ const trimmed = (text ?? '').trim();
51
+ if (!trimmed) return { subject: '', body: '' };
52
+
53
+ const firstBrace = trimmed.indexOf('{');
54
+ const lastBrace = trimmed.lastIndexOf('}');
55
+ if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
56
+ const jsonSlice = trimmed.slice(firstBrace, lastBrace + 1);
57
+ try {
58
+ const parsed = JSON.parse(jsonSlice);
59
+ return {
60
+ subject: String(parsed.subject ?? '').trim(),
61
+ body: String(parsed.body ?? '').trim(),
62
+ };
63
+ } catch {
64
+
65
+ }
66
+ }
67
+
68
+ const lines = trimmed.split(/\r?\n/);
69
+ const subject = (lines.shift() ?? '').trim();
70
+ const body = lines.join('\n').trim();
71
+ return { subject, body };
72
+ }
73
+
74
+ async function fileExists(filePath) {
75
+ try {
76
+ await fs.access(filePath);
77
+ return true;
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+
83
+ async function safeReadText(filePath, maxChars) {
84
+ try {
85
+ const content = await fs.readFile(filePath, 'utf8');
86
+ if (typeof maxChars === 'number' && maxChars > 0 && content.length > maxChars) {
87
+ return content.slice(0, maxChars);
88
+ }
89
+ return content;
90
+ } catch {
91
+ return '';
92
+ }
93
+ }
94
+
95
+ async function buildFileTree(rootDir, options) {
96
+ const ignore = new Set(options?.ignore ?? ['node_modules', '.git']);
97
+ const maxDepth = options?.maxDepth ?? 6;
98
+ const maxEntries = options?.maxEntries ?? 500;
99
+
100
+ let entriesCount = 0;
101
+ const lines = [];
102
+
103
+ async function walk(currentDir, depth) {
104
+ if (depth > maxDepth) return;
105
+ if (entriesCount >= maxEntries) return;
106
+
107
+ let dirents;
108
+ try {
109
+ dirents = await fs.readdir(currentDir, { withFileTypes: true });
110
+ } catch {
111
+ return;
112
+ }
113
+
114
+ dirents.sort((a, b) => a.name.localeCompare(b.name));
115
+
116
+ for (const d of dirents) {
117
+ if (entriesCount >= maxEntries) return;
118
+ if (ignore.has(d.name)) continue;
119
+
120
+ const full = path.join(currentDir, d.name);
121
+ const rel = path.relative(rootDir, full).replace(/\\/g, '/');
122
+
123
+ lines.push(rel + (d.isDirectory() ? '/' : ''));
124
+ entriesCount += 1;
125
+
126
+ if (d.isDirectory()) {
127
+ await walk(full, depth + 1);
128
+ }
129
+ }
130
+ }
131
+
132
+ await walk(rootDir, 0);
133
+ return lines.join('\n');
134
+ }
135
+
136
+ async function collectProjectContext(rootDir, scope) {
137
+ const packageJsonPath = path.join(rootDir, 'package.json');
138
+ const packageJson = await safeReadText(packageJsonPath, 12000);
139
+ const cliCommands = `
140
+ git-mood setup
141
+ git-mood model
142
+ git-mood commit
143
+ git-mood review
144
+ git-mood readme
145
+ `.trim();
146
+
147
+ if (scope === 'package') {
148
+ return `PACKAGE.JSON:\n${packageJson}\n\nCLI COMMANDS:\n${cliCommands}`;
149
+ }
150
+
151
+ const tree = await buildFileTree(rootDir, { ignore: ['node_modules', '.git'], maxDepth: 6, maxEntries: 500 });
152
+
153
+ if (scope === 'tree_key') {
154
+ const indexPath = path.join(rootDir, 'index.js');
155
+ const indexJs = await safeReadText(indexPath, 20000);
156
+ return `PACKAGE.JSON:\n${packageJson}\n\nCLI COMMANDS:\n${cliCommands}\n\nFILE TREE:\n${tree}\n\nKEY SOURCE FILES:\n\n--- index.js ---\n${indexJs}`;
157
+ }
158
+
159
+ const readmePath = path.join(rootDir, 'README.md');
160
+ const existingReadme = await safeReadText(readmePath, 20000);
161
+ const packageLockPath = path.join(rootDir, 'package-lock.json');
162
+ const packageLock = await safeReadText(packageLockPath, 12000);
163
+ const indexPath = path.join(rootDir, 'index.js');
164
+ const indexJs = await safeReadText(indexPath, 20000);
165
+
166
+ return `PACKAGE.JSON:\n${packageJson}\n\nPACKAGE-LOCK.JSON (TRUNCATED):\n${packageLock}\n\nCLI COMMANDS:\n${cliCommands}\n\nFILE TREE:\n${tree}\n\nEXISTING README (IF ANY):\n${existingReadme}\n\nSOURCE FILES:\n\n--- index.js ---\n${indexJs}`;
167
+ }
168
+
169
+ async function generateReadme() {
170
+ try {
171
+ const rootDir = process.cwd();
172
+ const readmePath = path.join(rootDir, 'README.md');
173
+ const hasReadme = await fileExists(readmePath);
174
+
175
+ const scopeAnswer = await inqPrompt([
176
+ {
177
+ type: 'select',
178
+ name: 'scope',
179
+ message: 'Choose context for README generation:',
180
+ choices: [
181
+ { name: 'Only package.json + CLI commands (fast)', value: 'package' },
182
+ { name: 'File tree + key source files (recommended)', value: 'tree_key' },
183
+ { name: 'Everything (can be slow / token-heavy)', value: 'all' },
184
+ ],
185
+ default: 0,
186
+ },
187
+ ]);
188
+
189
+ if (hasReadme) {
190
+ const overwriteAnswer = await inqPrompt([
191
+ {
192
+ type: 'confirm',
193
+ name: 'overwrite',
194
+ message: 'README.md already exists. Overwrite it?',
195
+ default: false,
196
+ },
197
+ ]);
198
+ if (!overwriteAnswer.overwrite) {
199
+ console.log(chalk.yellow('❌ Cancelled.'));
200
+ return;
201
+ }
202
+ }
203
+
204
+ process.stdout.write(chalk.blue('🧠 Writing README...'));
205
+
206
+ const model = getAI();
207
+ const context = await collectProjectContext(rootDir, scopeAnswer.scope);
208
+ const prompt = `
209
+ You are an expert technical writer.
210
+ Generate a high-quality README.md for this project in Markdown.
211
+ Output Markdown ONLY.
212
+
213
+ Include these sections (if applicable):
214
+ - Title
215
+ - Description
216
+ - Features
217
+ - Installation
218
+ - Setup (including Gemini API key configuration)
219
+ - Usage (show CLI commands and examples)
220
+ - Configuration
221
+ - Requirements
222
+ - License
223
+
224
+ Keep it concise and accurate. Do not invent features.
225
+
226
+ PROJECT CONTEXT:
227
+ ${context}
228
+ `;
229
+
230
+ const result = await model.generateContent(prompt);
231
+ const markdown = (result?.response?.text?.() ?? '').trim();
232
+
233
+ console.log("\r" + " ".repeat(50) + "\r");
234
+
235
+ if (!markdown) {
236
+ console.log(chalk.red('❌ Failed to generate README content.'));
237
+ return;
238
+ }
239
+
240
+ await fs.writeFile(readmePath, markdown + '\n', 'utf8');
241
+ console.log(chalk.green('✅ README.md generated!'));
242
+ console.log(chalk.cyan('📄 Saved locally to: ') + chalk.white(readmePath));
243
+ console.log(chalk.yellow('ℹ️ This only writes the file locally. It does NOT commit or push to GitHub yet.'));
244
+
245
+ let isRepo = false;
246
+ try {
247
+ isRepo = await git.checkIsRepo();
248
+ } catch {
249
+ isRepo = false;
250
+ }
251
+
252
+ if (isRepo) {
253
+ const stageAnswer = await inqPrompt([
254
+ {
255
+ type: 'confirm',
256
+ name: 'stage',
257
+ message: 'Stage README.md now (git add README.md)?',
258
+ default: false,
259
+ },
260
+ ]);
261
+
262
+ if (stageAnswer.stage) {
263
+ await git.add(['README.md']);
264
+ console.log(chalk.green('✅ Staged README.md'));
265
+
266
+ const commitNowAnswer = await inqPrompt([
267
+ {
268
+ type: 'confirm',
269
+ name: 'commitNow',
270
+ message: 'Generate commit message and commit now?',
271
+ default: false,
272
+ },
273
+ ]);
274
+
275
+ if (commitNowAnswer.commitNow) {
276
+ await generateCommit();
277
+ } else {
278
+ console.log(chalk.gray('Next: run `git-mood commit` when you are ready.'));
279
+ }
280
+ } else {
281
+ console.log(chalk.gray('Next: `git add README.md` then `git-mood commit` to publish it.'));
282
+ }
283
+ }
284
+ } catch (e) {
285
+ console.error(chalk.red('Error:'), e.message);
286
+ }
287
+ }
288
+
289
+ // --- HELPER: GET AI MODEL ---
290
+ function getAI() {
291
+ const apiKey = config.get('gemini_key');
292
+ if (!apiKey) {
293
+ console.log(chalk.red("❌ No API Key found! Run 'git-mood setup' first."));
294
+ cleanupAndExit(1);
295
+ }
296
+ const genAI = new GoogleGenerativeAI(apiKey);
297
+ const modelId = getModelId();
298
+ return genAI.getGenerativeModel({ model: modelId });
299
+ }
300
+
301
+ // --- COMMAND 1: AUTO COMMIT & PUSH ---
302
+ async function generateCommit(options = {}) {
303
+ try {
304
+ // 1. Check staged files
305
+ const diff = await git.diff(['--staged']);
306
+
307
+ if (!diff) {
308
+ console.log(chalk.yellow("⚠️ No staged changes found. Did you run 'git add .'?"));
309
+ return;
310
+ }
311
+
312
+ process.stdout.write(chalk.blue("🧠 Analyzing changes..."));
313
+
314
+ const model = getAI();
315
+ // Prompt asking for a conventional commit message
316
+ const aiPrompt = `
317
+ You are an expert developer. Generate a git commit subject and an extended description for these changes.
318
+ The subject MUST follow "Conventional Commits" format (e.g., 'feat: add login', 'fix: resolve crash').
319
+ Keep the subject <= 72 characters and do not wrap it in quotes.
320
+ The body should be 1-6 short lines explaining what changed and why (no code blocks).
321
+ Return STRICT JSON only, with exactly these keys:
322
+ {"subject":"...","body":"..."}
323
+
324
+ THE DIFF:
325
+ ${diff.substring(0, 5000)}
326
+ `;
327
+
328
+ const result = await model.generateContent(aiPrompt);
329
+ const suggestion = parseCommitSuggestion(result.response.text());
330
+ const subject = suggestion.subject;
331
+ const body = suggestion.body;
332
+ console.log("\r" + " ".repeat(50) + "\r"); // Clear spinner
333
+
334
+ console.log(chalk.bold.cyan('\n─ Suggested Commit ─\n'));
335
+ console.log(chalk.green('Subject: ') + chalk.bold.white(subject));
336
+ if (body) {
337
+ console.log(chalk.green('Description:\n') + chalk.white(body));
338
+ }
339
+ console.log(chalk.gray('─'.repeat(50)));
340
+
341
+ let finalSubject = subject;
342
+ let finalBody = body;
343
+
344
+ if (options.interactive) {
345
+ const edited = await inqPrompt([
346
+ {
347
+ type: 'input',
348
+ name: 'subject',
349
+ message: 'Edit commit subject:',
350
+ default: finalSubject,
351
+ },
352
+ {
353
+ type: 'editor',
354
+ name: 'body',
355
+ message: 'Edit commit description (body):',
356
+ default: finalBody,
357
+ },
358
+ ]);
359
+ finalSubject = String(edited.subject ?? '').trim();
360
+ finalBody = String(edited.body ?? '').trim();
361
+ } else {
362
+ const nextAction = await inqPrompt([
363
+ {
364
+ type: 'select',
365
+ name: 'action',
366
+ message: 'What do you want to do?',
367
+ choices: [
368
+ { name: 'Commit as-is', value: 'commit' },
369
+ { name: 'Edit then commit', value: 'edit_commit' },
370
+ { name: 'Cancel', value: 'cancel' },
371
+ ],
372
+ default: 0,
373
+ },
374
+ ]);
375
+
376
+ if (nextAction.action === 'cancel') {
377
+ console.log(chalk.yellow('❌ Cancelled.'));
378
+ return;
379
+ }
380
+
381
+ if (nextAction.action === 'edit_commit') {
382
+ const edited = await inqPrompt([
383
+ {
384
+ type: 'input',
385
+ name: 'subject',
386
+ message: 'Edit commit subject:',
387
+ default: finalSubject,
388
+ },
389
+ {
390
+ type: 'editor',
391
+ name: 'body',
392
+ message: 'Edit commit description (body):',
393
+ default: finalBody,
394
+ },
395
+ ]);
396
+ finalSubject = String(edited.subject ?? '').trim();
397
+ finalBody = String(edited.body ?? '').trim();
398
+ }
399
+ }
400
+
401
+ if (!finalSubject) {
402
+ console.log(chalk.red('❌ Commit subject cannot be empty.'));
403
+ return;
404
+ }
405
+
406
+ const fullMessage = finalBody ? `${finalSubject}\n\n${finalBody}` : finalSubject;
407
+ await git.commit(fullMessage);
408
+ console.log(chalk.green("✅ Committed locally!"));
409
+
410
+ // 3. NEW STEP: Ask user to PUSH
411
+ const pushAnswer = await inqPrompt([
412
+ {
413
+ type: 'confirm',
414
+ name: 'shouldPush',
415
+ message: '🚀 Do you want to push to GitHub now?',
416
+ default: true
417
+ },
418
+ ]);
419
+
420
+ if (pushAnswer.shouldPush) {
421
+ process.stdout.write(chalk.yellow("🚀 Pushing code..."));
422
+ try {
423
+ await git.push();
424
+ console.log("\r" + " ".repeat(50) + "\r");
425
+ console.log(chalk.green.bold("🎉 Pushed to GitHub successfully!"));
426
+ } catch (pushError) {
427
+ // Check if the error is because we need to pull
428
+ if (pushError.message.includes('fetch first') || pushError.message.includes('rejected')) {
429
+ console.log(chalk.yellow("\n⚠️ GitHub is ahead of your computer."));
430
+
431
+ const pullAnswer = await inqPrompt([
432
+ {
433
+ type: 'confirm',
434
+ name: 'shouldPull',
435
+ message: 'Do you want to PULL (download) changes and try pushing again?',
436
+ default: true
437
+ },
438
+ ]);
439
+
440
+ if (pullAnswer.shouldPull) {
441
+ try {
442
+ console.log(chalk.blue("⬇️ Pulling changes..."));
443
+ await git.pull();
444
+ console.log(chalk.blue("⬆️ Pushing again..."));
445
+ await git.push();
446
+ console.log(chalk.green.bold("🎉 Pushed to GitHub successfully!"));
447
+ } catch (pullError) {
448
+ console.error(chalk.red("\n❌ Auto-fix failed. You likely have merge conflicts. Fix them manually."));
449
+ }
450
+ }
451
+ } else {
452
+ console.error(chalk.red("\n❌ Push failed:"), pushError.message);
453
+ }
454
+ }
455
+ }
456
+
457
+ } catch (e) {
458
+ console.error(chalk.red("Error:"), e.message);
459
+ }
460
+ }
461
+
462
+ // --- COMMAND 2: CODE REVIEW ---
463
+ async function codeReview() {
464
+ try {
465
+ // Look at unstaged AND staged changes
466
+ const diff = await git.diff();
467
+
468
+ if (!diff) {
469
+ console.log(chalk.green("✨ No changes to review. Working directory clean."));
470
+ return;
471
+ }
472
+
473
+ process.stdout.write(chalk.magenta("🕵️ Scanning code for bugs and smell..."));
474
+
475
+ const model = getAI();
476
+ const aiPrompt = `
477
+ Review this code diff like a Senior Engineer.
478
+ 1. Identify potential bugs (logic errors, memory leaks).
479
+ 2. Point out security risks (exposed keys, unsafe inputs).
480
+ 3. Suggest 1 clean code improvement.
481
+
482
+ Format output as a bulleted list. Be helpful but strict.
483
+
484
+ THE DIFF:
485
+ ${diff.substring(0, 8000)}
486
+ `;
487
+
488
+ const result = await model.generateContent(aiPrompt);
489
+ console.log("\r" + " ".repeat(50) + "\r");
490
+
491
+ console.log(chalk.bold.magenta("\n🛡️ AI CODE REVIEW REPORT 🛡️"));
492
+ console.log(result.response.text());
493
+
494
+ } catch (e) {
495
+ console.error(chalk.red("Error:"), e.message);
496
+ }
497
+ }
498
+
499
+ // --- COMMAND 3: SETUP ---
500
+ async function setupCLI() {
501
+ const answers = await inqPrompt([
502
+ {
503
+ type: 'input',
504
+ name: 'apiKey',
505
+ message: 'Paste your Google Gemini API Key:',
506
+ },
507
+ {
508
+ type: 'select',
509
+ name: 'modelId',
510
+ message: 'Choose Gemini model (↑/↓ arrows, Enter to select):',
511
+ choices: MODELS.map((m) => ({ name: m.name, value: m.id })),
512
+ default: Math.max(0, MODELS.findIndex((m) => m.id === getModelId())),
513
+ },
514
+ ]);
515
+ config.set('gemini_key', answers.apiKey);
516
+ config.set('model_id', answers.modelId);
517
+ console.log(chalk.green("✅ API Key and model saved."));
518
+ }
519
+
520
+ // --- COMMAND 4: MODEL (change model) ---
521
+ async function modelCLI() {
522
+ const answer = await inqPrompt([
523
+ {
524
+ type: 'select',
525
+ name: 'modelId',
526
+ message: 'Choose Gemini model (↑/↓ arrows, Enter to select):',
527
+ choices: MODELS.map((m) => ({ name: m.name, value: m.id })),
528
+ default: Math.max(0, MODELS.findIndex((m) => m.id === getModelId())),
529
+ },
530
+ ]);
531
+ config.set('model_id', answer.modelId);
532
+ const label = MODELS.find((m) => m.id === answer.modelId)?.name ?? answer.modelId;
533
+ console.log(chalk.green("✅ Model set to: " + label));
534
+ }
535
+
536
+ // --- CLI CONFIG ---
537
+ program
538
+ .name('git-mood')
539
+ .description('AI-Powered Git Assistant — conventional commits & code review')
540
+ .version('2.1.0');
541
+
542
+ program.command('setup').description('Set Gemini API key and model').action(setupCLI);
543
+ program.command('model').description('Change Gemini model').action(modelCLI);
544
+
545
+ program
546
+ .command('commit')
547
+ .description('Generates a commit message from your staged changes and commits it')
548
+ .option('-i, --interactive', 'Edit subject/body before committing')
549
+ .action((options) => generateCommit(options));
550
+
551
+ program
552
+ .command('review')
553
+ .description('Scans your current changes for bugs before you commit')
554
+ .action(codeReview);
555
+
556
+ program
557
+ .command('readme')
558
+ .description('Generates a README.md for your current project using AI')
559
+ .action(generateReadme);
560
+
561
+ function cleanupAndExit(code) {
562
+ try {
563
+ if (process.stdin.isTTY) {
564
+ try {
565
+ process.stdin.setRawMode(false);
566
+ } catch {
567
+ // ignore
568
+ }
569
+ }
570
+ } catch {
571
+ // ignore
572
+ }
573
+ // Delay the exit slightly to allow libuv handles/threads (e.g., keep-alive fetch connections)
574
+ // to clean up and close gracefully, preventing Windows assertion crashes.
575
+ setTimeout(() => {
576
+ process.exit(code);
577
+ }, 100);
578
+ }
579
+
580
+ await program
581
+ .parseAsync(process.argv)
582
+ .then(() => cleanupAndExit(0))
583
+ .catch((err) => {
584
+ console.error(chalk.red('Error:'), err?.message ?? String(err));
585
+ cleanupAndExit(1);
562
586
  });
package/package.json CHANGED
@@ -1,46 +1,46 @@
1
- {
2
- "name": "git-mood",
3
- "version": "2.0.7",
4
- "description": "AI-powered Git assistant — conventional commits & code review with Gemini",
5
- "type": "module",
6
- "main": "index.js",
7
- "bin": {
8
- "git-mood": "./index.js"
9
- },
10
- "scripts": {
11
- "test": "echo \"Error: no test specified\" && exit 1"
12
- },
13
- "keywords": [
14
- "cli",
15
- "git",
16
- "ai",
17
- "gemini",
18
- "commit",
19
- "code-review",
20
- "conventional-commits"
21
- ],
22
- "author": "Eyuel Engida",
23
- "license": "ISC",
24
- "repository": {
25
- "type": "git",
26
- "url": "https://github.com/EyuApp/git-mood"
27
- },
28
- "homepage": "https://github.com/EyuApp/git-mood#readme",
29
- "bugs": {
30
- "url": "https://github.com/EyuApp/git-mood/issues"
31
- },
32
- "publishConfig": {
33
- "access": "public"
34
- },
35
- "engines": {
36
- "node": ">=18"
37
- },
38
- "dependencies": {
39
- "@google/generative-ai": "^0.2.1",
40
- "chalk": "^5.3.0",
41
- "commander": "^12.0.0",
42
- "conf": "^12.0.0",
43
- "inquirer": "^13.0.0",
44
- "simple-git": "^3.22.0"
45
- }
1
+ {
2
+ "name": "git-mood",
3
+ "version": "2.1.0",
4
+ "description": "AI-powered Git assistant — conventional commits & code review with Gemini",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "bin": {
8
+ "git-mood": "./index.js"
9
+ },
10
+ "scripts": {
11
+ "test": "echo \"Error: no test specified\" && exit 1"
12
+ },
13
+ "keywords": [
14
+ "cli",
15
+ "git",
16
+ "ai",
17
+ "gemini",
18
+ "commit",
19
+ "code-review",
20
+ "conventional-commits"
21
+ ],
22
+ "author": "Eyuel Engida",
23
+ "license": "ISC",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/EyuApp/git-mood"
27
+ },
28
+ "homepage": "https://github.com/EyuApp/git-mood#readme",
29
+ "bugs": {
30
+ "url": "https://github.com/EyuApp/git-mood/issues"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "engines": {
36
+ "node": ">=18"
37
+ },
38
+ "dependencies": {
39
+ "@google/generative-ai": "^0.2.1",
40
+ "chalk": "^5.3.0",
41
+ "commander": "^12.0.0",
42
+ "conf": "^12.0.0",
43
+ "inquirer": "^13.2.2",
44
+ "simple-git": "^3.22.0"
45
+ }
46
46
  }