gims 0.6.7 → 0.8.2

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.
@@ -15,22 +15,25 @@ class AIProviderManager {
15
15
 
16
16
  resolveProvider(preference = 'auto') {
17
17
  if (preference === 'none') return 'none';
18
- if (preference === 'openai') return process.env.OPENAI_API_KEY ? 'openai' : 'none';
19
- if (preference === 'gemini') return process.env.GEMINI_API_KEY ? 'gemini' : 'none';
20
- if (preference === 'groq') return process.env.GROQ_API_KEY ? 'groq' : 'none';
21
18
 
22
- // Auto-detection with preference order (Gemini first - fastest and cheapest)
19
+ // Check if preferred provider's key is available
20
+ if (preference === 'openai' && process.env.OPENAI_API_KEY) return 'openai';
21
+ if (preference === 'gemini' && process.env.GEMINI_API_KEY) return 'gemini';
22
+ if (preference === 'groq' && process.env.GROQ_API_KEY) return 'groq';
23
+
24
+ // Fallback: try any available provider (priority: Gemini → OpenAI → Groq)
23
25
  if (process.env.GEMINI_API_KEY) return 'gemini';
24
26
  if (process.env.OPENAI_API_KEY) return 'openai';
25
27
  if (process.env.GROQ_API_KEY) return 'groq';
28
+
26
29
  return 'none';
27
30
  }
28
31
 
29
32
  getDefaultModel(provider) {
30
33
  const defaults = {
31
- 'gemini': 'gemini-2.5-flash', // Latest and fastest
32
- 'openai': 'gpt-5', // Latest GPT model
33
- 'groq': 'groq/compound' // Latest Groq model
34
+ 'gemini': 'gemini-3-flash-preview', // Latest Gemini model
35
+ 'openai': 'gpt-5.2-2025-12-11', // Latest GPT model
36
+ 'groq': 'groq/compound' // Latest Groq model
34
37
  };
35
38
  return defaults[provider] || '';
36
39
  }
@@ -48,12 +51,12 @@ class AIProviderManager {
48
51
 
49
52
  setCache(cacheKey, result, usedLocal = false) {
50
53
  if (!this.config.cacheEnabled) return;
51
-
54
+
52
55
  if (this.cache.size >= this.maxCacheSize) {
53
56
  const firstKey = this.cache.keys().next().value;
54
57
  this.cache.delete(firstKey);
55
58
  }
56
-
59
+
57
60
  this.cache.set(cacheKey, {
58
61
  result,
59
62
  usedLocal,
@@ -67,11 +70,11 @@ class AIProviderManager {
67
70
  try {
68
71
  switch (provider) {
69
72
  case 'gemini':
70
- return await this.generateWithGemini(prompt, model || 'gemini-2.0-flash', options);
73
+ return await this.generateWithGemini(prompt, model || this.getDefaultModel('gemini'), options);
71
74
  case 'openai':
72
- return await this.generateWithOpenAI(prompt, model || 'gpt-4o-mini', options);
75
+ return await this.generateWithOpenAI(prompt, model || this.getDefaultModel('openai'), options);
73
76
  case 'groq':
74
- return await this.generateWithGroq(prompt, model || 'llama-3.1-8b-instant', options);
77
+ return await this.generateWithGroq(prompt, model || this.getDefaultModel('groq'), options);
75
78
  default:
76
79
  throw new Error(`Unknown provider: ${provider}`);
77
80
  }
@@ -103,9 +106,9 @@ class AIProviderManager {
103
106
  }
104
107
 
105
108
  async generateWithGroq(prompt, model, options) {
106
- const groq = new OpenAI({
107
- apiKey: process.env.GROQ_API_KEY,
108
- baseURL: process.env.GROQ_BASE_URL || 'https://api.groq.com/openai/v1'
109
+ const groq = new OpenAI({
110
+ apiKey: process.env.GROQ_API_KEY,
111
+ baseURL: process.env.GROQ_BASE_URL || 'https://api.groq.com/openai/v1'
109
112
  });
110
113
  const actualModel = model || this.getDefaultModel('groq');
111
114
  const response = await groq.chat.completions.create({
@@ -118,11 +121,11 @@ class AIProviderManager {
118
121
  }
119
122
 
120
123
  async generateCommitMessage(diff, options = {}) {
121
- const {
122
- provider: preferredProvider = 'auto',
123
- conventional = false,
124
+ const {
125
+ provider: preferredProvider = this.config.provider || 'auto',
126
+ conventional = this.config.conventional || false,
124
127
  body = false,
125
- verbose = false
128
+ verbose = false
126
129
  } = options;
127
130
 
128
131
  // Check cache first
@@ -134,11 +137,9 @@ class AIProviderManager {
134
137
  }
135
138
 
136
139
  const providerChain = this.buildProviderChain(preferredProvider);
137
-
140
+
138
141
  for (const provider of providerChain) {
139
142
  try {
140
- if (verbose) Progress.info(`Trying provider: ${provider}`);
141
-
142
143
  if (provider === 'local') {
143
144
  const result = await this.generateLocalHeuristic(diff, options);
144
145
  this.setCache(cacheKey, result, true);
@@ -148,10 +149,10 @@ class AIProviderManager {
148
149
  const prompt = this.buildPrompt(diff, { conventional, body });
149
150
  const result = await this.generateWithProvider(provider, prompt, options);
150
151
  const cleaned = this.cleanCommitMessage(result, { body });
151
-
152
+
152
153
  this.setCache(cacheKey, cleaned, false);
153
154
  return { message: cleaned, usedLocal: false };
154
-
155
+
155
156
  } catch (error) {
156
157
  if (verbose) Progress.warning(`${provider} failed: ${error.message}`);
157
158
  continue;
@@ -166,37 +167,41 @@ class AIProviderManager {
166
167
 
167
168
  buildProviderChain(preferred) {
168
169
  const available = [];
169
-
170
+
171
+ // First, try the preferred provider if its key is available
170
172
  if (preferred !== 'auto' && preferred !== 'none') {
171
- const resolved = this.resolveProvider(preferred);
172
- if (resolved !== 'none') available.push(resolved);
173
- } else if (preferred === 'auto') {
174
- if (process.env.GEMINI_API_KEY) available.push('gemini');
175
- if (process.env.OPENAI_API_KEY) available.push('openai');
176
- if (process.env.GROQ_API_KEY) available.push('groq');
173
+ if (preferred === 'gemini' && process.env.GEMINI_API_KEY) available.push('gemini');
174
+ else if (preferred === 'openai' && process.env.OPENAI_API_KEY) available.push('openai');
175
+ else if (preferred === 'groq' && process.env.GROQ_API_KEY) available.push('groq');
177
176
  }
178
-
177
+
178
+ // Then add all other available providers as fallbacks
179
+ if (process.env.GEMINI_API_KEY && !available.includes('gemini')) available.push('gemini');
180
+ if (process.env.OPENAI_API_KEY && !available.includes('openai')) available.push('openai');
181
+ if (process.env.GROQ_API_KEY && !available.includes('groq')) available.push('groq');
182
+
183
+ // Local heuristics as final fallback
179
184
  available.push('local');
180
- return [...new Set(available)];
185
+ return available;
181
186
  }
182
187
 
183
188
  buildPrompt(diff, options) {
184
189
  const { conventional, body } = options;
185
-
186
- const style = conventional
190
+
191
+ const style = conventional
187
192
  ? 'Use Conventional Commits format (e.g., feat:, fix:, chore:) for the subject.'
188
193
  : 'Subject must be a single short line.';
189
-
190
- const bodyInstr = body
194
+
195
+ const bodyInstr = body
191
196
  ? 'Provide a short subject line followed by an optional body separated by a blank line.'
192
197
  : 'Return only a short subject line without extra quotes.';
193
-
198
+
194
199
  return `Write a concise git commit message for these changes:\n${diff}\n\n${style} ${bodyInstr}`;
195
200
  }
196
201
 
197
202
  cleanCommitMessage(message, options = {}) {
198
203
  if (!message) return 'Update project code';
199
-
204
+
200
205
  // Remove markdown formatting
201
206
  let cleaned = message
202
207
  .replace(/```[\s\S]*?```/g, '')
@@ -212,7 +217,7 @@ class AIProviderManager {
212
217
 
213
218
  const lines = cleaned.split('\n').map(l => l.trim()).filter(Boolean);
214
219
  let subject = (lines[0] || '').replace(/\s{2,}/g, ' ').replace(/[\s:,.!;]+$/g, '').trim();
215
-
220
+
216
221
  if (subject.length === 0) subject = 'Update project code';
217
222
  // No length restriction - allow AI to generate full commit messages
218
223
 
@@ -226,16 +231,16 @@ class AIProviderManager {
226
231
  async generateLocalHeuristic(diff, options) {
227
232
  // This would need access to git status - simplified version
228
233
  const { conventional = false } = options;
229
-
234
+
230
235
  // Analyze diff for patterns
231
236
  const lines = diff.split('\n');
232
237
  const additions = lines.filter(l => l.startsWith('+')).length;
233
238
  const deletions = lines.filter(l => l.startsWith('-')).length;
234
239
  const files = (diff.match(/diff --git/g) || []).length;
235
-
240
+
236
241
  let type = 'chore';
237
242
  let subject = 'update files';
238
-
243
+
239
244
  if (additions > deletions * 2) {
240
245
  type = 'feat';
241
246
  subject = files === 1 ? 'add new functionality' : `add features to ${files} files`;
@@ -249,37 +254,39 @@ class AIProviderManager {
249
254
  type = 'docs';
250
255
  subject = 'update documentation';
251
256
  }
252
-
257
+
253
258
  return conventional ? `${type}: ${subject}` : subject.charAt(0).toUpperCase() + subject.slice(1);
254
259
  }
255
260
 
256
261
  async generateMultipleSuggestions(diff, options = {}, count = 3) {
257
262
  const suggestions = [];
258
263
  const baseOptions = { ...options };
259
-
264
+
260
265
  // Generate different styles
261
266
  const variants = [
262
267
  { ...baseOptions, conventional: false },
263
268
  { ...baseOptions, conventional: true },
264
269
  { ...baseOptions, conventional: true, body: true }
265
270
  ];
266
-
271
+
267
272
  for (let i = 0; i < Math.min(count, variants.length); i++) {
268
273
  try {
269
- const suggestion = await this.generateCommitMessage(diff, variants[i]);
270
- if (!suggestions.includes(suggestion)) {
271
- suggestions.push(suggestion);
274
+ const result = await this.generateCommitMessage(diff, variants[i]);
275
+ // Extract message string from result object
276
+ const message = result.message || result;
277
+ if (message && !suggestions.includes(message)) {
278
+ suggestions.push(message);
272
279
  }
273
280
  } catch (error) {
274
281
  // Skip failed generations
275
282
  }
276
283
  }
277
-
284
+
278
285
  // Ensure we have at least one suggestion
279
286
  if (suggestions.length === 0) {
280
287
  suggestions.push('Update project files');
281
288
  }
282
-
289
+
283
290
  return suggestions;
284
291
  }
285
292
  }
@@ -1,4 +1,5 @@
1
1
  const { color } = require('../utils/colors');
2
+ const { Intelligence } = require('../utils/intelligence');
2
3
 
3
4
  /**
4
5
  * Enhanced git analysis and insights
@@ -6,17 +7,22 @@ const { color } = require('../utils/colors');
6
7
  class GitAnalyzer {
7
8
  constructor(git) {
8
9
  this.git = git;
10
+ this.intelligence = new Intelligence(git);
9
11
  }
10
12
 
11
13
  async getEnhancedStatus() {
12
14
  try {
13
15
  const status = await this.git.status();
14
16
  const insights = await this.generateStatusInsights(status);
15
-
17
+ const sessionStats = await this.intelligence.getSessionStats();
18
+ const branchContext = await this.intelligence.detectBranchContext();
19
+
16
20
  return {
17
21
  ...status,
18
22
  insights,
19
- summary: this.generateStatusSummary(status)
23
+ summary: this.generateStatusSummary(status),
24
+ sessionStats,
25
+ branchContext,
20
26
  };
21
27
  } catch (error) {
22
28
  throw new Error(`Failed to get git status: ${error.message}`);
@@ -32,47 +38,48 @@ class GitAnalyzer {
32
38
  const untracked = Array.isArray(status.not_added) ? status.not_added : [];
33
39
 
34
40
  if (files.length === 0) return 'Working tree clean';
35
-
41
+
36
42
  const parts = [];
37
43
  if (staged.length > 0) parts.push(`${staged.length} staged`);
38
44
  if (modified.length > 0) parts.push(`${modified.length} modified`);
39
45
  if (created.length > 0) parts.push(`${created.length} new`);
40
46
  if (deleted.length > 0) parts.push(`${deleted.length} deleted`);
41
47
  if (untracked.length > 0) parts.push(`${untracked.length} untracked`);
42
-
48
+
43
49
  return parts.join(', ');
44
50
  }
45
51
 
46
52
  async generateStatusInsights(status) {
47
53
  const insights = [];
48
-
54
+
49
55
  // Ensure arrays exist and have proper methods
50
56
  const modified = Array.isArray(status.modified) ? status.modified : [];
51
57
  const created = Array.isArray(status.created) ? status.created : [];
52
58
  const deleted = Array.isArray(status.deleted) ? status.deleted : [];
53
59
  const files = Array.isArray(status.files) ? status.files : [];
54
-
60
+ const staged = Array.isArray(status.staged) ? status.staged : [];
61
+
55
62
  // Check for common patterns
56
63
  if (modified.some(f => String(f).includes('package.json'))) {
57
64
  insights.push('📦 Dependencies may have changed - consider updating package-lock.json');
58
65
  }
59
-
66
+
60
67
  if (created.some(f => String(f).includes('.env'))) {
61
68
  insights.push('🔐 New environment file detected - ensure it\'s in .gitignore');
62
69
  }
63
-
70
+
64
71
  if (modified.some(f => String(f).includes('README'))) {
65
72
  insights.push('📚 Documentation updated - good practice!');
66
73
  }
67
-
74
+
68
75
  if (deleted.length > created.length + modified.length) {
69
76
  insights.push('🧹 Cleanup operation detected - removing more than adding');
70
77
  }
71
-
78
+
72
79
  if (files.length > 20) {
73
- insights.push('📊 Large changeset - consider breaking into smaller commits');
80
+ insights.push('📊 Large changeset - consider using `g split` to break into smaller commits');
74
81
  }
75
-
82
+
76
83
  // Check for test files
77
84
  const testFiles = files.filter(f => {
78
85
  const fileName = String(f);
@@ -81,7 +88,7 @@ class GitAnalyzer {
81
88
  if (testFiles.length > 0) {
82
89
  insights.push('🧪 Test files modified - great for code quality!');
83
90
  }
84
-
91
+
85
92
  // Check for config files
86
93
  const configFiles = files.filter(f => {
87
94
  const fileName = String(f);
@@ -90,7 +97,12 @@ class GitAnalyzer {
90
97
  if (configFiles.length > 0) {
91
98
  insights.push('⚙️ Configuration changes detected');
92
99
  }
93
-
100
+
101
+ // Suggest staging if nothing staged
102
+ if (staged.length === 0 && files.length > 0) {
103
+ insights.push('💡 Nothing staged yet - use `g o --all` to stage and commit everything');
104
+ }
105
+
94
106
  return insights;
95
107
  }
96
108
 
@@ -98,7 +110,11 @@ class GitAnalyzer {
98
110
  try {
99
111
  const log = await this.git.log({ maxCount: limit });
100
112
  const commits = log.all;
101
-
113
+
114
+ if (commits.length === 0) {
115
+ return { totalCommits: 0 };
116
+ }
117
+
102
118
  const analysis = {
103
119
  totalCommits: commits.length,
104
120
  authors: [...new Set(commits.map(c => c.author_name))],
@@ -106,7 +122,7 @@ class GitAnalyzer {
106
122
  conventionalCommits: commits.filter(c => /^(feat|fix|docs|style|refactor|test|chore)(\(.+\))?:/.test(c.message)).length,
107
123
  recentActivity: this.analyzeRecentActivity(commits)
108
124
  };
109
-
125
+
110
126
  return analysis;
111
127
  } catch (error) {
112
128
  return { error: error.message };
@@ -117,10 +133,10 @@ class GitAnalyzer {
117
133
  const now = new Date();
118
134
  const oneDayAgo = new Date(now - 24 * 60 * 60 * 1000);
119
135
  const oneWeekAgo = new Date(now - 7 * 24 * 60 * 60 * 1000);
120
-
136
+
121
137
  const recentCommits = commits.filter(c => new Date(c.date) > oneDayAgo);
122
138
  const weeklyCommits = commits.filter(c => new Date(c.date) > oneWeekAgo);
123
-
139
+
124
140
  return {
125
141
  last24h: recentCommits.length,
126
142
  lastWeek: weeklyCommits.length,
@@ -133,16 +149,20 @@ class GitAnalyzer {
133
149
  const additions = lines.filter(l => l.startsWith('+')).length;
134
150
  const deletions = lines.filter(l => l.startsWith('-')).length;
135
151
  const files = (diff.match(/diff --git/g) || []).length;
136
-
152
+
137
153
  let complexity = 'simple';
154
+ let emoji = '🟢';
138
155
  if (files > 10 || additions + deletions > 500) {
139
156
  complexity = 'complex';
157
+ emoji = '🔴';
140
158
  } else if (files > 5 || additions + deletions > 100) {
141
159
  complexity = 'moderate';
160
+ emoji = '🟡';
142
161
  }
143
-
162
+
144
163
  return {
145
164
  complexity,
165
+ emoji,
146
166
  files,
147
167
  additions,
148
168
  deletions,
@@ -151,81 +171,132 @@ class GitAnalyzer {
151
171
  }
152
172
 
153
173
  formatStatusOutput(enhancedStatus) {
154
- const {
155
- files = [],
156
- staged = [],
157
- modified = [],
158
- created = [],
159
- deleted = [],
160
- not_added = [],
161
- insights = [],
162
- summary = 'Unknown status'
174
+ const {
175
+ files = [],
176
+ staged = [],
177
+ modified = [],
178
+ created = [],
179
+ deleted = [],
180
+ not_added = [],
181
+ insights = [],
182
+ summary = 'Unknown status',
183
+ sessionStats = {},
184
+ branchContext = {},
163
185
  } = enhancedStatus;
164
-
186
+
165
187
  let output = '';
166
-
167
- // Header
168
- output += `${color.bold('Git Status')}\n`;
169
- output += `${color.dim(summary)}\n\n`;
170
-
188
+
189
+ // Header with branch info
190
+ output += `${color.bold('Git Status')}`;
191
+ if (branchContext.branch) {
192
+ output += ` ${color.dim('on')} ${color.cyan(branchContext.branch)}`;
193
+ if (branchContext.type) {
194
+ output += ` ${color.dim(`(${branchContext.type})`)}`;
195
+ }
196
+ }
197
+ output += '\n';
198
+ output += `${color.dim(summary)}\n`;
199
+
200
+ // Session stats
201
+ if (sessionStats.timeSinceLastCommit) {
202
+ output += `${color.dim(`Last commit: ${sessionStats.timeSinceLastCommit}`)}`;
203
+ if (sessionStats.commitsToday > 0) {
204
+ output += `${color.dim(` • ${sessionStats.commitsToday} commit${sessionStats.commitsToday > 1 ? 's' : ''} today`)}`;
205
+ }
206
+ output += '\n';
207
+ }
208
+ output += '\n';
209
+
171
210
  // Staged changes
172
211
  if (staged.length > 0) {
173
212
  output += `${color.green('Staged for commit:')}\n`;
174
213
  staged.forEach(file => {
175
- output += ` ${color.green('+')} ${file}\n`;
214
+ const emoji = Intelligence.getFileEmoji(file);
215
+ output += ` ${color.green('+')} ${emoji} ${file}\n`;
176
216
  });
177
217
  output += '\n';
178
218
  }
179
-
219
+
180
220
  // Modified files
181
221
  if (modified.length > 0) {
182
222
  output += `${color.yellow('Modified (not staged):')}\n`;
183
223
  modified.forEach(file => {
184
- output += ` ${color.yellow('M')} ${file}\n`;
224
+ const emoji = Intelligence.getFileEmoji(file);
225
+ output += ` ${color.yellow('M')} ${emoji} ${file}\n`;
185
226
  });
186
227
  output += '\n';
187
228
  }
188
-
229
+
189
230
  // New files
190
231
  if (created.length > 0) {
191
232
  output += `${color.cyan('New files:')}\n`;
192
233
  created.forEach(file => {
193
- output += ` ${color.cyan('N')} ${file}\n`;
234
+ const emoji = Intelligence.getFileEmoji(file);
235
+ output += ` ${color.cyan('N')} ${emoji} ${file}\n`;
194
236
  });
195
237
  output += '\n';
196
238
  }
197
-
239
+
198
240
  // Deleted files
199
241
  if (deleted.length > 0) {
200
242
  output += `${color.red('Deleted:')}\n`;
201
243
  deleted.forEach(file => {
202
- output += ` ${color.red('D')} ${file}\n`;
244
+ const emoji = Intelligence.getFileEmoji(file);
245
+ output += ` ${color.red('D')} ${emoji} ${file}\n`;
203
246
  });
204
247
  output += '\n';
205
248
  }
206
-
249
+
207
250
  // Untracked files
208
251
  if (not_added.length > 0) {
209
252
  output += `${color.dim('Untracked files:')}\n`;
210
253
  not_added.slice(0, 10).forEach(file => {
211
- output += ` ${color.dim('?')} ${file}\n`;
254
+ const emoji = Intelligence.getFileEmoji(file);
255
+ output += ` ${color.dim('?')} ${emoji} ${file}\n`;
212
256
  });
213
257
  if (not_added.length > 10) {
214
258
  output += ` ${color.dim(`... and ${not_added.length - 10} more`)}\n`;
215
259
  }
216
260
  output += '\n';
217
261
  }
218
-
262
+
219
263
  // AI Insights
220
264
  if (insights.length > 0) {
221
- output += `${color.cyan('💡 AI Insights:')}\n`;
265
+ output += `${color.cyan('💡 Insights:')}\n`;
222
266
  insights.forEach(insight => {
223
267
  output += ` ${insight}\n`;
224
268
  });
225
269
  }
226
-
270
+
227
271
  return output;
228
272
  }
273
+
274
+ /**
275
+ * Get today's commits formatted nicely
276
+ */
277
+ async getTodayCommits() {
278
+ try {
279
+ const today = new Date();
280
+ today.setHours(0, 0, 0, 0);
281
+ const log = await this.git.log({ '--since': today.toISOString() });
282
+ return log.all;
283
+ } catch (error) {
284
+ return [];
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Format commit for display
290
+ */
291
+ formatCommit(commit, index) {
292
+ const hash = color.yellow(commit.hash.substring(0, 7));
293
+ const message = commit.message.split('\n')[0];
294
+ const time = new Date(commit.date).toLocaleTimeString('en-US', {
295
+ hour: '2-digit',
296
+ minute: '2-digit'
297
+ });
298
+ return ` ${color.dim(`${index + 1}.`)} ${hash} ${message} ${color.dim(`(${time})`)}`;
299
+ }
229
300
  }
230
301
 
231
302
  module.exports = { GitAnalyzer };
@@ -8,8 +8,23 @@ const color = {
8
8
  cyan: (s) => `\x1b[36m${s}\x1b[0m`,
9
9
  blue: (s) => `\x1b[34m${s}\x1b[0m`,
10
10
  magenta: (s) => `\x1b[35m${s}\x1b[0m`,
11
+ white: (s) => `\x1b[37m${s}\x1b[0m`,
12
+ gray: (s) => `\x1b[90m${s}\x1b[0m`,
11
13
  bold: (s) => `\x1b[1m${s}\x1b[0m`,
12
14
  dim: (s) => `\x1b[2m${s}\x1b[0m`,
15
+ italic: (s) => `\x1b[3m${s}\x1b[0m`,
16
+ underline: (s) => `\x1b[4m${s}\x1b[0m`,
17
+ // Background colors
18
+ bgGreen: (s) => `\x1b[42m\x1b[30m${s}\x1b[0m`,
19
+ bgYellow: (s) => `\x1b[43m\x1b[30m${s}\x1b[0m`,
20
+ bgRed: (s) => `\x1b[41m\x1b[37m${s}\x1b[0m`,
21
+ bgCyan: (s) => `\x1b[46m\x1b[30m${s}\x1b[0m`,
22
+ bgBlue: (s) => `\x1b[44m\x1b[37m${s}\x1b[0m`,
23
+ // Compound styles
24
+ success: (s) => `\x1b[32m✓\x1b[0m ${s}`,
25
+ warning: (s) => `\x1b[33m⚠\x1b[0m ${s}`,
26
+ error: (s) => `\x1b[31m✗\x1b[0m ${s}`,
27
+ info: (s) => `\x1b[36mℹ\x1b[0m ${s}`,
13
28
  reset: '\x1b[0m'
14
29
  };
15
30