commic 1.0.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 (50) hide show
  1. package/.husky/pre-commit +2 -0
  2. package/README.md +306 -0
  3. package/biome.json +50 -0
  4. package/dist/ai/AIService.d.ts +51 -0
  5. package/dist/ai/AIService.d.ts.map +1 -0
  6. package/dist/ai/AIService.js +351 -0
  7. package/dist/ai/AIService.js.map +1 -0
  8. package/dist/config/ConfigManager.d.ts +49 -0
  9. package/dist/config/ConfigManager.d.ts.map +1 -0
  10. package/dist/config/ConfigManager.js +124 -0
  11. package/dist/config/ConfigManager.js.map +1 -0
  12. package/dist/errors/CustomErrors.d.ts +54 -0
  13. package/dist/errors/CustomErrors.d.ts.map +1 -0
  14. package/dist/errors/CustomErrors.js +99 -0
  15. package/dist/errors/CustomErrors.js.map +1 -0
  16. package/dist/git/GitService.d.ts +77 -0
  17. package/dist/git/GitService.d.ts.map +1 -0
  18. package/dist/git/GitService.js +219 -0
  19. package/dist/git/GitService.js.map +1 -0
  20. package/dist/index.d.ts +3 -0
  21. package/dist/index.d.ts.map +1 -0
  22. package/dist/index.js +48 -0
  23. package/dist/index.js.map +1 -0
  24. package/dist/orchestrator/MainOrchestrator.d.ts +63 -0
  25. package/dist/orchestrator/MainOrchestrator.d.ts.map +1 -0
  26. package/dist/orchestrator/MainOrchestrator.js +225 -0
  27. package/dist/orchestrator/MainOrchestrator.js.map +1 -0
  28. package/dist/types/index.d.ts +55 -0
  29. package/dist/types/index.d.ts.map +1 -0
  30. package/dist/types/index.js +2 -0
  31. package/dist/types/index.js.map +1 -0
  32. package/dist/ui/UIManager.d.ts +118 -0
  33. package/dist/ui/UIManager.d.ts.map +1 -0
  34. package/dist/ui/UIManager.js +369 -0
  35. package/dist/ui/UIManager.js.map +1 -0
  36. package/dist/validation/ConventionalCommitsValidator.d.ts +33 -0
  37. package/dist/validation/ConventionalCommitsValidator.d.ts.map +1 -0
  38. package/dist/validation/ConventionalCommitsValidator.js +114 -0
  39. package/dist/validation/ConventionalCommitsValidator.js.map +1 -0
  40. package/package.json +49 -0
  41. package/src/ai/AIService.ts +413 -0
  42. package/src/config/ConfigManager.ts +141 -0
  43. package/src/errors/CustomErrors.ts +176 -0
  44. package/src/git/GitService.ts +246 -0
  45. package/src/index.ts +55 -0
  46. package/src/orchestrator/MainOrchestrator.ts +263 -0
  47. package/src/types/index.ts +60 -0
  48. package/src/ui/UIManager.ts +420 -0
  49. package/src/validation/ConventionalCommitsValidator.ts +139 -0
  50. package/tsconfig.json +24 -0
