@pheem49/mint 1.2.4 → 1.4.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.
@@ -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,
@@ -23,7 +24,8 @@ const DEFAULT_CONFIG = {
23
24
  pluginSpotifyEnabled: true,
24
25
  pluginCalendarEnabled: false,
25
26
  pluginDiscordEnabled: false,
26
- showDesktopWidget: true
27
+ showDesktopWidget: true,
28
+ mcpServers: {}
27
29
  };
28
30
 
29
31
  let currentConfig = { ...DEFAULT_CONFIG };
@@ -56,6 +58,18 @@ function applyConfig(config) {
56
58
  // Apply API key
57
59
  document.getElementById('api-key-input').value = config.apiKey || '';
58
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
+
59
73
  // Apply Gemini model
60
74
  applyModelSelection(config.geminiModel);
61
75
 
@@ -71,6 +85,24 @@ function applyConfig(config) {
71
85
  ollamaInput.value = config.ollamaModel || 'llama3:latest';
72
86
  }
73
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
+
74
106
  const voiceReplyToggle = document.getElementById('enable-voice-reply');
75
107
  if (voiceReplyToggle) {
76
108
  voiceReplyToggle.checked = config.enableVoiceReply !== false;
@@ -102,6 +134,11 @@ function applyConfig(config) {
102
134
  enableWorkflowsToggle.checked = config.enableCustomWorkflows !== false;
103
135
  }
104
136
 
137
+ const enableAgentCollaborationToggle = document.getElementById('enable-agent-collaboration');
138
+ if (enableAgentCollaborationToggle) {
139
+ enableAgentCollaborationToggle.checked = config.enableAgentCollaboration !== false;
140
+ }
141
+
105
142
  // Plugins logic
106
143
  updatePluginButton('spotify', config.pluginSpotifyEnabled);
107
144
  updatePluginButton('calendar', config.pluginCalendarEnabled);
@@ -153,6 +190,9 @@ function applyConfig(config) {
153
190
  document.getElementById('proactive-cooldown').value = cooldown;
154
191
  updateIntervalDisplay(interval);
155
192
  updateCooldownDisplay(cooldown);
193
+
194
+ // MCP Servers
195
+ renderMcpServers();
156
196
  }
157
197
 
158
198
  function lightenColor(hex, amount) {
@@ -164,9 +204,15 @@ function lightenColor(hex, amount) {
164
204
  }
165
205
 
166
206
  function applyModelSelection(model) {
167
- const select = document.getElementById('gemini-model-select');
168
- const customRow = document.getElementById('gemini-model-custom-row');
169
- 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
+
170
216
  const normalized = (model || '').trim();
171
217
  const optionValues = Array.from(select.options).map(opt => opt.value);
172
218
 
@@ -182,11 +228,16 @@ function applyModelSelection(model) {
182
228
  }
183
229
 
184
230
  function getSelectedModel() {
185
- const select = document.getElementById('gemini-model-select');
186
- 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;
187
238
  if (select.value === 'custom') {
188
239
  const custom = (customInput.value || '').trim();
189
- return custom || DEFAULT_CONFIG.geminiModel;
240
+ return custom || defaultModel;
190
241
  }
191
242
  return select.value;
192
243
  }
@@ -194,21 +245,28 @@ function getSelectedModel() {
194
245
  // --- Event Listeners ---
195
246
 
196
247
  // Close button
197
- document.getElementById('close-btn').addEventListener('click', () => {
198
- window.settingsApi.closeSettings();
199
- });
248
+ // Close button
249
+ const closeBtn = document.getElementById('close-btn');
250
+ if (closeBtn) {
251
+ closeBtn.addEventListener('click', () => {
252
+ window.settingsApi.closeSettings();
253
+ });
254
+ }
200
255
 
201
256
  // Toggle API key visibility
202
- document.getElementById('toggle-key').addEventListener('click', () => {
203
- const input = document.getElementById('api-key-input');
204
- input.type = input.type === 'password' ? 'text' : 'password';
205
- });
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
+ }
206
264
 
207
265
  async function saveApiKeyOnly() {
208
266
  const input = document.getElementById('api-key-input');
209
267
  const status = document.getElementById('api-save-status');
210
268
  const btn = document.getElementById('save-api-key');
211
- const apiKey = input.value.trim();
269
+ const apiKey = input ? input.value.trim() : '';
212
270
 
213
271
  try {
214
272
  const baseConfig = await window.settingsApi.getSettings();
@@ -216,26 +274,31 @@ async function saveApiKeyOnly() {
216
274
  await window.settingsApi.saveSettings(nextConfig);
217
275
  currentConfig.apiKey = apiKey;
218
276
 
219
- btn.textContent = 'Saved!';
220
- status.textContent = 'API key saved';
277
+ if (btn) btn.textContent = 'Saved!';
278
+ if (status) status.textContent = 'API key saved';
221
279
  setTimeout(() => {
222
- btn.textContent = 'Save API Key';
223
- status.textContent = '';
280
+ if (btn) btn.textContent = 'Save API Key';
281
+ if (status) status.textContent = '';
224
282
  }, 1500);
225
283
  } catch (err) {
226
284
  console.error('Failed to save API key:', err);
227
- status.textContent = 'Save failed';
228
- setTimeout(() => { status.textContent = ''; }, 1500);
285
+ if (status) status.textContent = 'Save failed';
286
+ setTimeout(() => { if (status) status.textContent = ''; }, 1500);
229
287
  }
230
288
  }
231
289
 
232
- document.getElementById('save-api-key').addEventListener('click', saveApiKeyOnly);
233
- document.getElementById('api-key-input').addEventListener('keydown', (e) => {
234
- if (e.key === 'Enter') {
235
- e.preventDefault();
236
- saveApiKeyOnly();
237
- }
238
- });
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
+ }
239
302
 
240
303
  // Gemini model select
241
304
  document.getElementById('gemini-model-select').addEventListener('change', (e) => {
@@ -253,23 +316,45 @@ document.getElementById('gemini-model-custom').addEventListener('input', (e) =>
253
316
  currentConfig.geminiModel = e.target.value.trim();
254
317
  });
255
318
 
256
- // AI Provider toggle
257
- function toggleProviderOptions(provider) {
258
- const geminiOptions = document.getElementById('gemini-options');
259
- const ollamaOptions = document.getElementById('ollama-options');
260
-
261
- if (provider === 'ollama') {
262
- geminiOptions.style.display = 'none';
263
- 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();
325
+ } else {
326
+ customRow.style.display = 'none';
327
+ currentConfig.openaiModel = e.target.value;
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();
264
341
  } else {
265
- geminiOptions.style.display = 'block';
266
- ollamaOptions.style.display = 'none';
342
+ customRow.style.display = 'none';
343
+ currentConfig.anthropicModel = e.target.value;
267
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
268
354
  }
269
355
 
270
356
  document.getElementById('ai-provider-select').addEventListener('change', (e) => {
271
357
  currentConfig.aiProvider = e.target.value;
272
- toggleProviderOptions(e.target.value);
273
358
  });
274
359
 
275
360
  document.getElementById('ollama-model-input').addEventListener('input', (e) => {
@@ -277,9 +362,12 @@ document.getElementById('ollama-model-input').addEventListener('input', (e) => {
277
362
  });
278
363
 
279
364
  // AI Studio link
280
- document.getElementById('ai-studio-link').addEventListener('click', () => {
281
- window.settingsApi.openExternal('https://aistudio.google.com/');
282
- });
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
+ }
283
371
 
284
372
  // Theme cards
285
373
  document.querySelectorAll('.theme-card').forEach(card => {
@@ -396,9 +484,34 @@ if (document.getElementById('tts-pitch')) {
396
484
  // Save
397
485
  document.getElementById('save-btn').addEventListener('click', async () => {
398
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
+
399
500
  currentConfig.geminiModel = getSelectedModel();
400
501
  currentConfig.aiProvider = document.getElementById('ai-provider-select').value;
401
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();
402
515
 
403
516
  const voiceReplyToggle = document.getElementById('enable-voice-reply');
404
517
  if (voiceReplyToggle) {
@@ -417,6 +530,11 @@ document.getElementById('save-btn').addEventListener('click', async () => {
417
530
  currentConfig.enableCustomWorkflows = enableWorkflowsToggle.checked;
418
531
  }
419
532
 
533
+ const enableAgentCollaborationToggle = document.getElementById('enable-agent-collaboration');
534
+ if (enableAgentCollaborationToggle) {
535
+ currentConfig.enableAgentCollaboration = enableAgentCollaborationToggle.checked;
536
+ }
537
+
420
538
  const showWidgetToggle = document.getElementById('show-desktop-widget');
421
539
  if (showWidgetToggle) {
422
540
  currentConfig.showDesktopWidget = showWidgetToggle.checked;
@@ -433,12 +551,100 @@ document.getElementById('save-btn').addEventListener('click', async () => {
433
551
  currentConfig.customBgEnd = document.getElementById('custom-bg-end').value;
434
552
  currentConfig.customPanelBg = document.getElementById('custom-panel-bg').value;
435
553
 
554
+ // Ensure mcpServers is part of the saved config
555
+ if (!currentConfig.mcpServers) currentConfig.mcpServers = {};
556
+
557
+ console.log('[Settings] Saving config with MCP servers:', Object.keys(currentConfig.mcpServers).length);
436
558
  await window.settingsApi.saveSettings(currentConfig);
437
559
  const btn = document.getElementById('save-btn');
438
560
  btn.textContent = '✅ Saved!';
439
561
  setTimeout(() => { btn.textContent = 'Save Settings'; }, 1500);
440
562
  });
441
563
 
564
+ // --- MCP Management Functions ---
565
+ function renderMcpServers() {
566
+ console.log('[Settings] Rendering MCP Servers UI...');
567
+ const list = document.getElementById('mcp-server-list');
568
+ if (!list) {
569
+ console.warn('[Settings] MCP list element not found in DOM.');
570
+ return;
571
+ }
572
+ list.innerHTML = '';
573
+
574
+ const servers = currentConfig.mcpServers || {};
575
+ const entries = Object.entries(servers);
576
+ console.log(`[Settings] Found ${entries.length} servers in currentConfig.`);
577
+
578
+ if (entries.length === 0) {
579
+ list.innerHTML = '<p class="hint" style="text-align: center; padding: 10px;">No MCP servers connected.</p>';
580
+ return;
581
+ }
582
+
583
+ for (const [name, cfg] of entries) {
584
+ const item = document.createElement('div');
585
+ item.style.cssText = 'display: flex; justify-content: space-between; align-items: center; padding: 12px; background: rgba(0,0,0,0.2); border-radius: 10px; border: 1px solid var(--border);';
586
+
587
+ item.innerHTML = `
588
+ <div style="display: flex; flex-direction: column; gap: 4px;">
589
+ <div style="font-weight: 600; color: var(--accent); display: flex; align-items: center; gap: 6px;">
590
+ <span>🌐 ${name}</span>
591
+ </div>
592
+ <div style="font-size: 0.75rem; opacity: 0.7; font-family: monospace;">${cfg.command} ${cfg.args.join(' ')}</div>
593
+ </div>
594
+ <button class="btn-danger" style="padding: 6px 12px; font-size: 0.8rem;" onclick="removeMcpServer('${name}')">Remove</button>
595
+ `;
596
+ list.appendChild(item);
597
+ }
598
+ }
599
+
600
+ window.removeMcpServer = function(name) {
601
+ if (confirm(`Remove MCP server "${name}"?`)) {
602
+ delete currentConfig.mcpServers[name];
603
+ renderMcpServers();
604
+ }
605
+ };
606
+
607
+ document.getElementById('btn-add-mcp').addEventListener('click', () => {
608
+ const nameInput = document.getElementById('mcp-new-name');
609
+ const cmdInput = document.getElementById('mcp-new-command');
610
+ const argsInput = document.getElementById('mcp-new-args');
611
+ const envInput = document.getElementById('mcp-new-env');
612
+
613
+ const name = nameInput.value.trim();
614
+ const command = cmdInput.value.trim();
615
+ const argsStr = argsInput.value.trim();
616
+ const envStr = envInput.value.trim();
617
+
618
+ if (!name || !command) {
619
+ alert('Name and Command are required!');
620
+ return;
621
+ }
622
+
623
+ // Basic args split (by space, but respecting some quotes if possible - simple for now)
624
+ const args = argsStr ? argsStr.split(/\s+/) : [];
625
+
626
+ let env = {};
627
+ if (envStr) {
628
+ try {
629
+ env = JSON.parse(envStr);
630
+ } catch (e) {
631
+ alert('Invalid JSON in Environment Variables field!');
632
+ return;
633
+ }
634
+ }
635
+
636
+ if (!currentConfig.mcpServers) currentConfig.mcpServers = {};
637
+ currentConfig.mcpServers[name] = { command, args, env };
638
+
639
+ // Clear inputs
640
+ nameInput.value = '';
641
+ cmdInput.value = '';
642
+ argsInput.value = '';
643
+ envInput.value = '';
644
+
645
+ renderMcpServers();
646
+ });
647
+
442
648
  // Custom Workflows functionality
443
649
  const openWorkflowsBtn = document.getElementById('open-workflows-btn');
444
650
  const reloadWorkflowsBtn = document.getElementById('reload-workflows-btn');
@@ -508,6 +714,11 @@ document.querySelectorAll('.tab-btn').forEach(btn => {
508
714
  btn.classList.add('active');
509
715
  const pane = document.getElementById(target);
510
716
  if (pane) pane.classList.add('active');
717
+
718
+ // Re-render MCP list if switching to plugins tab
719
+ if (target === 'sect-plugins') {
720
+ renderMcpServers();
721
+ }
511
722
  });
512
723
  });
513
724
 
@@ -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,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
+ });