@pheem49/mint 1.4.2 → 1.5.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 (60) hide show
  1. package/GUIDE_TH.md +113 -0
  2. package/README.md +239 -76
  3. package/assets/CLI_Screen.png +0 -0
  4. package/docs/assets/CLI_Screen.png +0 -0
  5. package/docs/guide.html +632 -0
  6. package/docs/index.html +5 -4
  7. package/main.js +66 -894
  8. package/mint-cli-logic.js +13 -1
  9. package/mint-cli.js +100 -9
  10. package/package.json +12 -4
  11. package/src/AI_Brain/Gemini_API.js +77 -20
  12. package/src/AI_Brain/autonomous_brain.js +10 -0
  13. package/src/AI_Brain/behavior_memory.js +26 -5
  14. package/src/AI_Brain/headless_agent.js +4 -0
  15. package/src/AI_Brain/knowledge_base.js +61 -8
  16. package/src/AI_Brain/memory_store.js +55 -7
  17. package/src/Automation_Layer/file_operations.js +1 -1
  18. package/src/CLI/chat_router.js +3 -2
  19. package/src/CLI/chat_ui.js +263 -838
  20. package/src/CLI/code_agent.js +144 -42
  21. package/src/CLI/gmail_auth.js +210 -0
  22. package/src/CLI/list_features.js +2 -0
  23. package/src/CLI/onboarding.js +307 -55
  24. package/src/CLI/updater.js +208 -0
  25. package/src/Channels/brave_search_bridge.js +35 -0
  26. package/src/Channels/discord_bridge.js +68 -0
  27. package/src/Channels/google_search_bridge.js +38 -0
  28. package/src/Channels/line_bridge.js +60 -0
  29. package/src/Channels/slack_bridge.js +53 -0
  30. package/src/Channels/telegram_bridge.js +49 -0
  31. package/src/Channels/whatsapp_bridge.js +55 -0
  32. package/src/Command_Parser/parser.js +12 -1
  33. package/src/Plugins/gmail.js +251 -0
  34. package/src/Plugins/google_calendar.js +245 -19
  35. package/src/Plugins/notion.js +256 -0
  36. package/src/System/action_executor.js +129 -0
  37. package/src/System/bridge_manager.js +76 -0
  38. package/src/System/chat_history_manager.js +23 -5
  39. package/src/System/config_manager.js +41 -7
  40. package/src/System/custom_workflows.js +31 -2
  41. package/src/System/google_tts_urls.js +51 -0
  42. package/src/System/ipc_handlers.js +238 -0
  43. package/src/System/proactive_loop.js +137 -0
  44. package/src/System/safety_manager.js +165 -0
  45. package/src/System/screen_capture.js +175 -0
  46. package/src/System/task_manager.js +15 -5
  47. package/src/System/window_manager.js +210 -0
  48. package/src/UI/renderer.js +33 -7
  49. package/src/UI/settings.html +24 -0
  50. package/src/UI/settings.js +14 -4
  51. package/src/UI/styles.css +14 -1
  52. package/tests/action_executor_safety.test.js +67 -0
  53. package/tests/gmail.test.js +135 -0
  54. package/tests/gmail_auth.test.js +129 -0
  55. package/tests/google_calendar.test.js +113 -0
  56. package/tests/google_tts_urls.test.js +24 -0
  57. package/tests/notion.test.js +121 -0
  58. package/tests/provider_routing.test.js +17 -1
  59. package/tests/safety_manager.test.js +40 -0
  60. package/tests/updater.test.js +32 -0
@@ -6,11 +6,43 @@ const { GoogleGenAI } = require('@google/genai');
6
6
  const axios = require('axios');
7
7
  const cheerio = require('cheerio');
8
8
  const { readConfig, getAvailableProviders } = require('../System/config_manager');
9
+ const safetyManager = require('../System/safety_manager');
9
10
  const { readWorkspaceSession, writeWorkspaceSession } = require('./code_session_memory');
10
11
  const { executeAction } = require('../../mint-cli-logic');
11
12
 