@@ -0,0 +1,420 @@
1
+ import boxen from 'boxen';
2
+ import chalk from 'chalk';
3
+ import inquirer from 'inquirer';
4
+ import ora, { type Ora } from 'ora';
5
+ import type { CommitSuggestion } from '../types/index.js';
6
+
7
+ /**
8
+ * Manages terminal UI rendering and visual feedback
9
+ * Provides consistent, colorful interface with emojis and loading indicators
10
+ */
11
+ export class UIManager {
12
+ private readonly colors = {
13
+ primary: chalk.cyan,
14
+ success: chalk.green,
15
+ error: chalk.red,
16
+ warning: chalk.yellow,
17
+ info: chalk.blue,
18
+ muted: chalk.gray,
19
+ };
20
+
21
+ /**
22
+ * Display welcome message with branding
23
+ */
24
+ showWelcome(): void {
25
+ const welcomeText =
26
+ chalk.bold.cyan('Commic') +
27
+ '\n' +
28
+ chalk.gray('AI-powered commit messages with Conventional Commits') +
29
+ '\n\n' +
30
+ this.colors.muted('✨ Features:') +
31
+ '\n' +
32
+ ' • ' +
33
+ this.colors.primary('Automated commit message generation') +
34
+ '\n' +
35
+ ' • ' +
36
+ this.colors.primary('Conventional Commits specification') +
37
+ '\n' +
38
+ ' • ' +
39
+ this.colors.primary('Multiple AI-generated suggestions') +
40
+ '\n' +
41
+ ' • ' +
42
+ this.colors.primary('Smart diff analysis') +
43
+ '\n\n' +
44
+ this.colors.muted('📋 How it works:') +
45
+ '\n' +
46
+ ' 1. Analyzes your Git changes' +
47
+ '\n' +
48
+ ' 2. Generates commit message suggestions' +
49
+ '\n' +
50
+ ' 3. Lets you choose the best one' +
51
+ '\n' +
52
+ ' 4. Creates the commit automatically';
53
+
54
+ console.log(
55
+ '\n' +
56
+ boxen(welcomeText, {
57
+ padding: 1,
58
+ margin: 1,
59
+ borderStyle: 'round',
60
+ borderColor: 'cyan',
61
+ }) +
62
+ '\n'
63
+ );
64
+ }
65
+
66
+ /**
67
+ * Display success message with checkmark emoji
68
+ * @param message Success message to display
69
+ */
70
+ showSuccess(message: string): void {
71
+ console.log(`\n${this.colors.success(`✨ ${message}`)}\n\n`);
72
+ }
73
+
74
+ /**
75
+ * Display error message with cross emoji
76
+ * @param message Error message to display
77
+ * @param details Optional detailed error information
78
+ */
79
+ showError(message: string, details?: string): void {
80
+ console.log(`\n${this.colors.error(`❌ Error: ${message}`)}`);
81
+ if (details) {
82
+ console.log(this.colors.muted(` ${details}`));
83
+ }
84
+ console.log('\n');
85
+ }
86
+
87
+ /**
88
+ * Display error with suggestion
89
+ * @param message Error message
90
+ * @param suggestion Actionable suggestion for user
91
+ */
92
+ showErrorWithSuggestion(message: string, suggestion: string): void {
93
+ console.log(`\n${this.colors.error(`❌ Error: ${message}`)}`);
94
+ console.log(`${this.colors.info(`💡 Suggestion: ${suggestion}`)}\n\n`);
95
+ }
96
+
97
+ /**
98
+ * Display informational message with bulb emoji
99
+ * @param message Info message to display
100
+ */
101
+ showInfo(message: string): void {
102
+ console.log(`\n${this.colors.info(`💡 ${message}`)}\n\n`);
103
+ }
104
+
105
+ /**
106
+ * Display warning message
107
+ * @param message Warning message to display
108
+ */
109
+ showWarning(message: string): void {
110
+ console.log(`\n${this.colors.warning(`⚠️ ${message}`)}\n\n`);
111
+ }
112
+
113
+ /**
114
+ * Apply muted (gray) color to text
115
+ * @param text Text to color
116
+ * @returns Colored text string
117
+ */
118
+ muted(text: string): string {
119
+ return this.colors.muted(text);
120
+ }
121
+
122
+ /**
123
+ * Create and start a loading spinner
124
+ * @param message Loading message to display
125
+ * @returns Ora spinner instance
126
+ */
127
+ showLoading(message: string): Ora {
128
+ return ora({
129
+ text: message,
130
+ color: 'cyan',
131
+ spinner: 'dots',
132
+ }).start();
133
+ }
134
+
135
+ /**
136
+ * Display a section header
137
+ * @param title Section title
138
+ */
139
+ showSectionHeader(title: string): void {
140
+ console.log(`\n${this.colors.primary.bold(title)}`);
141
+ console.log(this.colors.muted('─'.repeat(Math.max(title.length, 50))));
142
+ console.log();
143
+ }
144
+
145
+ /**
146
+ * Display formatted commit message preview
147
+ * @param message Commit message to preview
148
+ * @param index Optional index number
149
+ */
150
+ showCommitPreview(message: string, index?: number): void {
151
+ const prefix = index !== undefined ? `${index + 1}. ` : ' ';
152
+ const lines = message.split('\n');
153
+
154
+ console.log(this.colors.muted(prefix) + this.colors.primary(lines[0]));
155
+
156
+ if (lines.length > 1) {
157
+ lines.slice(1).forEach((line) => {
158
+ if (line.trim()) {
159
+ console.log(this.colors.muted(` ${line}`));
160
+ }
161
+ });
162
+ }
163
+ console.log();
164
+ }
165
+
166
+ /**
167
+ * Clear the console
168
+ */
169
+ clear(): void {
170
+ console.clear();
171
+ }
172
+
173
+ /**
174
+ * Display a blank line
175
+ */
176
+ newLine(): void {
177
+ console.log();
178
+ }
179
+
180
+ /**
181
+ * Display repository information banner
182
+ * @param repoName Repository name
183
+ * @param branch Current branch
184
+ * @param repoPath Repository path
185
+ * @param remoteUrl Optional remote repository URL
186
+ */
187
+ showRepositoryInfo(
188
+ repoName: string,
189
+ branch: string,
190
+ repoPath: string,
191
+ remoteUrl?: string | null
192
+ ): void {
193
+ console.log();
194
+ console.log(this.colors.primary.bold('📦 Repository Information'));
195
+ console.log(this.colors.muted('─'.repeat(50)));
196
+ console.log();
197
+ console.log(` ${this.colors.primary('📁 Repository: ')}${chalk.bold.white(repoName)}`);
198
+ console.log(` ${this.colors.primary('🌿 Branch: ')}${chalk.bold.green(branch)}`);
199
+ if (remoteUrl) {
200
+ console.log(` ${this.colors.primary('🔗 Remote: ')}${this.colors.muted(remoteUrl)}`);
201
+ }
202
+ console.log(` ${this.colors.muted('📍 Path: ')}${this.colors.muted(repoPath)}`);
203
+ console.log();
204
+ console.log(this.colors.muted('─'.repeat(50)));
205
+ console.log();
206
+ }
207
+
208
+ /**
209
+ * Display change statistics
210
+ * @param stats Statistics object with files, insertions, deletions
211
+ */
212
+ showChangeStats(stats: { filesChanged: number; insertions: number; deletions: number }): void {
213
+ const { filesChanged, insertions, deletions } = stats;
214
+
215
+ console.log();
216
+ console.log(this.colors.primary.bold('📊 Changes Summary'));
217
+ console.log(this.colors.muted('─'.repeat(50)));
218
+ console.log();
219
+
220
+ const fileText = filesChanged === 1 ? 'file' : 'files';
221
+ console.log(` ${chalk.bold.white(filesChanged)} ${fileText} changed`);
222
+
223
+ if (insertions > 0) {
224
+ console.log(` ${chalk.bold.green(`+${insertions}`)} insertions`);
225
+ }
226
+
227
+ if (deletions > 0) {
228
+ console.log(` ${chalk.bold.red(`-${deletions}`)} deletions`);
229
+ }
230
+
231
+ const totalChanges = insertions + deletions;
232
+ if (totalChanges > 0) {
233
+ console.log(` ${chalk.bold.cyan(`Total: ${totalChanges} lines`)} changed`);
234
+ }
235
+
236
+ console.log();
237
+ console.log(this.colors.muted('─'.repeat(50)));
238
+ console.log();
239
+ }
240
+
241
+ /**
242
+ * Display AI generation info
243
+ * @param model Model name being used
244
+ * @param suggestionCount Number of suggestions generated
245
+ */
246
+ showAIGenerationInfo(model: string, suggestionCount: number): void {
247
+ console.log(this.colors.muted(` 🤖 Model: ${chalk.cyan.bold(model)}`));
248
+ console.log(
249
+ this.colors.muted(
250
+ ` 📝 Generating ${chalk.cyan.bold(suggestionCount)} commit message suggestions...`
251
+ )
252
+ );
253
+ console.log();
254
+ }
255
+
256
+ /**
257
+ * Prompt user for API key
258
+ * @returns Entered API key
259
+ */
260
+ async promptForApiKey(): Promise<string> {
261
+ const answer = await inquirer.prompt([
262
+ {
263
+ type: 'password',
264
+ name: 'apiKey',
265
+ message: 'Enter your Gemini API key:',
266
+ mask: '*',
267
+ validate: (input: string) => {
268
+ if (!input || input.trim().length === 0) {
269
+ return 'API key cannot be empty';
270
+ }
271
+ if (input.length < 20) {
272
+ return 'API key seems too short. Please check and try again.';
273
+ }
274
+ return true;
275
+ },
276
+ },
277
+ ]);
278
+
279
+ return answer.apiKey.trim();
280
+ }
281
+
282
+ /**
283
+ * Prompt user to select a Gemini model
284
+ * @param models Available model options
285
+ * @returns Selected model
286
+ */
287
+ async promptForModel(models: string[]): Promise<string> {
288
+ const answer = await inquirer.prompt([
289
+ {
290
+ type: 'list',
291
+ name: 'model',
292
+ message: 'Select Gemini model:',
293
+ choices: models.map((model) => {
294
+ if (model === 'gemini-2.5-flash') {
295
+ return {
296
+ name: `${model} ${chalk.gray('(stable)')}`,
297
+ value: model,
298
+ };
299
+ } else if (model === 'gemini-flash-latest') {
300
+ return {
301
+ name: `${model} ${chalk.gray('(always latest, recommended)')}`,
302
+ value: model,
303
+ };
304
+ }
305
+ return {
306
+ name: model,
307
+ value: model,
308
+ };
309
+ }),
310
+ default: 'gemini-flash-latest',
311
+ },
312
+ ]);
313
+
314
+ return answer.model;
315
+ }
316
+
317
+ /**
318
+ * Prompt user to select a commit message from suggestions
319
+ * @param suggestions Array of commit message suggestions
320
+ * @returns Index of selected suggestion, or -1 if cancelled
321
+ */
322
+ async promptForCommitSelection(suggestions: CommitSuggestion[]): Promise<number> {
323
+ this.showSectionHeader('📝 Select a commit message');
324
+ this.newLine();
325
+
326
+ // Show all suggestions with more detail
327
+ console.log(this.colors.muted('Available suggestions:'));
328
+ console.log();
329
+ suggestions.forEach((suggestion, index) => {
330
+ const lines = suggestion.message.split('\n');
331
+ const firstLine = lines[0];
332
+ const hasBody = lines.length > 1;
333
+
334
+ console.log(
335
+ ` ${this.colors.primary.bold(`${index + 1}.`)} ${this.colors.primary(firstLine)}`
336
+ );
337
+ if (hasBody) {
338
+ lines.slice(1).forEach((line) => {
339
+ if (line.trim()) {
340
+ console.log(this.colors.muted(` ${line}`));
341
+ }
342
+ });
343
+ console.log();
344
+ } else {
345
+ console.log();
346
+ }
347
+ });
348
+ console.log();
349
+
350
+ const choices = suggestions.map((suggestion, index) => {
351
+ const lines = suggestion.message.split('\n');
352
+ const firstLine = lines[0];
353
+ const hasBody = lines.length > 1;
354
+
355
+ return {
356
+ name: hasBody ? `${firstLine} ${chalk.gray('(multi-line)')}` : firstLine,
357
+ value: index,
358
+ short: firstLine,
359
+ };
360
+ });
361
+
362
+ // Add cancel option
363
+ choices.push({
364
+ name: chalk.red("✖ Cancel (don't commit)"),
365
+ value: -1,
366
+ short: 'Cancelled',
367
+ });
368
+
369
+ const answer = await inquirer.prompt([
370
+ {
371
+ type: 'list',
372
+ name: 'selection',
373
+ message: 'Choose a commit message:',
374
+ choices,
375
+ pageSize: 10,
376
+ loop: false,
377
+ },
378
+ ]);
379
+
380
+ // Show preview of selected message if not cancelled
381
+ if (answer.selection !== -1) {
382
+ this.newLine();
383
+ this.showSectionHeader('✅ Selected commit message');
384
+ console.log();
385
+ console.log(this.colors.muted('─'.repeat(50)));
386
+ console.log();
387
+ const messageLines = suggestions[answer.selection].message.split('\n');
388
+ messageLines.forEach((line) => {
389
+ if (line.trim()) {
390
+ console.log(` ${this.colors.primary(line)}`);
391
+ } else {
392
+ console.log();
393
+ }
394
+ });
395
+ console.log();
396
+ console.log(this.colors.muted('─'.repeat(50)));
397
+ console.log();
398
+ }
399
+
400
+ return answer.selection;
401
+ }
402
+
403
+ /**
404
+ * Prompt for confirmation
405
+ * @param message Confirmation message
406
+ * @returns true if confirmed, false otherwise
407
+ */
408
+ async promptForConfirmation(message: string): Promise<boolean> {
409
+ const answer = await inquirer.prompt([
410
+ {
411
+ type: 'confirm',
412
+ name: 'confirmed',
413
+ message,
414
+ default: true,
415
+ },
416
+ ]);
417
+
418
+ return answer.confirmed;
419
+ }
420
+ }
@@ -0,0 +1,139 @@
1
+ import type { ParsedCommitMessage, ValidationResult } from '../types/index.js';
2
+
3
+ /**
4
+ * Validates commit messages against the Conventional Commits specification
5
+ * https://www.conventionalcommits.org/en/v1.0.0/
6
+ */
7
+ export class ConventionalCommitsValidator {
8
+ private static readonly VALID_TYPES = [
9
+ 'feat',
10
+ 'fix',
11
+ 'docs',
12
+ 'style',
13
+ 'refactor',
14
+ 'test',
15
+ 'chore',
16
+ 'perf',
17
+ 'ci',
18
+ 'build',
19
+ ];
20
+
21
+ // Regex pattern for Conventional Commits format
22
+ // Matches: type(scope)?: description or type(scope)?!: description
23
+ private static readonly COMMIT_PATTERN = /^(\w+)(\([a-z0-9-]+\))?(!)?:\s(.+)$/;
24
+
25
+ /**
26
+ * Get list of valid commit types
27
+ * @returns Array of valid type strings
28
+ */
29
+ static getValidTypes(): string[] {
30
+ return [...ConventionalCommitsValidator.VALID_TYPES];
31
+ }
32
+
33
+ /**
34
+ * Validate a commit message against Conventional Commits spec
35
+ * @param message Commit message to validate
36
+ * @returns ValidationResult with valid flag and any errors
37
+ */
38
+ static validate(message: string): ValidationResult {
39
+ const errors: string[] = [];
40
+
41
+ if (!message || message.trim().length === 0) {
42
+ errors.push('Commit message cannot be empty');
43
+ return { valid: false, errors };
44
+ }
45
+
46
+ // Split into lines to check subject and body separately
47
+ const lines = message.split('\n');
48
+ const subject = lines[0];
49
+
50
+ // Validate subject line format
51
+ const match = subject.match(ConventionalCommitsValidator.COMMIT_PATTERN);
52
+
53
+ if (!match) {
54
+ errors.push('Subject line must follow format: type(scope)?: description');
55
+ return { valid: false, errors };
56
+ }
57
+
58
+ const [, type, , breaking, description] = match;
59
+
60
+ // Validate type
61
+ if (!ConventionalCommitsValidator.VALID_TYPES.includes(type)) {
62
+ errors.push(
63
+ `Invalid type "${type}". Must be one of: ${ConventionalCommitsValidator.VALID_TYPES.join(', ')}`
64
+ );
65
+ }
66
+
67
+ // Validate description
68
+ if (!description || description.trim().length === 0) {
69
+ errors.push('Description cannot be empty');
70
+ }
71
+
72
+ // Check description starts with lowercase (Conventional Commits convention)
73
+ if (description && /^[A-Z]/.test(description)) {
74
+ errors.push('Description should start with lowercase letter');
75
+ }
76
+
77
+ // Validate subject line length (recommended max 72 characters)
78
+ if (subject.length > 72) {
79
+ errors.push('Subject line should be 72 characters or less');
80
+ }
81
+
82
+ // If multi-line, validate blank line between subject and body
83
+ if (lines.length > 1 && lines[1].trim() !== '') {
84
+ errors.push('There should be a blank line between subject and body');
85
+ }
86
+
87
+ // Check for breaking change notation
88
+ if (breaking === '!' || message.includes('BREAKING CHANGE:')) {
89
+ // This is valid - breaking changes are properly marked
90
+ // No error to add
91
+ }
92
+
93
+ return {
94
+ valid: errors.length === 0,
95
+ errors,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Parse a commit message into its components
101
+ * @param message Commit message to parse
102
+ * @returns Parsed commit message structure
103
+ */
104
+ static parseCommitMessage(message: string): ParsedCommitMessage | null {
105
+ const lines = message.split('\n');
106
+ const subject = lines[0];
107
+ const match = subject.match(ConventionalCommitsValidator.COMMIT_PATTERN);
108
+
109
+ if (!match) {
110
+ return null;
111
+ }
112
+
113
+ const [, type, scopeWithParens, breaking, description] = match;
114
+
115
+ // Extract scope without parentheses
116
+ const scope = scopeWithParens ? scopeWithParens.slice(1, -1) : undefined;
117
+
118
+ // Extract body (everything after the blank line)
119
+ const body = lines.length > 2 ? lines.slice(2).join('\n').trim() : undefined;
120
+
121
+ return {
122
+ type,
123
+ scope,
124
+ breaking: breaking === '!' || message.includes('BREAKING CHANGE:'),
125
+ description,
126
+ body,
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Check if a message is single-line (no body)
132
+ * @param message Commit message to check
133
+ * @returns true if single-line, false if multi-line
134
+ */
135
+ static isSingleLine(message: string): boolean {
136
+ const lines = message.split('\n').filter((line) => line.trim().length > 0);
137
+ return lines.length === 1;
138
+ }
139
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ES2022",
5
+ "lib": ["ES2022"],
6
+ "moduleResolution": "node",
7
+ "outDir": "./dist",
8
+ "rootDir": "./src",
9
+ "strict": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "forceConsistentCasingInFileNames": true,
13
+ "resolveJsonModule": true,
14
+ "declaration": true,
15
+ "declarationMap": true,
16
+ "sourceMap": true,
17
+ "noUnusedLocals": true,
18
+ "noUnusedParameters": true,
19
+ "noImplicitReturns": true,
20
+ "noFallthroughCasesInSwitch": true
21
+ },
22
+ "include": ["src/**/*"],
23
+ "exclude": ["node_modules", "dist"]
24
+ }