@pheem49/mint 1.3.0 → 1.4.1

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 (38) hide show
  1. package/.codex +0 -0
  2. package/README.md +174 -126
  3. package/main.js +21 -1
  4. package/mint-cli-logic.js +21 -1
  5. package/mint-cli.js +287 -45
  6. package/package.json +13 -2
  7. package/src/AI_Brain/Gemini_API.js +331 -64
  8. package/src/AI_Brain/agent_orchestrator.js +73 -0
  9. package/src/AI_Brain/autonomous_brain.js +2 -0
  10. package/src/AI_Brain/memory_store.js +318 -0
  11. package/src/AI_Brain/proactive_engine.js +2 -8
  12. package/src/Automation_Layer/file_operations.js +123 -4
  13. package/src/Automation_Layer/open_app.js +72 -43
  14. package/src/Automation_Layer/open_website.js +3 -3
  15. package/src/CLI/chat_router.js +57 -9
  16. package/src/CLI/chat_ui.js +117 -11
  17. package/src/CLI/code_agent.js +249 -36
  18. package/src/CLI/onboarding.js +53 -6
  19. package/src/CLI/workspace_manager.js +90 -0
  20. package/src/Plugins/docker.js +12 -10
  21. package/src/Plugins/spotify.js +168 -40
  22. package/src/Plugins/system_monitor.js +72 -0
  23. package/src/System/config_manager.js +35 -2
  24. package/src/System/custom_workflows.js +9 -2
  25. package/src/System/notifications.js +23 -0
  26. package/src/UI/settings.html +143 -65
  27. package/src/UI/settings.js +155 -41
  28. package/tests/agent_orchestrator.test.js +41 -0
  29. package/tests/chat_router.test.js +42 -0
  30. package/tests/code_agent.test.js +69 -0
  31. package/tests/config_manager.test.js +141 -0
  32. package/tests/docker.test.js +46 -0
  33. package/tests/file_operations.test.js +57 -0
  34. package/tests/memory_store.test.js +185 -0
  35. package/tests/provider_routing.test.js +67 -0
  36. package/tests/spotify.test.js +201 -0
  37. package/tests/system_monitor.test.js +37 -0
  38. package/tests/workspace_manager.test.js +56 -0
