maiass 5.7.31

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.
package/lib/commit.js ADDED
@@ -0,0 +1,885 @@
1
+ // Commit functionality for MAIASS - port of maiass.sh commit behavior
2
+ import { execSync } from 'child_process';
3
+ import { log } from './logger.js';
4
+ import { SYMBOLS } from './symbols.js';
5
+ import { getGitInfo, getGitStatus } from './git-info.js';
6
+ import readline from 'readline';
7
+ import { loadEnvironmentConfig } from './config.js';
8
+ import { generateMachineFingerprint } from './machine-fingerprint.js';
9
+ import { storeSecureVariable, retrieveSecureVariable } from './secure-storage.js';
10
+ import { getSingleCharInput, getMultiLineInput } from './input-utils.js';
11
+ import { logCommit } from './devlog.js';
12
+ import colors from './colors.js';
13
+ import chalk from 'chalk';
14
+
15
+ /**
16
+ * Get color for credit display based on remaining credits (matches bashmaiass)
17
+ * @param {number} credits - Remaining credits
18
+ * @returns {Function} Chalk color function
19
+ */
20
+ function getCreditColor(credits) {
21
+ if (credits >= 1000) {
22
+ return chalk.hex('#00AA00'); // Green
23
+ } else if (credits > 600) {
24
+ // Interpolate green to yellow
25
+ const ratio = (credits - 600) / 400;
26
+ const r = Math.round(0 + (255 - 0) * (1 - ratio));
27
+ const g = Math.round(170 + (255 - 170) * (1 - ratio));
28
+ return chalk.hex(`#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}00`);
29
+ } else if (credits > 300) {
30
+ // Interpolate yellow to orange
31
+ const ratio = (credits - 300) / 300;
32
+ const g = Math.round(165 + (255 - 165) * ratio);
33
+ return chalk.hex(`#FF${g.toString(16).padStart(2, '0')}00`);
34
+ } else {
35
+ // Interpolate orange to red
36
+ const ratio = credits / 300;
37
+ const g = Math.round(0 + 165 * ratio);
38
+ return chalk.hex(`#FF${g.toString(16).padStart(2, '0')}00`);
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Print gradient line (matches bashmaiass)
44
+ * Gradient from soft pink (#f7b2c4) to burgundy (#6b0022)
45
+ * @param {number} length - Length of line
46
+ */
47
+ function printGradientLine(length = 50) {
48
+ // Create gradient from soft pink to burgundy
49
+ const startColor = { r: 0xf7, g: 0xb2, b: 0xc4 }; // soft pink
50
+ const endColor = { r: 0x6b, g: 0x00, b: 0x22 }; // burgundy
51
+
52
+ let line = '';
53
+ for (let i = 0; i < length; i++) {
54
+ const ratio = length > 1 ? i / (length - 1) : 0;
55
+ const r = Math.round(startColor.r + (endColor.r - startColor.r) * ratio);
56
+ const g = Math.round(startColor.g + (endColor.g - startColor.g) * ratio);
57
+ const b = Math.round(startColor.b + (endColor.b - startColor.b) * ratio);
58
+ line += chalk.hex(`#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`)('═');
59
+ }
60
+ console.log(line);
61
+ }
62
+
63
+ /**
64
+ * Execute git command with proper error handling
65
+ * @param {string} command - Git command to execute
66
+ * @param {boolean} silent - Whether to suppress output
67
+ * @returns {string|null} Command output or null if failed
68
+ */
69
+ function executeGitCommand(command, silent = false) {
70
+ try {
71
+ const result = execSync(command, {
72
+ encoding: 'utf8',
73
+ stdio: silent ? 'pipe' : 'inherit'
74
+ });
75
+ return typeof result === 'string' ? result.trim() : '';
76
+ } catch (error) {
77
+ if (!silent) {
78
+ log.error(SYMBOLS.CROSS, `Git command failed: ${command}\n${error.message}`);
79
+ }
80
+ return null;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Check if remote exists
86
+ * @param {string} remoteName - Name of remote (default: origin)
87
+ * @returns {boolean} True if remote exists
88
+ */
89
+ function remoteExists(remoteName = 'origin') {
90
+ const result = executeGitCommand(`git remote get-url ${remoteName}`, true);
91
+ return result !== null;
92
+ }
93
+
94
+ /**
95
+ * Create anonymous subscription automatically if no API key exists
96
+ * @returns {Promise<string|null>} API key or null if failed
97
+ */
98
+ async function createAnonymousSubscriptionIfNeeded() {
99
+ const debugMode = process.env.MAIASS_DEBUG === 'true';
100
+
101
+ try {
102
+ // Check if we already have a token in environment (from secure storage or config files)
103
+ if (process.env.MAIASS_AI_TOKEN) {
104
+ if (debugMode) {
105
+ log.debug(SYMBOLS.INFO, '[MAIASS DEBUG] Using existing MAIASS_AI_TOKEN from environment');
106
+ }
107
+ return process.env.MAIASS_AI_TOKEN;
108
+ }
109
+
110
+ // Check if we have an anonymous token in secure storage
111
+ const existingToken = retrieveSecureVariable('MAIASS_AI_TOKEN');
112
+ if (existingToken) {
113
+ if (debugMode) {
114
+ log.debug(SYMBOLS.INFO, '[MAIASS DEBUG] Using existing anonymous token from secure storage');
115
+ }
116
+ // Set in environment for this session
117
+ process.env.MAIASS_AI_TOKEN = existingToken;
118
+ return existingToken;
119
+ }
120
+
121
+ log.info(SYMBOLS.INFO, 'No AI API key found. Creating anonymous subscription...');
122
+
123
+ const machineFingerprint = generateMachineFingerprint();
124
+ const endpoint = process.env.MAIASS_AI_HOST || 'https://pound.maiass.net';
125
+
126
+ if (debugMode) {
127
+ log.debug(SYMBOLS.INFO, `[MAIASS DEBUG] Creating anonymous subscription at: ${endpoint}/v1/token`);
128
+ log.debug(SYMBOLS.INFO, `[MAIASS DEBUG] Machine fingerprint: ${JSON.stringify(machineFingerprint, null, 2)}`);
129
+ }
130
+
131
+ const response = await fetch(`${endpoint}/v1/token`, {
132
+ method: 'POST',
133
+ headers: {
134
+ 'Content-Type': 'application/json',
135
+ 'X-Client-Name': 'nodemaiass',
136
+ 'X-Client-Version': '5.7.5'
137
+ },
138
+ body: JSON.stringify({
139
+ machine_fingerprint: machineFingerprint
140
+ })
141
+ });
142
+
143
+ if (!response.ok) {
144
+ const errorText = await response.text();
145
+ let errorData = {};
146
+ try {
147
+ errorData = JSON.parse(errorText);
148
+ } catch (e) {
149
+ errorData = { error: errorText };
150
+ }
151
+
152
+ if (debugMode) {
153
+ log.debug(SYMBOLS.WARNING, `[MAIASS DEBUG] Anonymous subscription failed: ${response.status} ${response.statusText}`);
154
+ log.debug(SYMBOLS.WARNING, `[MAIASS DEBUG] Error response: ${errorText}`);
155
+ }
156
+
157
+ log.error(SYMBOLS.CROSS, `Failed to create anonymous subscription: ${errorData.error || response.statusText}`);
158
+ return null;
159
+ }
160
+
161
+ const data = await response.json();
162
+
163
+ if (debugMode) {
164
+ log.debug(SYMBOLS.INFO, `[MAIASS DEBUG] Anonymous subscription response: ${JSON.stringify(data, null, 2)}`);
165
+ }
166
+
167
+ // Extract fields - try multiple field names for compatibility
168
+ const apiKey = data.apiKey || data.api_key || data.token;
169
+ const subscriptionId = data.id || data.subscription_id;
170
+ const credits = data.creditsRemaining || data.credits_remaining || data.credits;
171
+ const purchaseUrl = data.purchaseUrl || data.payment_url || data.top_up_url;
172
+
173
+ if (apiKey) {
174
+ // Store the token in secure storage
175
+ const stored = storeSecureVariable('MAIASS_AI_TOKEN', apiKey);
176
+
177
+ if (subscriptionId) {
178
+ storeSecureVariable('MAIASS_SUBSCRIPTION_ID', subscriptionId);
179
+ }
180
+
181
+ if (stored) {
182
+ log.success(SYMBOLS.CHECKMARK, 'Anonymous subscription created and stored securely');
183
+ log.info(SYMBOLS.INFO, ` API Key: ${apiKey.substring(0, 8)}...`);
184
+ log.info(SYMBOLS.INFO, ` Credits: ${credits || 'N/A'}`);
185
+
186
+ if (subscriptionId) {
187
+ log.info(SYMBOLS.INFO, ` Subscription ID: ${subscriptionId.substring(0, 12)}...`);
188
+ }
189
+
190
+ // Warn if zero credits
191
+ if (!credits || credits === 0) {
192
+ log.warning(SYMBOLS.WARNING, '⚠️ Your anonymous API key has zero credits. Please purchase credits to use AI features.');
193
+ if (purchaseUrl) {
194
+ log.info(SYMBOLS.INFO, ` Purchase credits here: ${purchaseUrl}`);
195
+ }
196
+ }
197
+ } else {
198
+ log.warning(SYMBOLS.WARNING, 'Anonymous subscription created but could not store securely');
199
+ }
200
+
201
+ // Set in environment for this session
202
+ process.env.MAIASS_AI_TOKEN = apiKey;
203
+ if (subscriptionId) {
204
+ process.env.MAIASS_SUBSCRIPTION_ID = subscriptionId;
205
+ }
206
+
207
+ return apiKey;
208
+ }
209
+
210
+ return null;
211
+
212
+ } catch (error) {
213
+ log.error(SYMBOLS.CROSS, `Failed to create anonymous subscription: ${error.message}`);
214
+ return null;
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Get AI commit message suggestion
220
+ * @param {Object} gitInfo - Git information object
221
+ * @returns {Promise<string|null>} AI suggested commit message or null
222
+ */
223
+ async function getAICommitSuggestion(gitInfo) {
224
+ const config = loadEnvironmentConfig();
225
+
226
+ // Enhanced debug logging: output the diff and parameters when debug or verbose
227
+ const debugMode = process.env.MAIASS_DEBUG === 'true' || (process.env.MAIASS_VERBOSITY && ['debug','verbose'].includes(process.env.MAIASS_VERBOSITY));
228
+
229
+ if (debugMode) {
230
+ log.debug(SYMBOLS.INFO, '[MAIASS DEBUG] --- AI Commit Suggestion Starting ---');
231
+ }
232
+
233
+ // Try to get existing token or create anonymous subscription
234
+ let maiassToken = await createAnonymousSubscriptionIfNeeded();
235
+
236
+ const aiHost = process.env.MAIASS_AI_HOST || 'https://pound.maiass.net';
237
+ const aiEndpoint = aiHost + '/proxy';
238
+ const aiModel = process.env.MAIASS_AI_MODEL || 'gpt-3.5-turbo';
239
+ const aiTemperature = parseFloat(process.env.MAIASS_AI_TEMPERATURE || '0.7');
240
+ const commitMessageStyle = process.env.MAIASS_AI_COMMIT_MESSAGE_STYLE || 'bullet';
241
+ const maxCharacters = parseInt(process.env.MAIASS_AI_MAX_CHARACTERS || '8000');
242
+
243
+ if (debugMode) {
244
+ log.debug(SYMBOLS.INFO, `[MAIASS DEBUG] AI Host: ${aiHost}`);
245
+ log.debug(SYMBOLS.INFO, `[MAIASS DEBUG] AI Endpoint: ${aiEndpoint}`);
246
+ log.debug(SYMBOLS.INFO, `[MAIASS DEBUG] Token available: ${maiassToken ? 'Yes' : 'No'}`);
247
+ if (maiassToken) {
248
+ log.debug(SYMBOLS.INFO, `[MAIASS DEBUG] Token preview: ${maiassToken.substring(0, 8)}...`);
249
+ }
250
+ }
251
+
252
+ if (!maiassToken) {
253
+ if (debugMode) {
254
+ log.debug(SYMBOLS.WARNING, '[MAIASS DEBUG] No AI token available - cannot proceed with AI suggestion');
255
+ }
256
+ return null;
257
+ }
258
+
259
+ try {
260
+ // Get changelog filenames from environment or use defaults
261
+ const changelogName = process.env.MAIASS_CHANGELOG_NAME || 'CHANGELOG.md';
262
+ const internalChangelogName = process.env.MAIASS_CHANGELOG_INTERNAL_NAME || '.CHANGELOG_internal.md';
263
+
264
+ // Build git diff command - first try with exclusions, then fallback to all files
265
+ const gitDiffCommand = `git diff --cached`;
266
+
267
+ if (debugMode) {
268
+ log.debug(SYMBOLS.INFO, `[MAIASS DEBUG] Git diff command: ${gitDiffCommand}`);
269
+ }
270
+
271
+ // Get git diff for staged changes
272
+ let gitDiff = executeGitCommand(gitDiffCommand, true);
273
+
274
+ if (debugMode) {
275
+ log.debug(SYMBOLS.INFO, `[MAIASS DEBUG] Raw git diff length: ${gitDiff ? gitDiff.length : 0} characters`);
276
+ }
277
+
278
+ // If we have a diff, filter out changelog content manually
279
+ if (gitDiff) {
280
+ // Split diff into file sections and filter out changelog files
281
+ const diffSections = gitDiff.split(/^diff --git /m);
282
+ const filteredSections = diffSections.filter(section => {
283
+ if (!section.trim()) return false;
284
+ // Check if this section is for a changelog file
285
+ const isChangelog = section.includes(`a/${changelogName}`) ||
286
+ section.includes(`b/${changelogName}`) ||
287
+ section.includes(`a/${internalChangelogName}`) ||
288
+ section.includes(`b/${internalChangelogName}`);
289
+ return !isChangelog;
290
+ });
291
+
292
+ // Reconstruct the diff
293
+ if (filteredSections.length > 1) {
294
+ gitDiff = 'diff --git ' + filteredSections.slice(1).join('diff --git ');
295
+ } else if (filteredSections.length === 1 && filteredSections[0].trim()) {
296
+ gitDiff = filteredSections[0];
297
+ } else {
298
+ gitDiff = '';
299
+ }
300
+
301
+ if (debugMode) {
302
+ log.debug(SYMBOLS.INFO, `[MAIASS DEBUG] Filtered git diff length: ${gitDiff.length} characters`);
303
+ log.debug(SYMBOLS.INFO, `[MAIASS DEBUG] Excluded changelog files: ${changelogName}, ${internalChangelogName}`);
304
+ }
305
+ }
306
+ if (!gitDiff) {
307
+ if (debugMode) {
308
+ log.debug(SYMBOLS.WARNING, '[MAIASS DEBUG] No git diff found for staged changes - cannot generate AI suggestion');
309
+ }
310
+ return null;
311
+ }
312
+
313
+ // Truncate git diff if it exceeds character limit (matching bash script behavior)
314
+ if (gitDiff.length > maxCharacters) {
315
+ gitDiff = gitDiff.substring(0, maxCharacters) + '...[truncated]';
316
+ log.info(SYMBOLS.INFO, `Git diff truncated to ${maxCharacters} characters`);
317
+ }
318
+
319
+ if (debugMode) {
320
+ log.debug(SYMBOLS.INFO, '[MAIASS DEBUG] --- AI Commit Suggestion Context ---');
321
+ log.debug(SYMBOLS.INFO, `[MAIASS DEBUG] Model: ${aiModel}`);
322
+ log.debug(SYMBOLS.INFO, `[MAIASS DEBUG] Temperature: ${aiTemperature}`);
323
+ log.debug(SYMBOLS.INFO, `[MAIASS DEBUG] Commit Message Style: ${commitMessageStyle}`);
324
+ log.debug(SYMBOLS.INFO, `[MAIASS DEBUG] Max Characters: ${maxCharacters}`);
325
+ log.debug(SYMBOLS.INFO, `[MAIASS DEBUG] Git diff length: ${gitDiff.length} characters`);
326
+ log.debug(SYMBOLS.INFO, '[MAIASS DEBUG] --- GIT DIFF FED TO AI ---');
327
+ log.debug(SYMBOLS.INFO, gitDiff);
328
+ }
329
+
330
+ // Create AI prompt based on commit message style
331
+ let prompt;
332
+ if (commitMessageStyle === 'bullet') {
333
+ prompt = `Analyze the following git diff and create a commit message with bullet points. Format as:
334
+ Brief summary title
335
+ - feat: add user authentication
336
+ - fix(api): resolve syntax error
337
+ - docs: update README
338
+
339
+ Use past tense verbs. No blank line between title and bullets. Keep concise. Do not wrap the response in quotes.
340
+
341
+ Git diff:
342
+ ${gitDiff}`;
343
+ } else {
344
+ prompt = `Analyze the following git diff and create a concise, descriptive commit message. Use conventional commit format when appropriate (feat:, fix:, docs:, etc.). Keep it under 72 characters for the first line.
345
+
346
+ Git diff:
347
+ ${gitDiff}`;
348
+ }
349
+
350
+ // Make API request
351
+ const requestBody = {
352
+ model: aiModel,
353
+ messages: [
354
+ {
355
+ role: 'system',
356
+ content: 'You are a helpful assistant that writes concise, descriptive git commit messages based on code changes.'
357
+ },
358
+ {
359
+ role: 'user',
360
+ content: prompt
361
+ }
362
+ ],
363
+ max_tokens: 150,
364
+ temperature: aiTemperature
365
+ };
366
+
367
+ if (debugMode) {
368
+ log.debug(SYMBOLS.INFO, '[MAIASS DEBUG] --- Making AI API Request ---');
369
+ log.debug(SYMBOLS.INFO, `[MAIASS DEBUG] Request URL: ${aiEndpoint}`);
370
+ log.debug(SYMBOLS.INFO, `[MAIASS DEBUG] Request body: ${JSON.stringify(requestBody, null, 2)}`);
371
+ }
372
+
373
+ const response = await fetch(aiEndpoint, {
374
+ method: 'POST',
375
+ headers: {
376
+ 'Content-Type': 'application/json',
377
+ 'Authorization': `Bearer ${maiassToken}`,
378
+ 'X-Machine-Fingerprint': generateMachineFingerprint(),
379
+ 'X-Client-Name': 'nodemaiass',
380
+ 'X-Client-Version': '5.7.5',
381
+ 'X-Subscription-ID': process.env.MAIASS_SUBSCRIPTION_ID || ''
382
+ },
383
+ body: JSON.stringify(requestBody)
384
+ });
385
+
386
+ if (debugMode) {
387
+ log.debug(SYMBOLS.INFO, `[MAIASS DEBUG] Response status: ${response.status} ${response.statusText}`);
388
+ log.debug(SYMBOLS.INFO, `[MAIASS DEBUG] Response headers: ${JSON.stringify(Object.fromEntries(response.headers), null, 2)}`);
389
+ }
390
+
391
+ if (!response.ok) {
392
+ const errorText = await response.text();
393
+ if (debugMode) {
394
+ log.debug(SYMBOLS.WARNING, `[MAIASS DEBUG] Response error body: ${errorText}`);
395
+ }
396
+ log.error(SYMBOLS.WARNING, `AI API request failed: ${response.status} ${response.statusText}`);
397
+ return null;
398
+ }
399
+
400
+ const data = await response.json();
401
+
402
+ if (debugMode) {
403
+ log.debug(SYMBOLS.INFO, `[MAIASS DEBUG] Response data: ${JSON.stringify(data, null, 2)}`);
404
+ }
405
+
406
+ if (data.choices && data.choices.length > 0) {
407
+ let suggestion = data.choices[0].message.content.trim();
408
+
409
+ // Enhanced debug logging: output the suggestion returned by AI
410
+ if (debugMode) {
411
+ log.debug(SYMBOLS.INFO, '[MAIASS DEBUG] --- AI SUGGESTION RETURNED ---');
412
+ log.debug(SYMBOLS.INFO, suggestion);
413
+ }
414
+
415
+ // Clean up any quotes that might wrap the entire response
416
+ if ((suggestion.startsWith("'") && suggestion.endsWith("'")) ||
417
+ (suggestion.startsWith('"') && suggestion.endsWith('"'))) {
418
+ suggestion = suggestion.slice(1, -1).trim();
419
+ }
420
+
421
+ // Extract credit information from billing data
422
+ let creditsUsed, creditsRemaining;
423
+ if (data.billing) {
424
+ creditsUsed = data.billing.credits_used;
425
+ creditsRemaining = data.billing.credits_remaining;
426
+
427
+ // Show warnings if available
428
+ if (data.billing.warning) {
429
+ log.warning(SYMBOLS.WARNING, data.billing.warning);
430
+ }
431
+
432
+ // Display proxy messages if available
433
+ if (data.messages && Array.isArray(data.messages)) {
434
+ data.messages.forEach(message => {
435
+ const icon = message.icon || '';
436
+ const text = message.text || '';
437
+
438
+ switch (message.type) {
439
+ case 'error':
440
+ log.error(icon, text);
441
+ break;
442
+ case 'warning':
443
+ log.warning(icon, text);
444
+ break;
445
+ case 'info':
446
+ log.info(icon, text);
447
+ break;
448
+ case 'notice':
449
+ log.blue(icon, text);
450
+ break;
451
+ case 'success':
452
+ log.success(icon, text);
453
+ break;
454
+ default:
455
+ log.plain(`${icon} ${text}`);
456
+ }
457
+ });
458
+ }
459
+ } else if (data.usage) {
460
+ // Fallback to legacy token display
461
+ const totalTokens = data.usage.total_tokens || 0;
462
+ const promptTokens = data.usage.prompt_tokens || 0;
463
+ const completionTokens = data.usage.completion_tokens || 0;
464
+ log.info(SYMBOLS.INFO, `Total Tokens: ${totalTokens} (${promptTokens} + ${completionTokens})`);
465
+ }
466
+
467
+ return {
468
+ suggestion,
469
+ creditsUsed,
470
+ creditsRemaining
471
+ };
472
+ }
473
+
474
+ if (debugMode) {
475
+ log.debug(SYMBOLS.WARNING, '[MAIASS DEBUG] No valid AI response received - no choices in response data');
476
+ }
477
+ return null;
478
+ } catch (error) {
479
+ if (debugMode) {
480
+ log.debug(SYMBOLS.WARNING, `[MAIASS DEBUG] AI suggestion error details: ${error.stack || error.message}`);
481
+ }
482
+ log.error(SYMBOLS.WARNING, `AI suggestion failed: ${error.message}`);
483
+ return null;
484
+ }
485
+ }
486
+
487
+ /**
488
+ * Get user input using readline
489
+ * @param {string} prompt - Prompt to display
490
+ * @returns {Promise<string>} User input
491
+ */
492
+ function getUserInput(prompt) {
493
+ const rl = readline.createInterface({
494
+ input: process.stdin,
495
+ output: process.stdout
496
+ });
497
+
498
+ return new Promise((resolve) => {
499
+ rl.on('SIGINT', () => {
500
+ rl.close();
501
+ log.warning(SYMBOLS.WARNING, 'Operation cancelled by user (Ctrl+C)');
502
+ process.exit(0);
503
+ });
504
+
505
+ rl.question(prompt, (answer) => {
506
+ rl.close();
507
+ resolve(answer.trim());
508
+ });
509
+ });
510
+ }
511
+
512
+ /**
513
+ * Get multi-line commit message from user
514
+ * @param {string} jiraTicket - JIRA ticket number to prepend
515
+ * @returns {Promise<string>} Multi-line commit message
516
+ */
517
+ async function getMultiLineCommitMessage(jiraTicket) {
518
+ const prompt = 'Enter multiple lines (press Enter three times to finish):';
519
+ return await getMultiLineInput(prompt);
520
+ }
521
+
522
+ /**
523
+ * Get commit message with AI integration and JIRA ticket handling
524
+ * @param {Object} gitInfo - Git information object
525
+ * @param {Object} options - Commit options
526
+ * @returns {Promise<string>} Final commit message
527
+ */
528
+ async function getCommitMessage(gitInfo, options = {}) {
529
+ const { silent = false } = options;
530
+
531
+ // Load environment config to ensure tokens are loaded from secure storage
532
+ loadEnvironmentConfig();
533
+
534
+ const aiMode = process.env.MAIASS_AI_MODE || 'ask';
535
+ const maiassToken = process.env.MAIASS_AI_TOKEN;
536
+ const jiraTicket = gitInfo.jiraTicket;
537
+
538
+ // Display JIRA ticket if found
539
+ if (jiraTicket) {
540
+ log.info(SYMBOLS.INFO, `Jira Ticket Number: ${jiraTicket}`);
541
+ }
542
+
543
+ let useAI = false;
544
+ let aiSuggestion = null;
545
+
546
+ // Handle AI commit message modes
547
+ switch (aiMode) {
548
+ case 'ask':
549
+ if (maiassToken) {
550
+ let reply;
551
+ if (silent) {
552
+ log.info('', 'Would you like to use AI to suggest a commit message? [y/N] y');
553
+ reply = 'y';
554
+ } else {
555
+ reply = await getSingleCharInput('Would you like to use AI to suggest a commit message? [y/N] ');
556
+ }
557
+ useAI = reply === 'y';
558
+ } else {
559
+ // No token found - try to create anonymous subscription (matches bashmaiass behavior)
560
+ const debugMode = process.env.MAIASS_DEBUG === 'true';
561
+ if (debugMode) {
562
+ log.debug(SYMBOLS.INFO, '[MAIASS DEBUG] No AI token found, attempting to create anonymous subscription...');
563
+ }
564
+ const anonToken = await createAnonymousSubscriptionIfNeeded();
565
+ if (anonToken) {
566
+ // Token created successfully, now ask if they want to use AI
567
+ let reply;
568
+ if (silent) {
569
+ log.info('', 'Would you like to use AI to suggest a commit message? [y/N] y');
570
+ reply = 'y';
571
+ } else {
572
+ reply = await getSingleCharInput('Would you like to use AI to suggest a commit message? [y/N] ');
573
+ }
574
+ useAI = reply === 'y';
575
+ } else {
576
+ log.warning(SYMBOLS.WARNING, 'Failed to create anonymous subscription. AI features will be disabled.');
577
+ }
578
+ }
579
+ break;
580
+ case 'autosuggest':
581
+ if (maiassToken) {
582
+ useAI = true;
583
+ }
584
+ break;
585
+ case 'off':
586
+ default:
587
+ useAI = false;
588
+ break;
589
+ }
590
+
591
+ // Try to get AI suggestion if requested
592
+ if (useAI) {
593
+ const aiResult = await getAICommitSuggestion(gitInfo);
594
+
595
+ if (aiResult && aiResult.suggestion) {
596
+ aiSuggestion = aiResult.suggestion;
597
+
598
+ // Display credits used and remaining (matching bashmaiass)
599
+ if (aiResult.creditsUsed !== undefined) {
600
+ log.branded(`Credits used: ${aiResult.creditsUsed}`, colors.White);
601
+ }
602
+ if (aiResult.creditsRemaining !== undefined) {
603
+ const creditColor = getCreditColor(aiResult.creditsRemaining);
604
+ console.log(`${colors.BSoftPink('|))')} ${creditColor(`${aiResult.creditsRemaining} Credits left`)}`);
605
+ }
606
+
607
+ // Display AI suggestion with gradient lines (matching bashmaiass)
608
+ console.log(`${colors.BSoftPink('|))')} ${colors.BMagenta('Commit message suggestion from MAIASS:')}`);
609
+ printGradientLine(60);
610
+ console.log(chalk.bold.inverse(aiSuggestion));
611
+ printGradientLine(60);
612
+
613
+ let reply;
614
+ if (silent) {
615
+ log.info('', 'Use this AI suggestion? [Y/n/e=edit] Y');
616
+ reply = 'Y';
617
+ } else {
618
+ reply = await getSingleCharInput('Use this AI suggestion? [Y/n/e=edit] ');
619
+ }
620
+
621
+ switch (reply) {
622
+ case 'n':
623
+ log.info(SYMBOLS.INFO, 'AI suggestion declined, entering manual mode');
624
+ useAI = false;
625
+ break;
626
+ case 'e':
627
+ log.info(SYMBOLS.INFO, 'Edit mode: You can modify the AI suggestion');
628
+ log.info('', 'Current AI suggestion:');
629
+ log.aisuggestion('', aiSuggestion);
630
+ console.log();
631
+ log.info('', 'Enter your modified commit message (press Enter three times when finished, or just Enter to keep AI suggestion):');
632
+
633
+ const editedMessage = await getMultiLineCommitMessage(jiraTicket);
634
+ const finalEditedMessage = (editedMessage || aiSuggestion).trim();
635
+
636
+ // Prepend JIRA ticket if found and not already present
637
+ if (jiraTicket && finalEditedMessage && !finalEditedMessage.startsWith(jiraTicket)) {
638
+ return `${jiraTicket} ${finalEditedMessage}`;
639
+ }
640
+ return finalEditedMessage;
641
+ case 'y':
642
+ case '':
643
+ // Accept AI suggestion - prepend JIRA ticket if needed and trim whitespace
644
+ const trimmedSuggestion = aiSuggestion.trim();
645
+ if (jiraTicket && trimmedSuggestion && !trimmedSuggestion.startsWith(jiraTicket)) {
646
+ return `${jiraTicket} ${trimmedSuggestion}`;
647
+ }
648
+ return trimmedSuggestion;
649
+ default:
650
+ log.info(SYMBOLS.INFO, `Invalid input '${reply}'. AI suggestion declined, entering manual mode`);
651
+ useAI = false;
652
+ break;
653
+ }
654
+ } else {
655
+ log.warning(SYMBOLS.WARNING, 'AI suggestion failed, falling back to manual entry');
656
+ useAI = false;
657
+ }
658
+ }
659
+
660
+ // Manual commit message entry
661
+ if (jiraTicket) {
662
+ log.info(SYMBOLS.INFO, `Enter a commit message (Jira ticket ${jiraTicket} will be prepended)`);
663
+ } else {
664
+ log.info(SYMBOLS.INFO, 'Enter a commit message (starting with Jira Ticket# when relevant)');
665
+ log.info(SYMBOLS.INFO, 'Please enter a ticket number or \'fix:\' or \'feature:\' or \'devops:\' to start the commit message');
666
+ }
667
+
668
+ const manualMessage = await getMultiLineCommitMessage(jiraTicket);
669
+
670
+ // Prepend JIRA ticket if found and not already present
671
+ if (jiraTicket && manualMessage && !manualMessage.startsWith(jiraTicket)) {
672
+ return `${jiraTicket} ${manualMessage}`;
673
+ }
674
+
675
+ return manualMessage;
676
+ }
677
+
678
+ /**
679
+ * Handle staged commit process
680
+ * @param {Object} gitInfo - Git information object
681
+ * @param {Object} options - Commit options
682
+ * @returns {Promise<boolean>} True if commit was successful
683
+ */
684
+ async function handleStagedCommit(gitInfo, options = {}) {
685
+ const { silent = false } = options;
686
+ // Check if there are actually staged changes
687
+ if (!gitInfo.status || gitInfo.status.stagedCount === 0) {
688
+ log.warning(SYMBOLS.INFO, 'Nothing to commit, working tree clean');
689
+ return true;
690
+ }
691
+
692
+ // Show staged changes
693
+ const stagedOutput = executeGitCommand('git diff --cached --name-status', true);
694
+ if (!stagedOutput) {
695
+ log.warning(SYMBOLS.INFO, 'No staged changes to show');
696
+ return true;
697
+ }
698
+
699
+ log.critical(SYMBOLS.INFO, 'Staged changes detected:');
700
+
701
+ // Display the staged changes
702
+ console.log(stagedOutput);
703
+
704
+ // Get commit message
705
+ const commitMessage = await getCommitMessage(gitInfo, { silent });
706
+ if (!commitMessage) {
707
+ log.error(SYMBOLS.CROSS, 'No commit message provided');
708
+ return false;
709
+ }
710
+
711
+ // Commit changes
712
+ const verbosity = process.env.MAIASS_VERBOSITY || 'brief';
713
+ const quietMode = verbosity !== 'debug';
714
+
715
+ try {
716
+ // Cross-platform commit message handling
717
+ let commitCommand, result;
718
+ // Ensure JIRA ticket is always prepended if present and not already
719
+ let finalCommitMessage = commitMessage;
720
+ const jiraTicket = gitInfo && gitInfo.jiraTicket;
721
+ if (jiraTicket && finalCommitMessage && !finalCommitMessage.startsWith(jiraTicket)) {
722
+ finalCommitMessage = `${jiraTicket} ${finalCommitMessage}`;
723
+ }
724
+ if (process.platform === 'win32') {
725
+ // Write commit message to a temporary file to avoid quoting/newline issues
726
+ const fs = (await import('fs')).default;
727
+ const os = (await import('os')).default;
728
+ const path = (await import('path')).default;
729
+ const tmpFile = path.join(os.tmpdir(), `maiass-commit-msg-${Date.now()}.txt`);
730
+ fs.writeFileSync(tmpFile, finalCommitMessage, { encoding: 'utf8' });
731
+ commitCommand = `git commit -F "${tmpFile}"`;
732
+ result = executeGitCommand(commitCommand, quietMode);
733
+ fs.unlinkSync(tmpFile);
734
+ } else {
735
+ // Use echo/pipe for non-Windows
736
+ commitCommand = `echo ${JSON.stringify(commitMessage)} | git commit -F -`;
737
+ result = executeGitCommand(commitCommand, quietMode);
738
+ }
739
+
740
+ if (result === null) {
741
+ log.error(SYMBOLS.CROSS, 'Commit failed');
742
+ return false;
743
+ }
744
+
745
+ log.success(SYMBOLS.CHECKMARK, 'Changes committed successfully');
746
+
747
+ // Log the commit to devlog.sh (equivalent to logthis in maiass.sh)
748
+ logCommit(commitMessage, gitInfo);
749
+
750
+ // Ask about pushing to remote
751
+ if (remoteExists('origin')) {
752
+ let reply;
753
+ if (silent) {
754
+ // In silent mode, automatically push
755
+ reply = 'y';
756
+ console.log('🔄 |)) Automatically pushing to remote (silent mode)');
757
+ } else {
758
+ reply = await getSingleCharInput('Do you want to push this commit to remote? [y/N] ');
759
+ }
760
+
761
+ if (reply === 'y') {
762
+
763
+ const pushResult = executeGitCommand(`git push --set-upstream origin ${gitInfo.branch}`, false);
764
+ if (pushResult !== null) {
765
+ log.success(SYMBOLS.CHECKMARK, 'Commit pushed.');
766
+ } else {
767
+ log.error(SYMBOLS.CROSS, 'Push failed');
768
+ return false;
769
+ }
770
+ }
771
+ } else {
772
+ log.warning(SYMBOLS.WARNING, 'No remote found.');
773
+ }
774
+
775
+ return true;
776
+ } catch (error) {
777
+ log.error(SYMBOLS.CROSS, `Commit failed: ${error.message}`);
778
+ return false;
779
+ }
780
+ }
781
+
782
+ /**
783
+ * Main commit function - checks for changes and handles commit workflow
784
+ * @param {Object} options - Commit options
785
+ * @returns {Promise<boolean>} True if process completed successfully
786
+ */
787
+ export async function commitThis(options = {}) {
788
+ const { autoStage = false, commitsOnly = false, silent = false } = options;
789
+
790
+ // Get git information
791
+ const gitInfo = getGitInfo();
792
+ if (!gitInfo) {
793
+ log.error(SYMBOLS.CROSS, 'Not in a git repository');
794
+ return false;
795
+ }
796
+
797
+ log.info(SYMBOLS.GEAR, 'Checking for Changes\n');
798
+
799
+ const status = gitInfo.status;
800
+
801
+ // Check if there are any changes
802
+ if (status.isClean) {
803
+ log.success(SYMBOLS.CHECKMARK, 'No changes found. Working directory is clean.');
804
+ if (commitsOnly) {
805
+ log.info('', 'Thank you for using MAIASS.');
806
+ }
807
+ return true;
808
+ }
809
+
810
+ // Display status message matching bashmaiass style
811
+ if (status.unstagedCount > 0 || status.untrackedCount > 0) {
812
+ log.warning(SYMBOLS.WARNING, 'There are unstaged changes in your working directory');
813
+ }
814
+
815
+ // Handle unstaged/untracked changes first
816
+ if (status.unstagedCount > 0 || status.untrackedCount > 0) {
817
+ if (!autoStage) {
818
+ let reply;
819
+ if (silent) {
820
+ // In silent mode, automatically stage
821
+ reply = 'y';
822
+ console.log('🔄 |)) Automatically staging changes (silent mode)');
823
+ } else {
824
+ reply = await getSingleCharInput('Do you want to stage and commit them? [y/N] ');
825
+ }
826
+ if (reply === 'y') {
827
+ // Stage all changes
828
+ const stageResult = executeGitCommand('git add -A', false);
829
+ if (stageResult === null) {
830
+ log.error(SYMBOLS.CROSS, 'Failed to stage changes');
831
+ return false;
832
+ }
833
+
834
+ // Refresh git info to get updated status
835
+ const updatedGitInfo = getGitInfo();
836
+ return await handleStagedCommit(updatedGitInfo, { silent });
837
+ } else {
838
+ // Check if there are staged changes to commit
839
+ if (status.stagedCount > 0) {
840
+ log.info(SYMBOLS.INFO, 'Proceeding with staged changes only');
841
+ return await handleStagedCommit(gitInfo, { silent });
842
+ }
843
+
844
+ // Handle the case where user declined to stage and there are no staged changes
845
+ if (commitsOnly) {
846
+ // In commits-only mode, it's OK to have unstaged changes
847
+ log.info('', 'No changes staged for commit.');
848
+ log.success('', 'Thank you for using MAIASS.');
849
+ return true;
850
+ } else {
851
+ // In pipeline mode, we cannot proceed with unstaged changes
852
+ log.warning('', 'No changes staged for commit.');
853
+ log.error(SYMBOLS.CROSS, 'Cannot proceed on release/changelog pipeline with uncommitted changes');
854
+ log.success('', 'Thank you for using MAIASS.');
855
+ return false;
856
+ }
857
+ }
858
+ } else {
859
+ // Auto-stage all changes
860
+ const stageResult = executeGitCommand('git add -A', false);
861
+ if (stageResult === null) {
862
+ console.log(colors.Red(`${SYMBOLS.CROSS} Failed to stage changes`));
863
+ return false;
864
+ }
865
+
866
+ // Refresh git info and commit
867
+ const updatedGitInfo = getGitInfo();
868
+ return await handleStagedCommit(updatedGitInfo, { silent });
869
+ }
870
+ } else if (status.stagedCount > 0) {
871
+ // Only staged changes present, proceed directly to commit
872
+ return await handleStagedCommit(gitInfo, { silent });
873
+ }
874
+
875
+ return true;
876
+ }
877
+
878
+ // Export individual functions for testing and reuse
879
+ export {
880
+ getCommitMessage,
881
+ handleStagedCommit,
882
+ getAICommitSuggestion,
883
+ executeGitCommand,
884
+ remoteExists
885
+ };