bit-cli-ai 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.
@@ -0,0 +1,238 @@
1
+ /**
2
+ * AI Service for generating commit messages and code analysis
3
+ * Supports multiple AI providers with fallback to rule-based generation
4
+ */
5
+
6
+ import OpenAI from 'openai';
7
+ import { config } from './config.js';
8
+ import { log } from './logger.js';
9
+ import { APIKeyError, RateLimitError, AIError } from './errors.js';
10
+
11
+ /**
12
+ * Get OpenAI client instance
13
+ */
14
+ function getOpenAIClient() {
15
+ const apiKey = config.get('apiKeys.openai');
16
+
17
+ if (!apiKey) {
18
+ return null;
19
+ }
20
+
21
+ return new OpenAI({ apiKey });
22
+ }
23
+
24
+ /**
25
+ * Generate commit message using AI
26
+ */
27
+ export async function generateCommitMessage(diff, options = {}) {
28
+ const { maxLength = 72, style = 'conventional' } = options;
29
+
30
+ const aiConfig = config.get('ai');
31
+ const client = getOpenAIClient();
32
+
33
+ // Fallback to rule-based if no API key
34
+ if (!client) {
35
+ log.ai('fallback', { reason: 'no_api_key' });
36
+ return generateRuleBasedMessage(diff);
37
+ }
38
+
39
+ try {
40
+ log.ai('generate_commit', { model: aiConfig.model, diffLength: diff.length });
41
+
42
+ const systemPrompt = getCommitSystemPrompt(style, maxLength);
43
+
44
+ const response = await client.chat.completions.create({
45
+ model: aiConfig.model,
46
+ messages: [
47
+ { role: 'system', content: systemPrompt },
48
+ { role: 'user', content: truncateDiff(diff, 4000) },
49
+ ],
50
+ max_tokens: aiConfig.maxTokens,
51
+ temperature: aiConfig.temperature,
52
+ });
53
+
54
+ const message = response.choices[0]?.message?.content?.trim();
55
+
56
+ log.ai('generate_commit_success', { message });
57
+
58
+ return message || generateRuleBasedMessage(diff);
59
+ } catch (error) {
60
+ log.exception(error, { operation: 'generate_commit' });
61
+
62
+ if (error.status === 429) {
63
+ throw new RateLimitError();
64
+ }
65
+
66
+ if (error.status === 401) {
67
+ throw new APIKeyError('OpenAI');
68
+ }
69
+
70
+ // Fallback to rule-based on any error
71
+ log.ai('fallback', { reason: error.message });
72
+ return generateRuleBasedMessage(diff);
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Analyze code changes for summary
78
+ */
79
+ export async function analyzeCodeChanges(diff, options = {}) {
80
+ const client = getOpenAIClient();
81
+
82
+ if (!client) {
83
+ return null;
84
+ }
85
+
86
+ try {
87
+ const response = await client.chat.completions.create({
88
+ model: config.get('ai.model'),
89
+ messages: [
90
+ {
91
+ role: 'system',
92
+ content: `Analyze the following git diff and provide:
93
+ 1. A brief summary of changes (1-2 sentences)
94
+ 2. List of modified functions/methods
95
+ 3. Potential risks or areas needing review
96
+ 4. Suggested reviewers based on code areas
97
+
98
+ Format as JSON with keys: summary, modifiedFunctions, risks, suggestedAreas`,
99
+ },
100
+ { role: 'user', content: truncateDiff(diff, 4000) },
101
+ ],
102
+ max_tokens: 1000,
103
+ temperature: 0.3,
104
+ });
105
+
106
+ const content = response.choices[0]?.message?.content;
107
+
108
+ try {
109
+ return JSON.parse(content);
110
+ } catch {
111
+ return { summary: content };
112
+ }
113
+ } catch (error) {
114
+ log.exception(error, { operation: 'analyze_code' });
115
+ return null;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Get system prompt for commit message generation
121
+ */
122
+ function getCommitSystemPrompt(style, maxLength) {
123
+ const basePrompt = `You are a helpful assistant that generates concise git commit messages.
124
+ Analyze the diff and write a clear, descriptive commit message.
125
+ Maximum length: ${maxLength} characters for the first line.`;
126
+
127
+ const stylePrompts = {
128
+ conventional: `${basePrompt}
129
+ Use Conventional Commits format:
130
+ - feat: new feature
131
+ - fix: bug fix
132
+ - docs: documentation changes
133
+ - style: formatting, no code change
134
+ - refactor: code restructuring
135
+ - test: adding tests
136
+ - chore: maintenance tasks
137
+
138
+ Examples:
139
+ - feat: add user authentication flow
140
+ - fix: resolve null pointer in checkout
141
+ - refactor: simplify payment processing logic`,
142
+
143
+ simple: `${basePrompt}
144
+ Write simple, clear messages starting with a verb.
145
+ Examples:
146
+ - Add user authentication
147
+ - Fix checkout bug
148
+ - Update README`,
149
+
150
+ detailed: `${basePrompt}
151
+ Provide a short summary line followed by bullet points of changes.
152
+ Format:
153
+ Summary line (max 50 chars)
154
+
155
+ - Change 1
156
+ - Change 2`,
157
+ };
158
+
159
+ return stylePrompts[style] || stylePrompts.conventional;
160
+ }
161
+
162
+ /**
163
+ * Rule-based commit message generation (fallback)
164
+ */
165
+ export function generateRuleBasedMessage(diff) {
166
+ const lines = diff.split('\n');
167
+ const stats = {
168
+ additions: 0,
169
+ deletions: 0,
170
+ files: new Set(),
171
+ types: new Set(),
172
+ };
173
+
174
+ // Parse diff
175
+ for (const line of lines) {
176
+ if (line.startsWith('+++ b/')) {
177
+ const file = line.replace('+++ b/', '');
178
+ stats.files.add(file);
179
+
180
+ // Detect file type
181
+ if (file.endsWith('.test.js') || file.endsWith('.spec.js')) {
182
+ stats.types.add('test');
183
+ } else if (file.endsWith('.md')) {
184
+ stats.types.add('docs');
185
+ } else if (file.includes('config')) {
186
+ stats.types.add('chore');
187
+ }
188
+ } else if (line.startsWith('+') && !line.startsWith('+++')) {
189
+ stats.additions++;
190
+ } else if (line.startsWith('-') && !line.startsWith('---')) {
191
+ stats.deletions++;
192
+ }
193
+ }
194
+
195
+ // Determine commit type
196
+ let type = 'chore';
197
+ if (stats.types.has('test')) {
198
+ type = 'test';
199
+ } else if (stats.types.has('docs')) {
200
+ type = 'docs';
201
+ } else if (stats.additions > stats.deletions * 2) {
202
+ type = 'feat';
203
+ } else if (stats.deletions > stats.additions) {
204
+ type = 'refactor';
205
+ } else if (stats.files.size === 1) {
206
+ type = 'fix';
207
+ }
208
+
209
+ // Generate message
210
+ const fileCount = stats.files.size;
211
+ const fileList = Array.from(stats.files).slice(0, 3);
212
+
213
+ if (fileCount === 1) {
214
+ const fileName = fileList[0].split('/').pop();
215
+ return `${type}: update ${fileName}`;
216
+ } else if (fileCount <= 3) {
217
+ return `${type}: update ${fileList.map((f) => f.split('/').pop()).join(', ')}`;
218
+ } else {
219
+ return `${type}: update ${fileCount} files`;
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Truncate diff to fit token limit
225
+ */
226
+ function truncateDiff(diff, maxChars) {
227
+ if (diff.length <= maxChars) {
228
+ return diff;
229
+ }
230
+
231
+ return diff.slice(0, maxChars) + '\n\n... (diff truncated)';
232
+ }
233
+
234
+ export default {
235
+ generateCommitMessage,
236
+ analyzeCodeChanges,
237
+ generateRuleBasedMessage,
238
+ };
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Configuration management for Bit CLI
3
+ * Handles global config, project config, and environment variables
4
+ */
5
+
6
+ import Conf from 'conf';
7
+ import path from 'path';
8
+ import fs from 'fs';
9
+ import os from 'os';
10
+ import dotenv from 'dotenv';
11
+
12
+ // Load environment variables
13
+ dotenv.config();
14
+
15
+ // Default configuration values
16
+ const defaults = {
17
+ ai: {
18
+ provider: 'openai',
19
+ model: 'gpt-4o-mini',
20
+ maxTokens: 500,
21
+ temperature: 0.7,
22
+ },
23
+ git: {
24
+ ghostPrefix: 'ghost/',
25
+ autoStage: false,
26
+ signCommits: false,
27
+ },
28
+ ui: {
29
+ colors: true,
30
+ spinners: true,
31
+ verbose: false,
32
+ },
33
+ telemetry: {
34
+ enabled: false,
35
+ anonymousId: null,
36
+ },
37
+ };
38
+
39
+ // Schema for configuration validation
40
+ const schema = {
41
+ ai: {
42
+ type: 'object',
43
+ properties: {
44
+ provider: { type: 'string', enum: ['openai', 'anthropic', 'local'] },
45
+ model: { type: 'string' },
46
+ maxTokens: { type: 'number', minimum: 100, maximum: 4000 },
47
+ temperature: { type: 'number', minimum: 0, maximum: 2 },
48
+ },
49
+ },
50
+ git: {
51
+ type: 'object',
52
+ properties: {
53
+ ghostPrefix: { type: 'string' },
54
+ autoStage: { type: 'boolean' },
55
+ signCommits: { type: 'boolean' },
56
+ },
57
+ },
58
+ ui: {
59
+ type: 'object',
60
+ properties: {
61
+ colors: { type: 'boolean' },
62
+ spinners: { type: 'boolean' },
63
+ verbose: { type: 'boolean' },
64
+ },
65
+ },
66
+ };
67
+
68
+ // Global configuration (stored in ~/.bit/config.json)
69
+ const globalConfig = new Conf({
70
+ projectName: 'bit-cli',
71
+ defaults,
72
+ schema,
73
+ });
74
+
75
+ /**
76
+ * Get project-specific configuration from .bit/config.json
77
+ */
78
+ function getProjectConfig() {
79
+ const projectConfigPath = path.join(process.cwd(), '.bit', 'config.json');
80
+
81
+ if (fs.existsSync(projectConfigPath)) {
82
+ try {
83
+ const content = fs.readFileSync(projectConfigPath, 'utf-8');
84
+ return JSON.parse(content);
85
+ } catch (error) {
86
+ return {};
87
+ }
88
+ }
89
+
90
+ return {};
91
+ }
92
+
93
+ /**
94
+ * Merge configurations with priority: env > project > global > defaults
95
+ */
96
+ function getMergedConfig() {
97
+ const projectConfig = getProjectConfig();
98
+
99
+ return {
100
+ ai: {
101
+ ...defaults.ai,
102
+ ...globalConfig.get('ai'),
103
+ ...projectConfig.ai,
104
+ // Environment overrides
105
+ provider: process.env.BIT_AI_PROVIDER || globalConfig.get('ai.provider'),
106
+ model: process.env.BIT_AI_MODEL || globalConfig.get('ai.model'),
107
+ },
108
+ git: {
109
+ ...defaults.git,
110
+ ...globalConfig.get('git'),
111
+ ...projectConfig.git,
112
+ },
113
+ ui: {
114
+ ...defaults.ui,
115
+ ...globalConfig.get('ui'),
116
+ ...projectConfig.ui,
117
+ verbose: process.env.BIT_VERBOSE === 'true' || globalConfig.get('ui.verbose'),
118
+ },
119
+ // API Keys from environment only (never stored in config)
120
+ apiKeys: {
121
+ openai: process.env.OPENAI_API_KEY,
122
+ anthropic: process.env.ANTHROPIC_API_KEY,
123
+ },
124
+ };
125
+ }
126
+
127
+ // Configuration API
128
+ export const config = {
129
+ // Get merged configuration
130
+ get: (key) => {
131
+ const merged = getMergedConfig();
132
+ if (!key) return merged;
133
+
134
+ return key.split('.').reduce((obj, k) => obj?.[k], merged);
135
+ },
136
+
137
+ // Set global configuration
138
+ set: (key, value) => {
139
+ globalConfig.set(key, value);
140
+ },
141
+
142
+ // Set project configuration
143
+ setProject: (key, value) => {
144
+ const projectConfigPath = path.join(process.cwd(), '.bit', 'config.json');
145
+ const projectConfig = getProjectConfig();
146
+
147
+ // Set nested key
148
+ const keys = key.split('.');
149
+ let obj = projectConfig;
150
+ for (let i = 0; i < keys.length - 1; i++) {
151
+ obj[keys[i]] = obj[keys[i]] || {};
152
+ obj = obj[keys[i]];
153
+ }
154
+ obj[keys[keys.length - 1]] = value;
155
+
156
+ fs.writeFileSync(projectConfigPath, JSON.stringify(projectConfig, null, 2));
157
+ },
158
+
159
+ // Reset to defaults
160
+ reset: () => {
161
+ globalConfig.clear();
162
+ },
163
+
164
+ // Check if API key is configured
165
+ hasApiKey: (provider = 'openai') => {
166
+ const keys = getMergedConfig().apiKeys;
167
+ return !!keys[provider];
168
+ },
169
+
170
+ // Get config file paths
171
+ paths: {
172
+ global: globalConfig.path,
173
+ project: path.join(process.cwd(), '.bit', 'config.json'),
174
+ logs: path.join(os.homedir(), '.bit', 'logs'),
175
+ },
176
+ };
177
+
178
+ export default config;
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Custom error classes and error handling utilities
3
+ * Provides consistent error handling across the CLI
4
+ */
5
+
6
+ import chalk from 'chalk';
7
+ import { log } from './logger.js';
8
+
9
+ // Base error class for Bit CLI
10
+ export class BitError extends Error {
11
+ constructor(message, code = 'BIT_ERROR', details = {}) {
12
+ super(message);
13
+ this.name = 'BitError';
14
+ this.code = code;
15
+ this.details = details;
16
+ this.timestamp = new Date().toISOString();
17
+ }
18
+ }
19
+
20
+ // Git-related errors
21
+ export class GitError extends BitError {
22
+ constructor(message, details = {}) {
23
+ super(message, 'GIT_ERROR', details);
24
+ this.name = 'GitError';
25
+ }
26
+ }
27
+
28
+ // Not in a git repository
29
+ export class NotARepositoryError extends GitError {
30
+ constructor() {
31
+ super('Not a git repository. Run "bit init" to initialize.', {
32
+ suggestion: 'Run "bit init" to create a new repository',
33
+ });
34
+ this.name = 'NotARepositoryError';
35
+ }
36
+ }
37
+
38
+ // Merge conflict error
39
+ export class MergeConflictError extends GitError {
40
+ constructor(files = []) {
41
+ super('Merge conflicts detected', { conflictingFiles: files });
42
+ this.name = 'MergeConflictError';
43
+ }
44
+ }
45
+
46
+ // AI-related errors
47
+ export class AIError extends BitError {
48
+ constructor(message, details = {}) {
49
+ super(message, 'AI_ERROR', details);
50
+ this.name = 'AIError';
51
+ }
52
+ }
53
+
54
+ // API key not configured
55
+ export class APIKeyError extends AIError {
56
+ constructor(provider = 'OpenAI') {
57
+ super(`${provider} API key not configured`, {
58
+ suggestion: `Set ${provider.toUpperCase()}_API_KEY environment variable`,
59
+ });
60
+ this.name = 'APIKeyError';
61
+ }
62
+ }
63
+
64
+ // Rate limit exceeded
65
+ export class RateLimitError extends AIError {
66
+ constructor(retryAfter = null) {
67
+ super('API rate limit exceeded', {
68
+ retryAfter,
69
+ suggestion: 'Wait a moment and try again',
70
+ });
71
+ this.name = 'RateLimitError';
72
+ }
73
+ }
74
+
75
+ // Configuration errors
76
+ export class ConfigError extends BitError {
77
+ constructor(message, details = {}) {
78
+ super(message, 'CONFIG_ERROR', details);
79
+ this.name = 'ConfigError';
80
+ }
81
+ }
82
+
83
+ // Validation errors
84
+ export class ValidationError extends BitError {
85
+ constructor(message, details = {}) {
86
+ super(message, 'VALIDATION_ERROR', details);
87
+ this.name = 'ValidationError';
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Global error handler for CLI
93
+ */
94
+ export function handleError(error, options = {}) {
95
+ const { exit = true, showStack = false } = options;
96
+
97
+ // Log the error
98
+ log.exception(error, { command: process.argv.slice(2).join(' ') });
99
+
100
+ // Format error output
101
+ console.error('');
102
+
103
+ if (error instanceof BitError) {
104
+ console.error(chalk.red(`Error [${error.code}]: ${error.message}`));
105
+
106
+ if (error.details?.suggestion) {
107
+ console.error(chalk.yellow(`Suggestion: ${error.details.suggestion}`));
108
+ }
109
+
110
+ if (error.details?.conflictingFiles?.length) {
111
+ console.error(chalk.yellow('\nConflicting files:'));
112
+ error.details.conflictingFiles.forEach((file) => {
113
+ console.error(chalk.yellow(` - ${file}`));
114
+ });
115
+ }
116
+ } else {
117
+ console.error(chalk.red(`Error: ${error.message}`));
118
+ }
119
+
120
+ // Show stack trace in debug mode
121
+ if (showStack || process.env.BIT_DEBUG === 'true') {
122
+ console.error(chalk.gray('\nStack trace:'));
123
+ console.error(chalk.gray(error.stack));
124
+ }
125
+
126
+ console.error('');
127
+
128
+ if (exit) {
129
+ process.exit(1);
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Wrap async functions with error handling
135
+ */
136
+ export function withErrorHandling(fn) {
137
+ return async (...args) => {
138
+ try {
139
+ return await fn(...args);
140
+ } catch (error) {
141
+ handleError(error);
142
+ }
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Try to execute a function, return default value on error
148
+ */
149
+ export function tryOrDefault(fn, defaultValue = null) {
150
+ try {
151
+ return fn();
152
+ } catch {
153
+ return defaultValue;
154
+ }
155
+ }
156
+
157
+ export default {
158
+ BitError,
159
+ GitError,
160
+ NotARepositoryError,
161
+ MergeConflictError,
162
+ AIError,
163
+ APIKeyError,
164
+ RateLimitError,
165
+ ConfigError,
166
+ ValidationError,
167
+ handleError,
168
+ withErrorHandling,
169
+ tryOrDefault,
170
+ };