@@ -16,6 +16,7 @@ const DEFAULT_CONFIG = {
16
16
  ollamaModel: 'llama3:latest',
17
17
  enableVoiceReply: true,
18
18
  enableCustomWorkflows: true,
19
+ enableAgentCollaboration: true,
19
20
  ttsProvider: 'google',
20
21
  ttsVolume: 1.0,
21
22
  ttsSpeed: 1.0,
@@ -57,6 +58,18 @@ function applyConfig(config) {
57
58
  // Apply API key
58
59
  document.getElementById('api-key-input').value = config.apiKey || '';
59
60
 
61
+ const anthropicInput = document.getElementById('anthropic-api-key-input');
62
+ if (anthropicInput) anthropicInput.value = config.anthropicApiKey || '';
63
+
64
+ const openaiInput = document.getElementById('openai-api-key-input');
65
+ if (openaiInput) openaiInput.value = config.openaiApiKey || '';
66
+
67
+ const hfInput = document.getElementById('hf-api-key');
68
+ if (hfInput) hfInput.value = config.hfApiKey || '';
69
+
70
+ const ollamaHostInput = document.getElementById('ollama-host-input');
71
+ if (ollamaHostInput) ollamaHostInput.value = config.ollamaHost || 'http://localhost:11434';
72
+
60
73
  // Apply Gemini model
61
74
  applyModelSelection(config.geminiModel);
62
75
 
@@ -72,6 +85,24 @@ function applyConfig(config) {
72
85
  ollamaInput.value = config.ollamaModel || 'llama3:latest';
73
86
  }
74
87
 
88
+ applyModelSelectionGeneric('openai-model-select', 'openai-model-custom-row', 'openai-model-custom', config.openaiModel || 'gpt-4o');
89
+ applyModelSelectionGeneric('anthropic-model-select', 'anthropic-model-custom-row', 'anthropic-model-custom', config.anthropicModel || 'claude-3-5-sonnet-latest');
90
+
91
+ const hfModelInput = document.getElementById('hf-model-name');
92
+ if (hfModelInput) {
93
+ hfModelInput.value = config.hfModel || 'meta-llama/Meta-Llama-3-8B-Instruct';
94
+ }
95
+
96
+ const localApiBaseUrlInput = document.getElementById('local-api-base-url');
97
+ if (localApiBaseUrlInput) {
98
+ localApiBaseUrlInput.value = config.localApiBaseUrl || 'http://localhost:1234/v1';
99
+ }
100
+
101
+ const localModelNameInput = document.getElementById('local-model-name');
102
+ if (localModelNameInput) {
103
+ localModelNameInput.value = config.localModelName || 'local-model';
104
+ }
105
+
75
106
  const voiceReplyToggle = document.getElementById('enable-voice-reply');
76
107
  if (voiceReplyToggle) {
77
108
  voiceReplyToggle.checked = config.enableVoiceReply !== false;
@@ -103,6 +134,11 @@ function applyConfig(config) {
103
134
  enableWorkflowsToggle.checked = config.enableCustomWorkflows !== false;
104
135
  }
105
136
 
137
+ const enableAgentCollaborationToggle = document.getElementById('enable-agent-collaboration');
138
+ if (enableAgentCollaborationToggle) {
139
+ enableAgentCollaborationToggle.checked = config.enableAgentCollaboration !== false;
140
+ }
141
+
106
142
  // Plugins logic
107
143
  updatePluginButton('spotify', config.pluginSpotifyEnabled);
108
144
  updatePluginButton('calendar', config.pluginCalendarEnabled);
@@ -168,9 +204,15 @@ function lightenColor(hex, amount) {
168
204
  }
169
205
 
170
206
  function applyModelSelection(model) {
171
- const select = document.getElementById('gemini-model-select');
172
- const customRow = document.getElementById('gemini-model-custom-row');
173
- const customInput = document.getElementById('gemini-model-custom');
207
+ applyModelSelectionGeneric('gemini-model-select', 'gemini-model-custom-row', 'gemini-model-custom', model);
208
+ }
209
+
210
+ function applyModelSelectionGeneric(selectId, customRowId, customInputId, model) {
211
+ const select = document.getElementById(selectId);
212
+ const customRow = document.getElementById(customRowId);
213
+ const customInput = document.getElementById(customInputId);
214
+ if (!select || !customRow || !customInput) return;
215
+
174
216
  const normalized = (model || '').trim();
175
217
  const optionValues = Array.from(select.options).map(opt => opt.value);
176
218
 
@@ -186,11 +228,16 @@ function applyModelSelection(model) {
186
228
  }
187
229
 
188
230
  function getSelectedModel() {
189
- const select = document.getElementById('gemini-model-select');
190
- const customInput = document.getElementById('gemini-model-custom');
231
+ return getSelectedModelGeneric('gemini-model-select', 'gemini-model-custom', DEFAULT_CONFIG.geminiModel);
232
+ }
233
+
234
+ function getSelectedModelGeneric(selectId, customInputId, defaultModel) {
235
+ const select = document.getElementById(selectId);
236
+ const customInput = document.getElementById(customInputId);
237
+ if (!select || !customInput) return defaultModel;
191
238
  if (select.value === 'custom') {
192
239
  const custom = (customInput.value || '').trim();
193
- return custom || DEFAULT_CONFIG.geminiModel;
240
+ return custom || defaultModel;
194
241
  }
195
242
  return select.value;
196
243
  }
@@ -198,21 +245,28 @@ function getSelectedModel() {
198
245
  // --- Event Listeners ---
199
246
 
200
247
  // Close button
201
- document.getElementById('close-btn').addEventListener('click', () => {
202
- window.settingsApi.closeSettings();
203
- });
248
+ // Close button
249
+ const closeBtn = document.getElementById('close-btn');
250
+ if (closeBtn) {
251
+ closeBtn.addEventListener('click', () => {
252
+ window.settingsApi.closeSettings();
253
+ });
254
+ }
204
255
 
205
256
  // Toggle API key visibility
206
- document.getElementById('toggle-key').addEventListener('click', () => {
207
- const input = document.getElementById('api-key-input');
208
- input.type = input.type === 'password' ? 'text' : 'password';
209
- });
257
+ const toggleKey = document.getElementById('toggle-key');
258
+ if (toggleKey) {
259
+ toggleKey.addEventListener('click', () => {
260
+ const input = document.getElementById('api-key-input');
261
+ input.type = input.type === 'password' ? 'text' : 'password';
262
+ });
263
+ }
210
264
 
211
265
  async function saveApiKeyOnly() {
212
266
  const input = document.getElementById('api-key-input');
213
267
  const status = document.getElementById('api-save-status');
214
268
  const btn = document.getElementById('save-api-key');
215
- const apiKey = input.value.trim();
269
+ const apiKey = input ? input.value.trim() : '';
216
270
 
217
271
  try {
218
272
  const baseConfig = await window.settingsApi.getSettings();
@@ -220,26 +274,31 @@ async function saveApiKeyOnly() {
220
274
  await window.settingsApi.saveSettings(nextConfig);
221
275
  currentConfig.apiKey = apiKey;
222
276
 
223
- btn.textContent = 'Saved!';
224
- status.textContent = 'API key saved';
277
+ if (btn) btn.textContent = 'Saved!';
278
+ if (status) status.textContent = 'API key saved';
225
279
  setTimeout(() => {
226
- btn.textContent = 'Save API Key';
227
- status.textContent = '';
280
+ if (btn) btn.textContent = 'Save API Key';
281
+ if (status) status.textContent = '';
228
282
  }, 1500);
229
283
  } catch (err) {
230
284
  console.error('Failed to save API key:', err);
231
- status.textContent = 'Save failed';
232
- setTimeout(() => { status.textContent = ''; }, 1500);
285
+ if (status) status.textContent = 'Save failed';
286
+ setTimeout(() => { if (status) status.textContent = ''; }, 1500);
233
287
  }
234
288
  }
235
289
 
236
- document.getElementById('save-api-key').addEventListener('click', saveApiKeyOnly);
237
- document.getElementById('api-key-input').addEventListener('keydown', (e) => {
238
- if (e.key === 'Enter') {
239
- e.preventDefault();
240
- saveApiKeyOnly();
241
- }
242
- });
290
+ const saveApiKey = document.getElementById('save-api-key');
291
+ if (saveApiKey) saveApiKey.addEventListener('click', saveApiKeyOnly);
292
+
293
+ const apiKeyInput = document.getElementById('api-key-input');
294
+ if (apiKeyInput) {
295
+ apiKeyInput.addEventListener('keydown', (e) => {
296
+ if (e.key === 'Enter') {
297
+ e.preventDefault();
298
+ saveApiKeyOnly();
299
+ }
300
+ });
301
+ }
243
302
 
244
303
  // Gemini model select
245
304
  document.getElementById('gemini-model-select').addEventListener('change', (e) => {
@@ -257,23 +316,45 @@ document.getElementById('gemini-model-custom').addEventListener('input', (e) =>
257
316
  currentConfig.geminiModel = e.target.value.trim();
258
317
  });
259
318
 
260
- // AI Provider toggle
261
- function toggleProviderOptions(provider) {
262
- const geminiOptions = document.getElementById('gemini-options');
263
- const ollamaOptions = document.getElementById('ollama-options');
264
-
265
- if (provider === 'ollama') {
266
- geminiOptions.style.display = 'none';
267
- ollamaOptions.style.display = 'block';
319
+ // OpenAI model select
320
+ document.getElementById('openai-model-select').addEventListener('change', (e) => {
321
+ const customRow = document.getElementById('openai-model-custom-row');
322
+ if (e.target.value === 'custom') {
323
+ customRow.style.display = 'block';
324
+ currentConfig.openaiModel = (document.getElementById('openai-model-custom').value || '').trim();
268
325
  } else {
269
- geminiOptions.style.display = 'block';
270
- ollamaOptions.style.display = 'none';
326
+ customRow.style.display = 'none';
327
+ currentConfig.openaiModel = e.target.value;
271
328
  }
329
+ });
330
+
331
+ document.getElementById('openai-model-custom').addEventListener('input', (e) => {
332
+ currentConfig.openaiModel = e.target.value.trim();
333
+ });
334
+
335
+ // Anthropic model select
336
+ document.getElementById('anthropic-model-select').addEventListener('change', (e) => {
337
+ const customRow = document.getElementById('anthropic-model-custom-row');
338
+ if (e.target.value === 'custom') {
339
+ customRow.style.display = 'block';
340
+ currentConfig.anthropicModel = (document.getElementById('anthropic-model-custom').value || '').trim();
341
+ } else {
342
+ customRow.style.display = 'none';
343
+ currentConfig.anthropicModel = e.target.value;
344
+ }
345
+ });
346
+
347
+ document.getElementById('anthropic-model-custom').addEventListener('input', (e) => {
348
+ currentConfig.anthropicModel = e.target.value.trim();
349
+ });
350
+
351
+ // AI Provider toggle (No-op since all sections stay visible)
352
+ function toggleProviderOptions(provider) {
353
+ // No-op
272
354
  }
273
355
 
274
356
  document.getElementById('ai-provider-select').addEventListener('change', (e) => {
275
357
  currentConfig.aiProvider = e.target.value;
276
- toggleProviderOptions(e.target.value);
277
358
  });
278
359
 
279
360
  document.getElementById('ollama-model-input').addEventListener('input', (e) => {
@@ -281,9 +362,12 @@ document.getElementById('ollama-model-input').addEventListener('input', (e) => {
281
362
  });
282
363
 
283
364
  // AI Studio link
284
- document.getElementById('ai-studio-link').addEventListener('click', () => {
285
- window.settingsApi.openExternal('https://aistudio.google.com/');
286
- });
365
+ const aiStudioLink = document.getElementById('ai-studio-link');
366
+ if (aiStudioLink) {
367
+ aiStudioLink.addEventListener('click', () => {
368
+ window.settingsApi.openExternal('https://aistudio.google.com/');
369
+ });
370
+ }
287
371
 
288
372
  // Theme cards
289
373
  document.querySelectorAll('.theme-card').forEach(card => {
@@ -400,9 +484,34 @@ if (document.getElementById('tts-pitch')) {
400
484
  // Save
401
485
  document.getElementById('save-btn').addEventListener('click', async () => {
402
486
  currentConfig.apiKey = document.getElementById('api-key-input').value.trim();
487
+
488
+ const anthropicInput = document.getElementById('anthropic-api-key-input');
489
+ if (anthropicInput) currentConfig.anthropicApiKey = anthropicInput.value.trim();
490
+
491
+ const openaiInput = document.getElementById('openai-api-key-input');
492
+ if (openaiInput) currentConfig.openaiApiKey = openaiInput.value.trim();
493
+
494
+ const hfInput = document.getElementById('hf-api-key');
495
+ if (hfInput) currentConfig.hfApiKey = hfInput.value.trim();
496
+
497
+ const ollamaHostInput = document.getElementById('ollama-host-input');
498
+ if (ollamaHostInput) currentConfig.ollamaHost = ollamaHostInput.value.trim();
499
+
403
500
  currentConfig.geminiModel = getSelectedModel();
404
501
  currentConfig.aiProvider = document.getElementById('ai-provider-select').value;
405
502
  currentConfig.ollamaModel = document.getElementById('ollama-model-input').value.trim();
503
+
504
+ currentConfig.openaiModel = getSelectedModelGeneric('openai-model-select', 'openai-model-custom', DEFAULT_CONFIG.openaiModel || 'gpt-4o');
505
+ currentConfig.anthropicModel = getSelectedModelGeneric('anthropic-model-select', 'anthropic-model-custom', DEFAULT_CONFIG.anthropicModel || 'claude-3-5-sonnet-latest');
506
+
507
+ const hfModelInput = document.getElementById('hf-model-name');
508
+ if (hfModelInput) currentConfig.hfModel = hfModelInput.value.trim();
509
+
510
+ const localApiBaseUrlInput = document.getElementById('local-api-base-url');
511
+ if (localApiBaseUrlInput) currentConfig.localApiBaseUrl = localApiBaseUrlInput.value.trim();
512
+
513
+ const localModelNameInput = document.getElementById('local-model-name');
514
+ if (localModelNameInput) currentConfig.localModelName = localModelNameInput.value.trim();
406
515
 
407
516
  const voiceReplyToggle = document.getElementById('enable-voice-reply');
408
517
  if (voiceReplyToggle) {
@@ -421,6 +530,11 @@ document.getElementById('save-btn').addEventListener('click', async () => {
421
530
  currentConfig.enableCustomWorkflows = enableWorkflowsToggle.checked;
422
531
  }
423
532
 
533
+ const enableAgentCollaborationToggle = document.getElementById('enable-agent-collaboration');
534
+ if (enableAgentCollaborationToggle) {
535
+ currentConfig.enableAgentCollaboration = enableAgentCollaborationToggle.checked;
536
+ }
537
+
424
538
  const showWidgetToggle = document.getElementById('show-desktop-widget');
425
539
  if (showWidgetToggle) {
426
540
  currentConfig.showDesktopWidget = showWidgetToggle.checked;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Tests: agent_orchestrator.js
3
+ */
4
+
5
+ const orchestrator = require('../src/AI_Brain/agent_orchestrator');
6
+
7
+ describe('Agent Orchestrator', () => {
8
+ beforeEach(() => {
9
+ orchestrator.resetAgent();
10
+ });
11
+
12
+ test('starts with general agent', () => {
13
+ const agent = orchestrator.getCurrentAgent();
14
+ expect(agent.name).toBe('Mint Default');
15
+ });
16
+
17
+ test('can switch to coder agent', () => {
18
+ orchestrator.setAgent('coder');
19
+ const agent = orchestrator.getCurrentAgent();
20
+ expect(agent.name).toBe('Mint Coder');
21
+ });
22
+
23
+ test('can switch to researcher agent', () => {
24
+ orchestrator.setAgent('researcher');
25
+ const agent = orchestrator.getCurrentAgent();
26
+ expect(agent.name).toBe('Mint Researcher');
27
+ });
28
+
29
+ test('falls back to general for invalid agent', () => {
30
+ orchestrator.setAgent('nonexistent');
31
+ const agent = orchestrator.getCurrentAgent();
32
+ expect(agent.name).toBe('Mint Default');
33
+ });
34
+
35
+ test('lists available agents', () => {
36
+ const agents = orchestrator.listAgents();
37
+ expect(agents).toContain('general');
38
+ expect(agents).toContain('coder');
39
+ expect(agents).toContain('researcher');
40
+ });
41
+ });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Tests: chat_router routing helpers
3
+ */
4
+
5
+ jest.mock('@google/genai', () => ({
6
+ GoogleGenAI: jest.fn()
7
+ }));
8
+
9
+ jest.mock('../src/CLI/code_agent', () => ({
10
+ executeCodeTask: jest.fn(),
11
+ _helpers: {
12
+ selectSupportedCodeProvider: jest.fn(() => 'gemini')
13
+ }
14
+ }));
15
+
16
+ jest.mock('../src/System/config_manager', () => ({
17
+ readConfig: jest.fn(() => ({})),
18
+ getAvailableProviders: jest.fn(() => ['gemini'])
19
+ }));
20
+
21
+ describe('chat_router helpers', () => {
22
+ test('recognizes direct folder open request as chat task', () => {
23
+ const { _helpers } = require('../src/CLI/chat_router');
24
+ expect(_helpers.isDirectFilesystemActionRequest('เปิดโฟลเดอร์ xidaidai ให้หน่อย')).toBe(true);
25
+ });
26
+
27
+ test('does not classify direct folder open request as code intent', () => {
28
+ const { _helpers } = require('../src/CLI/chat_router');
29
+ const route = _helpers.detectCodeIntentHeuristic('open folder xidaidai', process.cwd());
30
+ expect(route).toBe(false);
31
+ });
32
+
33
+ test('treats small file-related request as normal chat', () => {
34
+ const { _helpers } = require('../src/CLI/chat_router');
35
+ expect(_helpers.isLargeCodeTaskRequest('ดูไฟล์ package.json ให้หน่อย', process.cwd())).toBe(false);
36
+ });
37
+
38
+ test('treats substantial project fix request as code task', () => {
39
+ const { _helpers } = require('../src/CLI/chat_router');
40
+ expect(_helpers.isLargeCodeTaskRequest('fix the failing tests in this project and verify the result', process.cwd())).toBe(true);
41
+ });
42
+ });
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Tests: code_agent helpers
3
+ */
4
+
5
+ jest.mock('@google/genai', () => ({
6
+ GoogleGenAI: jest.fn()
7
+ }));
8
+
9
+ jest.mock('axios', () => ({}));
10
+
11
+ jest.mock('../src/System/config_manager', () => ({
12
+ readConfig: jest.fn(() => ({})),
13
+ getAvailableProviders: jest.fn(() => ['ollama', 'gemini'])
14
+ }));
15
+
16
+ jest.mock('../src/CLI/code_session_memory', () => ({
17
+ readWorkspaceSession: jest.fn(() => ({
18
+ summary: '',
19
+ lastTask: '',
20
+ lastVerification: '',
21
+ updatedAt: null
22
+ })),
23
+ writeWorkspaceSession: jest.fn()
24
+ }));
25
+
26
+ const fs = require('fs');
27
+ const path = require('path');
28
+ const os = require('os');
29
+
30
+ describe('code_agent helpers', () => {
31
+ test('extractJson recovers JSON embedded in surrounding text', () => {
32
+ const { _helpers } = require('../src/CLI/code_agent');
33
+ const parsed = _helpers.extractJson('note\n{"action":"finish","input":{"summary":"ok"}}\nthanks');
34
+ expect(parsed.action).toBe('finish');
35
+ expect(parsed.input.summary).toBe('ok');
36
+ });
37
+
38
+ test('selectSupportedCodeProvider falls back away from unsupported code providers', () => {
39
+ const { _helpers } = require('../src/CLI/code_agent');
40
+ const selected = _helpers.selectSupportedCodeProvider(
41
+ { aiProvider: 'ollama' },
42
+ ['ollama', 'openai', 'gemini']
43
+ );
44
+ expect(selected).toBe('openai');
45
+ });
46
+
47
+ test('selectSupportedCodeProvider keeps configured supported provider when available', () => {
48
+ const { _helpers } = require('../src/CLI/code_agent');
49
+ const selected = _helpers.selectSupportedCodeProvider(
50
+ { aiProvider: 'anthropic' },
51
+ ['anthropic', 'gemini']
52
+ );
53
+ expect(selected).toBe('anthropic');
54
+ });
55
+
56
+ test('findPaths can locate directories by partial name', async () => {
57
+ const { _helpers } = require('../src/CLI/code_agent');
58
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mint-code-agent-'));
59
+ const targetDir = path.join(tempDir, 'projects', 'xidaidai');
60
+ fs.mkdirSync(targetDir, { recursive: true });
61
+
62
+ try {
63
+ const result = await _helpers.findPaths(tempDir, 'xidaidai', 'dir');
64
+ expect(result).toContain('[dir] projects/xidaidai');
65
+ } finally {
66
+ fs.rmSync(tempDir, { recursive: true, force: true });
67
+ }
68
+ });
69
+ });
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Tests: config_manager.js
3
+ * Tests readConfig, writeConfig, and getAvailableProviders.
4
+ *
5
+ * Isolation strategy: spy on os.homedir() BEFORE requiring config_manager
6
+ * so CONFIG_DIR and CONFIG_PATH point to a temp directory, never touching
7
+ * the real ~/.config/mint/mint-config.json.
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const os = require('os');
13
+
14
+ let tempDir;
15
+
16
+ beforeEach(() => {
17
+ // 1. Reset module registry so config_manager re-initialises fresh each test
18
+ jest.resetModules();
19
+
20
+ // 2. Create an isolated temp dir for this test
21
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mint-cfg-test-'));
22
+
23
+ // 3. Mock os.homedir() BEFORE requiring config_manager.
24
+ // config_manager computes CONFIG_DIR = os.homedir() + '/.config/mint'
25
+ // at load time, so the spy must be active when the module first loads.
26
+ jest.spyOn(os, 'homedir').mockReturnValue(tempDir);
27
+ });
28
+
29
+ afterEach(() => {
30
+ jest.restoreAllMocks();
31
+ jest.resetModules();
32
+ fs.rmSync(tempDir, { recursive: true, force: true });
33
+ });
34
+
35
+ // Helper — always gets a fresh, isolated instance of config_manager
36
+ function getModule() {
37
+ return require('../src/System/config_manager');
38
+ }
39
+
40
+ // ── readConfig ─────────────────────────────────────────────────────────────
41
+
42
+ describe('config_manager — readConfig', () => {
43
+ test('returns DEFAULT_CONFIG when no config file exists', () => {
44
+ const { readConfig } = getModule();
45
+ const config = readConfig();
46
+ expect(config).toHaveProperty('geminiModel', 'gemini-2.5-flash');
47
+ expect(config).toHaveProperty('aiProvider', 'gemini');
48
+ expect(config).toHaveProperty('language', 'th-TH');
49
+ });
50
+
51
+ test('merges saved values with defaults (saved value wins)', () => {
52
+ const { readConfig, writeConfig } = getModule();
53
+ writeConfig({ geminiModel: 'gemini-2.0-pro' });
54
+ const config = readConfig();
55
+ expect(config.geminiModel).toBe('gemini-2.0-pro');
56
+ // Default fields still present
57
+ expect(config.aiProvider).toBe('gemini');
58
+ expect(config.language).toBe('th-TH');
59
+ });
60
+
61
+ test('missing keys in saved file are filled by defaults', () => {
62
+ const { readConfig, writeConfig } = getModule();
63
+ // Write a partial config (no 'ollamaModel' key)
64
+ writeConfig({ geminiModel: 'my-model' });
65
+ const config = readConfig();
66
+ expect(config.ollamaModel).toBe('llama3:latest'); // default
67
+ });
68
+ });
69
+
70
+ // ── writeConfig ────────────────────────────────────────────────────────────
71
+
72
+ describe('config_manager — writeConfig', () => {
73
+ test('returns { success: true } on valid write', () => {
74
+ const { writeConfig } = getModule();
75
+ const result = writeConfig({ testKey: 'testValue' });
76
+ expect(result).toEqual({ success: true });
77
+ });
78
+
79
+ test('written JSON is readable back correctly', () => {
80
+ const { readConfig, writeConfig } = getModule();
81
+ writeConfig({ geminiModel: 'custom-model-test' });
82
+ const config = readConfig();
83
+ expect(config.geminiModel).toBe('custom-model-test');
84
+ });
85
+
86
+ test('config file is actually created on disk', () => {
87
+ const { writeConfig, CONFIG_PATH } = getModule();
88
+ writeConfig({ geminiModel: 'test' });
89
+ expect(fs.existsSync(CONFIG_PATH)).toBe(true);
90
+ });
91
+ });
92
+
93
+ // ── getAvailableProviders ──────────────────────────────────────────────────
94
+
95
+ describe('config_manager — getAvailableProviders', () => {
96
+ test('always includes ollama (local, no key needed)', () => {
97
+ const { getAvailableProviders } = getModule();
98
+ expect(getAvailableProviders({})).toContain('ollama');
99
+ });
100
+
101
+ test('includes gemini when apiKey is set', () => {
102
+ const { getAvailableProviders } = getModule();
103
+ expect(getAvailableProviders({ apiKey: 'test-key' })).toContain('gemini');
104
+ });
105
+
106
+ test('includes anthropic when anthropicApiKey is set', () => {
107
+ const { getAvailableProviders } = getModule();
108
+ expect(getAvailableProviders({ anthropicApiKey: 'ant-key' })).toContain('anthropic');
109
+ });
110
+
111
+ test('includes openai when openaiApiKey is set', () => {
112
+ const { getAvailableProviders } = getModule();
113
+ expect(getAvailableProviders({ openaiApiKey: 'oai-key' })).toContain('openai');
114
+ });
115
+
116
+ test('includes huggingface when hfApiKey is set', () => {
117
+ const { getAvailableProviders } = getModule();
118
+ expect(getAvailableProviders({ hfApiKey: 'hf-key' })).toContain('huggingface');
119
+ });
120
+
121
+ test('includes local_openai when localApiBaseUrl is set', () => {
122
+ const { getAvailableProviders } = getModule();
123
+ expect(
124
+ getAvailableProviders({ localApiBaseUrl: 'http://localhost:1234/v1' })
125
+ ).toContain('local_openai');
126
+ });
127
+
128
+ test('does NOT include gemini when no key', () => {
129
+ const { getAvailableProviders } = getModule();
130
+ const savedEnv = process.env.GEMINI_API_KEY;
131
+ delete process.env.GEMINI_API_KEY;
132
+ const providers = getAvailableProviders({ apiKey: '' });
133
+ expect(providers).not.toContain('gemini');
134
+ if (savedEnv) process.env.GEMINI_API_KEY = savedEnv;
135
+ });
136
+
137
+ test('returns array type', () => {
138
+ const { getAvailableProviders } = getModule();
139
+ expect(Array.isArray(getAvailableProviders({}))).toBe(true);
140
+ });
141
+ });
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Tests: docker.js plugin
3
+ */
4
+
5
+ jest.mock('child_process', () => ({
6
+ execFile: jest.fn()
7
+ }));
8
+
9
+ let docker;
10
+ let execFile;
11
+
12
+ beforeEach(() => {
13
+ jest.resetModules();
14
+ ({ execFile } = require('child_process'));
15
+ execFile.mockReset();
16
+ docker = require('../src/Plugins/docker');
17
+ });
18
+
19
+ describe('Docker Plugin', () => {
20
+ test('lists running containers', async () => {
21
+ execFile.mockImplementation((command, args, callback) => {
22
+ callback(null, 'web (Up 2 hours)\n', '');
23
+ });
24
+
25
+ const result = await docker.execute('list');
26
+ expect(execFile).toHaveBeenCalledWith('docker', ['ps', '--format', '{{.Names}} ({{.Status}})'], expect.any(Function));
27
+ expect(result).toContain('Running Containers');
28
+ expect(result).toContain('web (Up 2 hours)');
29
+ });
30
+
31
+ test('starts a named container without shell interpolation', async () => {
32
+ execFile.mockImplementation((command, args, callback) => {
33
+ callback(null, '', '');
34
+ });
35
+
36
+ const result = await docker.execute('start my-app');
37
+ expect(execFile).toHaveBeenCalledWith('docker', ['start', 'my-app'], expect.any(Function));
38
+ expect(result).toContain('Successfully executed "docker start"');
39
+ });
40
+
41
+ test('returns helpful message when command is invalid', async () => {
42
+ const result = await docker.execute('remove my-app');
43
+ expect(result).toContain('Invalid docker command');
44
+ expect(execFile).not.toHaveBeenCalled();
45
+ });
46
+ });