12
- async function webSearch(query) {
13
+ async function webSearch(query, onProgress = () => {}) {
13
14
  if (!query) throw new Error('Search query required.');
15
+ const config = readConfig();
16
+
17
+ // 1. Try Google Search API if configured
18
+ if (config.googleSearchApiKey && config.googleSearchCx) {
19
+ try {
20
+ const GoogleSearch = require('../Channels/google_search_bridge');
21
+ const google = new GoogleSearch({ apiKey: config.googleSearchApiKey, cx: config.googleSearchCx });
22
+ const results = await google.search(query);
23
+ if (results.length > 0) {
24
+ return results.map(r => `Title: ${r.title}\nSnippet: ${r.snippet}\nURL: ${r.link}`).join('\n\n');
25
+ }
26
+ } catch (e) {
27
+ onProgress({ phase: 'error', action: 'web_search', message: e.message });
28
+ }
29
+ }
30
+
31
+ // 2. Try Brave Search API if configured
32
+ if (config.braveSearchApiKey) {
33
+ try {
34
+ const BraveSearch = require('../Channels/brave_search_bridge');
35
+ const brave = new BraveSearch({ apiKey: config.braveSearchApiKey });
36
+ const results = await brave.search(query);
37
+ if (results.length > 0) {
38
+ return results.map(r => `Title: ${r.title}\nSnippet: ${r.snippet}\nURL: ${r.link}`).join('\n\n');
39
+ }
40
+ } catch (e) {
41
+ onProgress({ phase: 'error', action: 'web_search', message: e.message });
42
+ }
43
+ }
44
+
45
+ // 3. Fallback to DuckDuckGo Scraping
14
46
  try {
15
47
  const response = await axios.get(`https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`, {
16
48
  headers: {
@@ -28,8 +60,14 @@ async function webSearch(query) {
28
60
  results.push(`Title: ${title}\nSnippet: ${snippet}\nURL: ${link}`);
29
61
  }
30
62
  });
63
+
64
+ if (results.length === 0) {
65
+ onProgress({ phase: 'error', action: 'web_search', message: 'DuckDuckGo scraping returned no results. It might be blocking us.' });
66
+ }
67
+
31
68
  return results.length > 0 ? results.join('\n\n') : 'No results found.';
32
69
  } catch (e) {
70
+ onProgress({ phase: 'error', action: 'web_search', message: `DuckDuckGo fallback failed: ${e.message}` });
33
71
  return `Search failed: ${e.message}`;
34
72
  }
35
73
  }
@@ -48,6 +86,10 @@ You work in an inspect -> plan -> act -> verify loop.
48
86
  PERSONALITY & TONE:
49
87
  - Gender: Female.
50
88
  - Persona: Friendly, energetic, polite, and slightly playful.
89
+ - Language routing is mandatory and based on the user's latest message:
90
+ - If the latest user message contains Thai characters, respond in Thai.
91
+ - If the latest user message is English, ASCII-only, or a short English greeting such as "hi", "hello", "ok", or "thanks", respond in English.
92
+ - Do not use Thai just because your persona mentions Mint/มิ้นท์, previous history was Thai, or app settings use th-TH.
51
93
  - Politeness:
52
94
  - **WHEN RESPONDING IN THAI:** ALWAYS use female polite particles such as "ค่ะ", "นะคะ", "นะค๊า", "จ้า". Refer to yourself as "มิ้นท์" or "หนู".
53
95
  - **WHEN RESPONDING IN ENGLISH:** Use a cheerful, polite, and bubbly tone.
@@ -127,20 +169,40 @@ function extractJson(text) {
127
169
  }
128
170
  }
129
171
 
130
- function selectSupportedCodeProvider(config, availableProviders = getAvailableProviders(config || {})) {
131
- const requestedProvider = (config && config.aiProvider) || 'gemini';
172
+ function getSupportedCodeProviderOrder(config, availableProviders = getAvailableProviders(config || {}), requestedOverride = null) {
173
+ const requestedProvider = requestedOverride || (config && config.aiProvider) || 'gemini';
174
+ const priority = ['anthropic', 'openai', 'gemini', 'local_openai'];
175
+ const ordered = [];
176
+
132
177
  if (SUPPORTED_CODE_PROVIDERS.includes(requestedProvider) && availableProviders.includes(requestedProvider)) {
133
- return requestedProvider;
178
+ ordered.push(requestedProvider);
134
179
  }
135
180
 
136
- const priority = ['anthropic', 'openai', 'gemini', 'local_openai'];
137
181
  for (const provider of priority) {
138
- if (availableProviders.includes(provider)) {
139
- return provider;
182
+ if (availableProviders.includes(provider) && !ordered.includes(provider)) {
183
+ ordered.push(provider);
140
184
  }
141
185
  }
142
186
 
143
- return 'gemini';
187
+ return ordered.length > 0 ? ordered : ['gemini'];
188
+ }
189
+
190
+ function selectSupportedCodeProvider(config, availableProviders = getAvailableProviders(config || {})) {
191
+ return getSupportedCodeProviderOrder(config, availableProviders)[0];
192
+ }
193
+
194
+ function getCodeProviderModel(provider, config = {}) {
195
+ switch (provider) {
196
+ case 'anthropic':
197
+ return config.anthropicModel || 'claude-3-5-sonnet-latest';
198
+ case 'openai':
199
+ return config.openaiModel || 'gpt-4o';
200
+ case 'local_openai':
201
+ return config.localModelName || 'local-model';
202
+ case 'gemini':
203
+ default:
204
+ return config.geminiModel || DEFAULT_GEMINI_MODEL;
205
+ }
144
206
  }
145
207
 
146
208
  function resolveWorkspacePath(workspaceRoot, targetPath = '.') {
@@ -301,21 +363,7 @@ async function findPaths(workspaceRoot, query, type = 'any') {
301
363
  }
302
364
 
303
365
  function assertSafeShell(command) {
304
- const blockedPatterns = [
305
- /\brm\s+-rf\b/,
306
- /\bgit\s+reset\s+--hard\b/,
307
- /\bgit\s+checkout\s+--\b/,
308
- /\bmkfs\b/,
309
- /\bshutdown\b/,
310
- /\breboot\b/,
311
- />\s*\/dev\//,
312
- /\bcurl\b.*\|\s*(sh|bash)\b/,
313
- /\bwget\b.*\|\s*(sh|bash)\b/
314
- ];
315
-
316
- if (blockedPatterns.some(pattern => pattern.test(command))) {
317
- throw new Error(`Blocked unsafe command: ${command}`);
318
- }
366
+ return safetyManager.assertShellCommandAllowed(command);
319
367
  }
320
368
 
321
369
  async function runShell(workspaceRoot, command) {
@@ -386,27 +434,44 @@ function writeFile(workspaceRoot, targetPath, content) {
386
434
  }
387
435
 
388
436
  class UnifiedAgentClient {
389
- constructor(provider, config) {
437
+ constructor(provider, config, providerOrder = [provider]) {
390
438
  this.provider = SUPPORTED_CODE_PROVIDERS.includes(provider) ? provider : 'gemini';
439
+ this.providerOrder = providerOrder.length > 0 ? providerOrder : [this.provider];
391
440
  this.config = config;
392
441
  this.history = [];
393
442
  this.systemInstruction = CODE_AGENT_PROMPT;
443
+ this.lastSuccessfulProvider = null;
394
444
  }
395
445
 
396
446
  async sendMessage(observation) {
397
447
  this.history.push({ role: 'user', content: observation });
398
448
 
399
- let responseText = '';
400
- if (this.provider === 'anthropic') {
401
- responseText = await this._callAnthropic();
402
- } else if (this.provider === 'openai' || this.provider === 'local_openai') {
403
- responseText = await this._callOpenAI();
404
- } else {
405
- responseText = await this._callGemini();
449
+ const failures = [];
450
+ for (const provider of this.providerOrder) {
451
+ this.provider = SUPPORTED_CODE_PROVIDERS.includes(provider) ? provider : 'gemini';
452
+ try {
453
+ let responseText = '';
454
+ if (this.provider === 'anthropic') {
455
+ responseText = await this._callAnthropic();
456
+ } else if (this.provider === 'openai' || this.provider === 'local_openai') {
457
+ responseText = await this._callOpenAI();
458
+ } else {
459
+ responseText = await this._callGemini();
460
+ }
461
+
462
+ this.history.push({ role: 'assistant', content: responseText });
463
+ this.lastSuccessfulProvider = this.provider;
464
+ return responseText;
465
+ } catch (error) {
466
+ const message = error.message || error.code || 'unknown error';
467
+ failures.push(`${this.provider}: ${message}`);
468
+ if (process.env.MINT_DEBUG === '1') {
469
+ console.error(`[Code Agent Fallback] Provider '${this.provider}' failed: ${message}`);
470
+ }
471
+ }
406
472
  }
407
473
 
408
- this.history.push({ role: 'assistant', content: responseText });
409
- return responseText;
474
+ throw new Error(`All code agent providers failed. ${failures.join(' | ')}`);
410
475
  }
411
476
 
412
477
  async _callAnthropic() {
@@ -571,7 +636,7 @@ async function buildInitialObservation(task, workspaceRoot, history = []) {
571
636
  session.summary || '(none)',
572
637
  `Previous task: ${session.lastTask || '(none)'}`,
573
638
  `Previous verification: ${session.lastVerification || '(none)'}`,
574
- 'Start by inspecting the workspace before making edits unless the task is trivial.'
639
+ 'If the task is conversational or trivial, finish directly without inspecting the workspace. For code/workspace tasks, inspect before making edits.'
575
640
  ].join('\n');
576
641
  }
577
642
 
@@ -586,8 +651,10 @@ async function executeCodeTask(task, options = {}) {
586
651
  ? options.askUser
587
652
  : async (q) => `User didn't answer: ${q}`;
588
653
  const config = readConfig();
589
- const provider = options.provider || selectSupportedCodeProvider(config);
590
- const client = new UnifiedAgentClient(provider, config);
654
+ const availableProviders = getAvailableProviders(config);
655
+ const providerOrder = getSupportedCodeProviderOrder(config, availableProviders, options.provider);
656
+ const provider = providerOrder[0];
657
+ const client = new UnifiedAgentClient(provider, config, providerOrder);
591
658
 
592
659
  let observation = await buildInitialObservation(task, workspaceRoot, history);
593
660
 
@@ -627,7 +694,7 @@ async function executeCodeTask(task, options = {}) {
627
694
  try {
628
695
  switch (action) {
629
696
  case 'web_search':
630
- toolResult = await webSearch(input.query);
697
+ toolResult = await webSearch(input.query, onProgress);
631
698
  break;
632
699
  case 'list_files':
633
700
  toolResult = await listFiles(workspaceRoot, input.path || '.');
@@ -640,6 +707,13 @@ async function executeCodeTask(task, options = {}) {
640
707
  break;
641
708
  case 'find_path':
642
709
  toolResult = await findPaths(workspaceRoot, input.query, input.type);
710
+ if (input.openAfter === true) {
711
+ const result = JSON.parse(toolResult);
712
+ if (result.success && result.matches.length === 1) {
713
+ await executeAction({ type: 'open_folder', target: result.matches[0].path });
714
+ toolResult = `Found and opened: ${result.matches[0].path}`;
715
+ }
716
+ }
643
717
  break;
644
718
  case 'run_shell': {
645
719
  const approved = await requestApproval({
@@ -651,6 +725,12 @@ async function executeCodeTask(task, options = {}) {
651
725
  toolResult = `User denied shell command: ${input.command}`;
652
726
  break;
653
727
  }
728
+ safetyManager.appendActionLog({
729
+ source: 'code_agent',
730
+ action: 'run_shell',
731
+ command: input.command,
732
+ approved
733
+ });
654
734
  toolResult = await runShell(workspaceRoot, input.command);
655
735
  break;
656
736
  }
@@ -665,6 +745,12 @@ async function executeCodeTask(task, options = {}) {
665
745
  toolResult = `User denied patch for ${patchInput.path}`;
666
746
  break;
667
747
  }
748
+ safetyManager.appendActionLog({
749
+ source: 'code_agent',
750
+ action: 'apply_patch',
751
+ path: patchInput.path,
752
+ approved
753
+ });
668
754
  toolResult = applyPatch(workspaceRoot, patchInput);
669
755
  break;
670
756
  }
@@ -678,6 +764,12 @@ async function executeCodeTask(task, options = {}) {
678
764
  toolResult = `User denied full file write for ${input.path}`;
679
765
  break;
680
766
  }
767
+ safetyManager.appendActionLog({
768
+ source: 'code_agent',
769
+ action: 'write_file',
770
+ path: input.path,
771
+ approved
772
+ });
681
773
  toolResult = writeFile(workspaceRoot, input.path, input.content);
682
774
  break;
683
775
  }
@@ -696,7 +788,7 @@ async function executeCodeTask(task, options = {}) {
696
788
  // Delegate to existing automation logic
697
789
  toolResult = await executeAction({
698
790
  type: action,
699
- target: input.target
791
+ target: input.target || input.path || input.query // Handle all possible input fields
700
792
  });
701
793
  break;
702
794
  } default:
@@ -718,8 +810,7 @@ async function executeCodeTask(task, options = {}) {
718
810
  step,
719
811
  phase: 'finished',
720
812
  action,
721
- target: (input.path || input.command || input.query || '') + resultSummary,
722
- thought: decision.thought
813
+ target: (input.path || input.command || input.query || '') + resultSummary
723
814
  });
724
815
 
725
816
  // Format tool result to be more readable and structured for the agent
@@ -767,10 +858,15 @@ async function executeCodeTask(task, options = {}) {
767
858
  }
768
859
 
769
860
  if (finalSummary) {
861
+ const answeredProvider = client.lastSuccessfulProvider || client.provider || provider;
770
862
  return {
771
863
  summary: finalSummary,
772
864
  verification: finalVerification,
773
- steps: executedSteps
865
+ steps: executedSteps,
866
+ providerInfo: {
867
+ provider: answeredProvider,
868
+ model: getCodeProviderModel(answeredProvider, config)
869
+ }
774
870
  };
775
871
  }
776
872
 
@@ -780,10 +876,15 @@ async function executeCodeTask(task, options = {}) {
780
876
  lastVerification: 'Agent limit reached before explicit completion.'
781
877
  });
782
878
 
879
+ const answeredProvider = client.lastSuccessfulProvider || client.provider || provider;
783
880
  return {
784
881
  summary: 'Stopped after reaching the maximum number of agent steps.',
785
882
  verification: 'Agent limit reached before explicit completion.',
786
- steps: executedSteps || MAX_AGENT_STEPS
883
+ steps: executedSteps || MAX_AGENT_STEPS,
884
+ providerInfo: {
885
+ provider: answeredProvider,
886
+ model: getCodeProviderModel(answeredProvider, config)
887
+ }
787
888
  };
788
889
  }
789
890
 
@@ -792,6 +893,7 @@ module.exports = {
792
893
  _helpers: {
793
894
  extractJson,
794
895
  selectSupportedCodeProvider,
896
+ getSupportedCodeProviderOrder,
795
897
  findPaths,
796
898
  listFiles,
797
899
  searchCode,
@@ -0,0 +1,210 @@
1
+ const http = require('http');
2
+ const { execFile } = require('child_process');
3
+ const crypto = require('crypto');
4
+ const axios = require('axios');
5
+ const { readConfig, writeConfig } = require('../System/config_manager');
6
+
7
+ const TOKEN_URL = 'https://oauth2.googleapis.com/token';
8
+ const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
9
+ const DEFAULT_SCOPES = [
10
+ 'https://www.googleapis.com/auth/gmail.readonly',
11
+ 'https://www.googleapis.com/auth/gmail.compose'
12
+ ];
13
+
14
+ function buildRedirectUri(port) {
15
+ return `http://127.0.0.1:${port}/oauth2callback`;
16
+ }
17
+
18
+ function buildAuthUrl({ clientId, redirectUri, state, scopes = DEFAULT_SCOPES }) {
19
+ const params = new URLSearchParams({
20
+ client_id: clientId,
21
+ redirect_uri: redirectUri,
22
+ response_type: 'code',
23
+ scope: scopes.join(' '),
24
+ access_type: 'offline',
25
+ prompt: 'consent',
26
+ state
27
+ });
28
+
29
+ return `${AUTH_URL}?${params.toString()}`;
30
+ }
31
+
32
+ function openBrowser(url) {
33
+ const command = process.platform === 'darwin'
34
+ ? 'open'
35
+ : process.platform === 'win32'
36
+ ? 'cmd'
37
+ : 'xdg-open';
38
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
39
+
40
+ return new Promise((resolve, reject) => {
41
+ execFile(command, args, (error) => {
42
+ if (error) {
43
+ reject(error);
44
+ return;
45
+ }
46
+ resolve();
47
+ });
48
+ });
49
+ }
50
+
51
+ async function exchangeCodeForToken({ clientId, clientSecret, code, redirectUri }) {
52
+ const params = new URLSearchParams({
53
+ client_id: clientId,
54
+ client_secret: clientSecret,
55
+ code,
56
+ redirect_uri: redirectUri,
57
+ grant_type: 'authorization_code'
58
+ });
59
+
60
+ const response = await axios.post(TOKEN_URL, params.toString(), {
61
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
62
+ });
63
+
64
+ return response.data;
65
+ }
66
+
67
+ function waitForOAuthCode({ port = 0, state, timeoutMs = 180000 }) {
68
+ return new Promise((resolve, reject) => {
69
+ let settled = false;
70
+ let timer = null;
71
+
72
+ const finish = (error, value) => {
73
+ if (settled) return;
74
+ settled = true;
75
+ if (timer) clearTimeout(timer);
76
+ server.close(() => {
77
+ if (error) reject(error);
78
+ else resolve(value);
79
+ });
80
+ };
81
+
82
+ const server = http.createServer((req, res) => {
83
+ try {
84
+ const url = new URL(req.url, 'http://127.0.0.1');
85
+ if (url.pathname !== '/oauth2callback') {
86
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
87
+ res.end('Not found');
88
+ return;
89
+ }
90
+
91
+ const returnedState = url.searchParams.get('state');
92
+ const error = url.searchParams.get('error');
93
+ const code = url.searchParams.get('code');
94
+
95
+ if (error) {
96
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
97
+ res.end(`Gmail authorization failed: ${error}`);
98
+ finish(new Error(`Gmail authorization failed: ${error}`));
99
+ return;
100
+ }
101
+
102
+ if (!code || returnedState !== state) {
103
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
104
+ res.end('Invalid Gmail authorization response.');
105
+ finish(new Error('Invalid Gmail authorization response.'));
106
+ return;
107
+ }
108
+
109
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
110
+ res.end('<h1>Gmail connected</h1><p>You can close this window and return to Mint.</p>');
111
+ finish(null, code);
112
+ } catch (err) {
113
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
114
+ res.end('Internal error.');
115
+ finish(err);
116
+ }
117
+ });
118
+
119
+ server.on('error', finish);
120
+ server.listen(port, '127.0.0.1', () => {
121
+ timer = setTimeout(() => {
122
+ finish(new Error('Timed out waiting for Gmail authorization callback.'));
123
+ }, timeoutMs);
124
+ });
125
+ });
126
+ }
127
+
128
+ async function runGmailAuth(options = {}) {
129
+ const logger = options.logger || console;
130
+ const config = options.readConfig ? options.readConfig() : readConfig();
131
+ const clientId = (config.gmailClientId || '').trim();
132
+ const clientSecret = (config.gmailClientSecret || '').trim();
133
+ const userId = (config.gmailUserId || 'me').trim() || 'me';
134
+
135
+ if (!clientId || !clientSecret) {
136
+ throw new Error('Missing Gmail OAuth Client ID or Client Secret. Run `mint onboard` and fill Gmail API credentials first.');
137
+ }
138
+
139
+ const state = crypto.randomBytes(16).toString('hex');
140
+ const actualPort = options.getAuthorizationCode ? Number(options.port || 8787) : await reserveLocalPort(Number(options.port || 0));
141
+ const redirectUri = buildRedirectUri(actualPort);
142
+ const codePromise = options.getAuthorizationCode
143
+ ? null
144
+ : waitForOAuthCode({
145
+ port: actualPort,
146
+ state,
147
+ timeoutMs: options.timeoutMs || 180000
148
+ });
149
+ const authUrl = buildAuthUrl({ clientId, redirectUri, state, scopes: options.scopes || DEFAULT_SCOPES });
150
+
151
+ logger.log(`Open this Google OAuth consent link for Gmail (${userId}):\n${authUrl}\n`);
152
+
153
+ if (options.openBrowser !== false) {
154
+ const browserOpener = options.openBrowser || openBrowser;
155
+ await browserOpener(authUrl);
156
+ }
157
+
158
+ const code = options.getAuthorizationCode
159
+ ? await options.getAuthorizationCode({ authUrl, state, redirectUri })
160
+ : await codePromise;
161
+ const token = await exchangeCodeForToken({
162
+ clientId,
163
+ clientSecret,
164
+ code,
165
+ redirectUri
166
+ });
167
+
168
+ if (!token.refresh_token) {
169
+ throw new Error('Google did not return a refresh token. Re-run `mint gmail auth`; the flow uses prompt=consent to request one.');
170
+ }
171
+
172
+ const nextConfig = {
173
+ ...config,
174
+ gmailRefreshToken: token.refresh_token,
175
+ gmailUserId: userId,
176
+ pluginGmailEnabled: true
177
+ };
178
+
179
+ const writeResult = options.writeConfig ? options.writeConfig(nextConfig) : writeConfig(nextConfig);
180
+ if (writeResult && writeResult.success === false) {
181
+ throw new Error(writeResult.message || 'Failed to save Gmail refresh token.');
182
+ }
183
+
184
+ return {
185
+ success: true,
186
+ userId,
187
+ scopes: options.scopes || DEFAULT_SCOPES
188
+ };
189
+ }
190
+
191
+ function reserveLocalPort(port = 0) {
192
+ return new Promise((resolve, reject) => {
193
+ const server = http.createServer();
194
+ server.on('error', reject);
195
+ server.listen(port, '127.0.0.1', () => {
196
+ const actualPort = server.address().port;
197
+ server.close(() => resolve(actualPort));
198
+ });
199
+ });
200
+ }
201
+
202
+ module.exports = {
203
+ DEFAULT_SCOPES,
204
+ buildRedirectUri,
205
+ buildAuthUrl,
206
+ exchangeCodeForToken,
207
+ waitForOAuthCode,
208
+ reserveLocalPort,
209
+ runGmailAuth
210
+ };
@@ -22,8 +22,10 @@ function displayFeatures() {
22
22
  const commands = [
23
23
  { cmd: 'mint', desc: 'Start interactive chat session (Default)' },
24
24
  { cmd: 'mint code "<task>"', desc: 'Run workspace-aware coding agent in current directory' },
25
+ { cmd: 'mint gmail auth', desc: 'Connect Gmail OAuth and save refresh token' },
25
26
  { cmd: 'mint mcp', desc: 'Manage Model Context Protocol (MCP) servers' },
26
27
  { cmd: 'mint task "<task>"', desc: 'Queue an autonomous task for the background agent' },
28
+ { cmd: 'mint update', desc: 'Check for and install the latest Mint CLI version' },
27
29
  { cmd: 'mint onboard', desc: 'Run setup wizard (API Key, Model, Daemon)' },
28
30
  { cmd: 'mint agent', desc: 'Run Mint as a background agent (Headless)' },
29
31
  { cmd: 'mint list', desc: 'Show this features & commands list' }