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,413 @@
1
+ import { type GenerativeModel, GoogleGenerativeAI } from '@google/generative-ai';
2
+ import { APIError, ValidationError } from '../errors/CustomErrors.js';
3
+ import type { CommitSuggestion, GitDiff } from '../types/index.js';
4
+ import { ConventionalCommitsValidator } from '../validation/ConventionalCommitsValidator.js';
5
+
6
+ /**
7
+ * Handles AI-powered commit message generation using Google's Gemini API
8
+ */
9
+ export class AIService {
10
+ private readonly genAI: GoogleGenerativeAI;
11
+ private readonly model: GenerativeModel;
12
+
13
+ constructor(apiKey: string, modelName: string) {
14
+ this.genAI = new GoogleGenerativeAI(apiKey);
15
+ this.model = this.genAI.getGenerativeModel({ model: modelName });
16
+ }
17
+
18
+ /**
19
+ * Build prompt for Gemini API to generate commit messages
20
+ * @param diff Git diff information
21
+ * @param count Number of suggestions to generate (3-5)
22
+ * @returns Formatted prompt string
23
+ */
24
+ private buildPrompt(diff: GitDiff, count: number): string {
25
+ // Combine staged and unstaged diffs
26
+ const combinedDiff = [diff.staged, diff.unstaged].filter((d) => d.length > 0).join('\n\n');
27
+
28
+ // Truncate diff if too large (Gemini input token limit: 1,048,576 tokens)
29
+ // Using ~800K characters (approx 200K-266K tokens) leaves plenty of room for prompt
30
+ // 1 token ≈ 3-4 characters on average, so 800K chars ≈ 200K-266K tokens
31
+ const maxDiffLength = 800000;
32
+ const truncatedDiff =
33
+ combinedDiff.length > maxDiffLength
34
+ ? combinedDiff.substring(0, maxDiffLength) +
35
+ '\n\n[... diff truncated due to size limit ...]'
36
+ : combinedDiff;
37
+
38
+ return `You are an expert Git commit message writer. Analyze the ENTIRE Git diff below and generate ${count} commit messages that summarize ALL changes together. Each commit message should cover the complete set of changes, not individual features.
39
+
40
+ IMPORTANT:
41
+ - Analyze ALL changes in the diff as a single commit
42
+ - Each suggested message should describe the complete set of changes
43
+ - Do NOT create separate messages for different parts of the diff
44
+ - Consider all file changes, additions, deletions, and modifications together
45
+ - Provide different perspectives/styles for the SAME set of changes
46
+
47
+ CRITICAL RULES:
48
+ 1. Format: type(scope)?: description
49
+ 2. Valid types: feat, fix, docs, style, refactor, test, chore, perf, ci, build
50
+ 3. Use imperative mood (add, fix, update - NOT added, fixed, updated)
51
+ 4. Description starts with lowercase
52
+ 5. Single-line messages: max 72 chars, no body
53
+ 6. Multi-line messages: blank line between subject and body
54
+ 7. At least 2 messages should be single-line
55
+ 8. Each message must summarize ALL changes in the diff
56
+
57
+ OUTPUT FORMAT:
58
+ Separate each commit message with exactly "---" on its own line.
59
+ Return ONLY the messages, no explanations or numbering.
60
+
61
+ EXAMPLES (each covers all changes):
62
+ feat(auth): add JWT validation and user login flow
63
+ ---
64
+ fix: resolve authentication issues and update error handling
65
+ ---
66
+ refactor(auth): improve JWT implementation and error messages
67
+
68
+ Update token validation logic and add comprehensive error handling
69
+ ---
70
+ feat: implement user authentication system
71
+
72
+ Add JWT token validation, login endpoints, and error handling
73
+
74
+ GIT DIFF:
75
+ ${truncatedDiff}
76
+
77
+ Generate ${count} commit messages that each describe ALL the changes above:`;
78
+ }
79
+
80
+ /**
81
+ * Parse API response into CommitSuggestion array
82
+ * Tries multiple parsing strategies for robustness
83
+ * @param response Raw response text from API
84
+ * @returns Array of commit suggestions
85
+ */
86
+ private parseResponse(response: string): CommitSuggestion[] {
87
+ if (!response || response.trim().length === 0) {
88
+ return [];
89
+ }
90
+
91
+ let messages: string[] = [];
92
+
93
+ // Strategy 1: Split by "---" on its own line
94
+ const tripleDashPattern = /\n---\n/g;
95
+ if (tripleDashPattern.test(response)) {
96
+ messages = response
97
+ .split(tripleDashPattern)
98
+ .map((msg) => msg.trim())
99
+ .filter((msg) => msg.length > 0);
100
+ }
101
+
102
+ // Strategy 2: Split by "---" anywhere
103
+ if (messages.length === 0 || messages.length === 1) {
104
+ messages = response
105
+ .split('---')
106
+ .map((msg) => msg.trim())
107
+ .filter((msg) => msg.length > 0);
108
+ }
109
+
110
+ // Strategy 3: Split by numbered items (1., 2., etc.)
111
+ if (messages.length === 0 || messages.length === 1) {
112
+ const numberedPattern = /^\d+\.\s+/gm;
113
+ if (numberedPattern.test(response)) {
114
+ messages = response
115
+ .split(numberedPattern)
116
+ .map((msg) => msg.trim())
117
+ .filter((msg) => msg.length > 0 && !/^\d+\./.test(msg));
118
+ }
119
+ }
120
+
121
+ // Strategy 4: Split by double newlines (common for multi-line messages)
122
+ if (messages.length === 0 || messages.length === 1) {
123
+ const doubleNewlinePattern = /\n\n+/;
124
+ if (doubleNewlinePattern.test(response)) {
125
+ const parts = response.split(doubleNewlinePattern);
126
+ // Filter out parts that look like commit messages (start with type:)
127
+ messages = parts
128
+ .map((msg) => msg.trim())
129
+ .filter((msg) => {
130
+ const trimmed = msg.trim();
131
+ return (
132
+ trimmed.length > 0 &&
133
+ /^(feat|fix|docs|style|refactor|test|chore|perf|ci|build)(\([^)]+\))?(!)?:\s/.test(
134
+ trimmed
135
+ )
136
+ );
137
+ });
138
+ }
139
+ }
140
+
141
+ // Strategy 5: Try to extract commit messages by pattern matching
142
+ if (messages.length === 0) {
143
+ const commitPattern =
144
+ /(feat|fix|docs|style|refactor|test|chore|perf|ci|build)(\([^)]+\))?(!)?:\s[^\n]+(?:\n(?!---|\d+\.)[^\n]+)*/g;
145
+ const matches = response.match(commitPattern);
146
+ if (matches) {
147
+ messages = matches.map((msg) => msg.trim());
148
+ }
149
+ }
150
+
151
+ // If still no messages, try to extract any line starting with a valid type
152
+ if (messages.length === 0) {
153
+ const lines = response.split('\n');
154
+ const validTypeLines: string[] = [];
155
+ let currentMessage = '';
156
+
157
+ for (const line of lines) {
158
+ const trimmed = line.trim();
159
+ if (
160
+ /^(feat|fix|docs|style|refactor|test|chore|perf|ci|build)(\([^)]+\))?(!)?:\s/.test(
161
+ trimmed
162
+ )
163
+ ) {
164
+ if (currentMessage) {
165
+ validTypeLines.push(currentMessage.trim());
166
+ }
167
+ currentMessage = trimmed;
168
+ } else if (currentMessage && trimmed.length > 0) {
169
+ currentMessage += `\n${trimmed}`;
170
+ } else if (currentMessage && trimmed.length === 0) {
171
+ // Blank line - continue building message
172
+ currentMessage += '\n';
173
+ }
174
+ }
175
+ if (currentMessage) {
176
+ validTypeLines.push(currentMessage.trim());
177
+ }
178
+ messages = validTypeLines;
179
+ }
180
+
181
+ // Clean up messages - remove any that are clearly not commit messages
182
+ messages = messages
183
+ .map((msg) => {
184
+ // Remove common prefixes AI might add
185
+ return msg
186
+ .replace(/^(Here are|Here's|Generated|Commit messages?):?\s*/i, '')
187
+ .replace(/^[-*•]\s*/, '')
188
+ .trim();
189
+ })
190
+ .filter((msg) => {
191
+ // Must start with a valid commit type
192
+ return (
193
+ msg.length > 0 &&
194
+ /^(feat|fix|docs|style|refactor|test|chore|perf|ci|build)(\([^)]+\))?(!)?:\s/.test(msg)
195
+ );
196
+ });
197
+
198
+ return messages.map((message) => ({
199
+ message: message.trim(),
200
+ type: ConventionalCommitsValidator.isSingleLine(message) ? 'single-line' : 'multi-line',
201
+ }));
202
+ }
203
+
204
+ /**
205
+ * Filter and validate suggestions, returning only valid ones
206
+ * @param suggestions Array of suggestions to validate
207
+ * @returns Array of valid suggestions
208
+ */
209
+ private filterValidSuggestions(suggestions: CommitSuggestion[]): CommitSuggestion[] {
210
+ const validSuggestions: CommitSuggestion[] = [];
211
+
212
+ for (const suggestion of suggestions) {
213
+ const validation = ConventionalCommitsValidator.validate(suggestion.message);
214
+ if (validation.valid) {
215
+ validSuggestions.push(suggestion);
216
+ }
217
+ }
218
+
219
+ return validSuggestions;
220
+ }
221
+
222
+ /**
223
+ * Check if suggestions meet minimum requirements
224
+ * @param suggestions Array of suggestions to check
225
+ * @returns true if meets minimum requirements
226
+ */
227
+ private meetsMinimumRequirements(suggestions: CommitSuggestion[]): boolean {
228
+ // Need at least 2 valid suggestions
229
+ if (suggestions.length < 2) {
230
+ return false;
231
+ }
232
+
233
+ // At least 1 should be single-line (relaxed from 2)
234
+ const singleLineCount = suggestions.filter((s) => s.type === 'single-line').length;
235
+ if (singleLineCount < 1) {
236
+ return false;
237
+ }
238
+
239
+ return true;
240
+ }
241
+
242
+ /**
243
+ * Generate commit message suggestions using Gemini API
244
+ * @param diff Git diff information
245
+ * @param count Number of suggestions to generate (3-5)
246
+ * @returns Array of validated commit suggestions
247
+ * @throws APIError if API request fails
248
+ * @throws ValidationError if no valid suggestions generated
249
+ */
250
+ async generateCommitMessages(diff: GitDiff, count: number = 4): Promise<CommitSuggestion[]> {
251
+ // Ensure count is within bounds
252
+ const requestCount = Math.max(3, Math.min(5, count));
253
+
254
+ let attempts = 0;
255
+ const maxAttempts = 3;
256
+ let bestSuggestions: CommitSuggestion[] = [];
257
+
258
+ while (attempts < maxAttempts) {
259
+ attempts++;
260
+
261
+ try {
262
+ // Build prompt
263
+ const prompt = this.buildPrompt(diff, requestCount);
264
+
265
+ // Call Gemini API with timeout
266
+ const timeoutPromise = new Promise<never>((_, reject) =>
267
+ setTimeout(() => reject(new Error('Request timeout')), 30000)
268
+ );
269
+
270
+ const result = await Promise.race([this.model.generateContent(prompt), timeoutPromise]);
271
+
272
+ const response = result.response;
273
+ const text = response.text();
274
+
275
+ if (!text || text.trim().length === 0) {
276
+ continue;
277
+ }
278
+
279
+ // Parse response
280
+ const parsedSuggestions = this.parseResponse(text);
281
+
282
+ if (parsedSuggestions.length === 0) {
283
+ continue;
284
+ }
285
+
286
+ // Filter valid suggestions
287
+ const validSuggestions = this.filterValidSuggestions(parsedSuggestions);
288
+
289
+ // If we have valid suggestions, check if they meet requirements
290
+ if (validSuggestions.length > 0) {
291
+ // If we meet minimum requirements, return them
292
+ if (this.meetsMinimumRequirements(validSuggestions)) {
293
+ // Limit to requested count, prioritizing single-line messages
294
+ const sorted = validSuggestions.sort((a, b) => {
295
+ if (a.type === 'single-line' && b.type !== 'single-line') return -1;
296
+ if (a.type !== 'single-line' && b.type === 'single-line') return 1;
297
+ return 0;
298
+ });
299
+ return sorted.slice(0, requestCount);
300
+ }
301
+
302
+ // Store best suggestions so far
303
+ if (validSuggestions.length > bestSuggestions.length) {
304
+ bestSuggestions = validSuggestions;
305
+ }
306
+ }
307
+
308
+ // If we have some valid suggestions but not enough, try to generate more
309
+ if (validSuggestions.length > 0 && validSuggestions.length < 2 && attempts < maxAttempts) {
310
+ // Request more messages in next attempt
311
+ continue;
312
+ }
313
+
314
+ // If we have at least 2 valid suggestions, return them even if not perfect
315
+ if (validSuggestions.length >= 2) {
316
+ return validSuggestions.slice(0, requestCount);
317
+ }
318
+ } catch (error) {
319
+ const errorMessage = (error as Error).message.toLowerCase();
320
+
321
+ // Handle specific API errors that shouldn't be retried
322
+ if (errorMessage.includes('rate limit') || errorMessage.includes('quota')) {
323
+ throw APIError.rateLimitExceeded();
324
+ }
325
+
326
+ if (
327
+ errorMessage.includes('api key') ||
328
+ errorMessage.includes('auth') ||
329
+ errorMessage.includes('permission')
330
+ ) {
331
+ throw APIError.authenticationFailed();
332
+ }
333
+
334
+ if (errorMessage.includes('timeout') || errorMessage.includes('request timeout')) {
335
+ if (attempts >= maxAttempts) {
336
+ throw APIError.timeout();
337
+ }
338
+ // Continue to retry on timeout
339
+ continue;
340
+ }
341
+
342
+ // For other errors, continue retrying
343
+ if (attempts < maxAttempts) {
344
+ }
345
+ }
346
+ }
347
+
348
+ // If we have some valid suggestions, return them as fallback
349
+ if (bestSuggestions.length >= 1) {
350
+ return bestSuggestions.slice(0, requestCount);
351
+ }
352
+
353
+ // Last resort: try to generate a simple fallback message
354
+ if (bestSuggestions.length === 0) {
355
+ const fallbackMessage = this.generateFallbackMessage(diff);
356
+ if (fallbackMessage) {
357
+ return [
358
+ {
359
+ message: fallbackMessage,
360
+ type: 'single-line',
361
+ },
362
+ ];
363
+ }
364
+ }
365
+
366
+ // If all else fails, throw error with helpful message
367
+ throw ValidationError.noValidSuggestions();
368
+ }
369
+
370
+ /**
371
+ * Generate a simple fallback commit message when AI fails
372
+ * @param diff Git diff information
373
+ * @returns Simple commit message or null
374
+ */
375
+ private generateFallbackMessage(diff: GitDiff): string | null {
376
+ const combinedDiff = `${diff.staged}\n${diff.unstaged}`.toLowerCase();
377
+
378
+ // Simple heuristics to determine commit type
379
+ let type = 'chore';
380
+ if (
381
+ combinedDiff.includes('fix') ||
382
+ combinedDiff.includes('bug') ||
383
+ combinedDiff.includes('error')
384
+ ) {
385
+ type = 'fix';
386
+ } else if (
387
+ combinedDiff.includes('feat') ||
388
+ combinedDiff.includes('add') ||
389
+ combinedDiff.includes('new')
390
+ ) {
391
+ type = 'feat';
392
+ } else if (combinedDiff.includes('doc') || combinedDiff.includes('readme')) {
393
+ type = 'docs';
394
+ } else if (combinedDiff.includes('refactor')) {
395
+ type = 'refactor';
396
+ }
397
+
398
+ // Try to extract a simple description
399
+ const lines = combinedDiff.split('\n').slice(0, 5);
400
+ const fileChanges = lines.filter((l) => l.startsWith('+++') || l.startsWith('---'));
401
+
402
+ if (fileChanges.length > 0) {
403
+ const firstFile =
404
+ fileChanges[0]
405
+ .replace(/^[+-]{3}\s+/, '')
406
+ .split('/')
407
+ .pop() || 'files';
408
+ return `${type}: update ${firstFile}`;
409
+ }
410
+
411
+ return `${type}: update code`;
412
+ }
413
+ }
@@ -0,0 +1,141 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { ConfigurationError } from '../errors/CustomErrors.js';
5
+ import type { Config } from '../types/index.js';
6
+ import type { UIManager } from '../ui/UIManager.js';
7
+
8
+ /**
9
+ * Manages persistent configuration for the Commit CLI
10
+ * Handles loading, saving, and validation of API key and model preferences
11
+ */
12
+ export class ConfigManager {
13
+ private readonly configPath: string;
14
+ private readonly configDir: string;
15
+ private static readonly CONFIG_VERSION = '1.0.0';
16
+
17
+ constructor(configPath?: string) {
18
+ this.configPath = configPath || join(homedir(), '.commic', 'config.json');
19
+ this.configDir = dirname(this.configPath);
20
+ }
21
+
22
+ /**
23
+ * Load configuration from disk
24
+ * @returns Config object or null if not found
25
+ * @throws ConfigurationError if config file is corrupted
26
+ */
27
+ async load(): Promise<Config | null> {
28
+ try {
29
+ const configData = await fs.readFile(this.configPath, 'utf-8');
30
+ const config = JSON.parse(configData) as Config;
31
+
32
+ // Validate config structure
33
+ if (!config.apiKey || !config.model) {
34
+ throw ConfigurationError.configFileCorrupted();
35
+ }
36
+
37
+ return config;
38
+ } catch (error) {
39
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
40
+ // Config file doesn't exist yet - this is fine on first run
41
+ return null;
42
+ }
43
+
44
+ if (error instanceof SyntaxError) {
45
+ throw ConfigurationError.configFileCorrupted();
46
+ }
47
+
48
+ if (error instanceof ConfigurationError) {
49
+ throw error;
50
+ }
51
+
52
+ throw ConfigurationError.configSaveFailed(error as Error);
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Save configuration to disk
58
+ * Creates config directory if it doesn't exist
59
+ * @param config Configuration to save
60
+ * @throws ConfigurationError if save fails
61
+ */
62
+ async save(config: Config): Promise<void> {
63
+ try {
64
+ // Ensure config directory exists
65
+ await fs.mkdir(this.configDir, { recursive: true });
66
+
67
+ // Add version to config
68
+ const configWithVersion: Config = {
69
+ ...config,
70
+ version: ConfigManager.CONFIG_VERSION,
71
+ };
72
+
73
+ // Write config file with pretty formatting
74
+ await fs.writeFile(this.configPath, JSON.stringify(configWithVersion, null, 2), 'utf-8');
75
+ } catch (error) {
76
+ throw ConfigurationError.configSaveFailed(error as Error);
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Check if configuration file exists
82
+ * @returns true if config exists, false otherwise
83
+ */
84
+ async exists(): Promise<boolean> {
85
+ try {
86
+ await fs.access(this.configPath);
87
+ return true;
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Get the configuration file path
95
+ * @returns Absolute path to config file
96
+ */
97
+ getConfigPath(): string {
98
+ return this.configPath;
99
+ }
100
+
101
+ /**
102
+ * Validate API key format
103
+ * Basic validation - checks if it looks like a Gemini API key
104
+ * @param apiKey API key to validate
105
+ * @returns true if valid format
106
+ */
107
+ static validateApiKeyFormat(apiKey: string): boolean {
108
+ // Gemini API keys typically start with "AIza" and are around 39 characters
109
+ // This is a basic check - actual validation happens when making API calls
110
+ return apiKey.length > 20 && apiKey.trim() === apiKey;
111
+ }
112
+
113
+ /**
114
+ * Prompt user for configuration (API key and model)
115
+ * @param ui UIManager instance for prompts
116
+ * @returns New configuration object
117
+ */
118
+ async promptForConfig(ui: UIManager): Promise<Config> {
119
+ ui.showSectionHeader('🔧 Configuration Setup');
120
+ ui.showInfo('Get your free API key at: https://aistudio.google.com/app/api-keys');
121
+ ui.newLine();
122
+
123
+ // Prompt for API key
124
+ const apiKey = await ui.promptForApiKey();
125
+
126
+ // Validate API key format
127
+ if (!ConfigManager.validateApiKeyFormat(apiKey)) {
128
+ throw ConfigurationError.invalidApiKey();
129
+ }
130
+
131
+ // Prompt for model selection
132
+ const availableModels = ['gemini-2.5-flash', 'gemini-flash-latest'];
133
+ const model = await ui.promptForModel(availableModels);
134
+
135
+ return {
136
+ apiKey,
137
+ model,
138
+ version: ConfigManager.CONFIG_VERSION,
139
+ };
140
+ }
141
+ }