groove-dev 0.27.142 → 0.27.144

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 (187) hide show
  1. package/node_modules/@groove-dev/cli/package.json +1 -1
  2. package/node_modules/@groove-dev/daemon/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/src/api.js +1086 -6532
  4. package/node_modules/@groove-dev/daemon/src/gateways/manager.js +35 -1
  5. package/node_modules/@groove-dev/daemon/src/index.js +3 -0
  6. package/node_modules/@groove-dev/daemon/src/journalist.js +23 -13
  7. package/node_modules/@groove-dev/daemon/src/mlx-server.js +365 -0
  8. package/node_modules/@groove-dev/daemon/src/model-lab.js +308 -12
  9. package/node_modules/@groove-dev/daemon/src/pm.js +1 -1
  10. package/node_modules/@groove-dev/daemon/src/process.js +2 -2
  11. package/node_modules/@groove-dev/daemon/src/providers/local.js +36 -8
  12. package/node_modules/@groove-dev/daemon/src/registry.js +21 -5
  13. package/node_modules/@groove-dev/daemon/src/routes/agents.js +889 -0
  14. package/node_modules/@groove-dev/daemon/src/routes/coordination.js +318 -0
  15. package/node_modules/@groove-dev/daemon/src/routes/files.js +751 -0
  16. package/node_modules/@groove-dev/daemon/src/routes/integrations.js +485 -0
  17. package/node_modules/@groove-dev/daemon/src/routes/network.js +1784 -0
  18. package/node_modules/@groove-dev/daemon/src/routes/providers.js +755 -0
  19. package/node_modules/@groove-dev/daemon/src/routes/schedules.js +110 -0
  20. package/node_modules/@groove-dev/daemon/src/routes/teams.js +650 -0
  21. package/node_modules/@groove-dev/daemon/src/scheduler.js +456 -24
  22. package/node_modules/@groove-dev/daemon/src/teams.js +1 -1
  23. package/node_modules/@groove-dev/daemon/src/validate.js +38 -1
  24. package/node_modules/@groove-dev/daemon/templates/mlx-setup.json +12 -0
  25. package/node_modules/@groove-dev/daemon/templates/tgi-setup.json +1 -1
  26. package/node_modules/@groove-dev/daemon/templates/vllm-setup.json +1 -1
  27. package/node_modules/@groove-dev/daemon/test/introducer.test.js +3 -3
  28. package/node_modules/@groove-dev/daemon/test/journalist.test.js +7 -10
  29. package/node_modules/@groove-dev/daemon/test/registry.test.js +38 -0
  30. package/node_modules/@groove-dev/gui/dist/assets/index-BcoF6_eF.js +1012 -0
  31. package/node_modules/@groove-dev/gui/dist/assets/index-Dd7qhiEd.css +1 -0
  32. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  33. package/node_modules/@groove-dev/gui/package.json +1 -1
  34. package/{packages/gui/src/app.jsx → node_modules/@groove-dev/gui/src/App.jsx} +0 -2
  35. package/node_modules/@groove-dev/gui/src/app.css +35 -0
  36. package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +1 -128
  37. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +144 -31
  38. package/node_modules/@groove-dev/gui/src/components/agents/agent-node.jsx +8 -13
  39. package/node_modules/@groove-dev/gui/src/components/agents/code-review.jsx +159 -122
  40. package/node_modules/@groove-dev/gui/src/components/agents/diff-viewer.jsx +23 -23
  41. package/node_modules/@groove-dev/gui/src/components/agents/journalist-panel.jsx +1 -1
  42. package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +2 -135
  43. package/node_modules/@groove-dev/gui/src/components/automations/automation-card.jsx +274 -0
  44. package/node_modules/@groove-dev/gui/src/components/automations/automation-wizard.jsx +1136 -0
  45. package/node_modules/@groove-dev/gui/src/components/dashboard/activity-feed.jsx +3 -3
  46. package/node_modules/@groove-dev/gui/src/components/dashboard/cache-ring.jsx +5 -5
  47. package/node_modules/@groove-dev/gui/src/components/dashboard/context-gauges.jsx +6 -8
  48. package/node_modules/@groove-dev/gui/src/components/dashboard/fleet-panel.jsx +8 -14
  49. package/node_modules/@groove-dev/gui/src/components/dashboard/intel-panel.jsx +238 -656
  50. package/node_modules/@groove-dev/gui/src/components/dashboard/kpi-card.jsx +3 -3
  51. package/node_modules/@groove-dev/gui/src/components/dashboard/routing-chart.jsx +3 -3
  52. package/node_modules/@groove-dev/gui/src/components/dashboard/team-burn-panel.jsx +1 -1
  53. package/node_modules/@groove-dev/gui/src/components/dashboard/token-chart.jsx +4 -4
  54. package/node_modules/@groove-dev/gui/src/components/editor/selection-menu.jsx +2 -0
  55. package/node_modules/@groove-dev/gui/src/components/lab/lab-assistant.jsx +316 -82
  56. package/node_modules/@groove-dev/gui/src/components/lab/metrics-panel.jsx +187 -32
  57. package/node_modules/@groove-dev/gui/src/components/lab/parameter-panel.jsx +195 -14
  58. package/node_modules/@groove-dev/gui/src/components/lab/runtime-config.jsx +286 -102
  59. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +2 -4
  60. package/node_modules/@groove-dev/gui/src/components/layout/terminal-panel.jsx +4 -2
  61. package/node_modules/@groove-dev/gui/src/components/layout/welcome-splash.jsx +137 -108
  62. package/node_modules/@groove-dev/gui/src/components/network/network-health.jsx +2 -2
  63. package/node_modules/@groove-dev/gui/src/components/network/performance-dashboard.jsx +4 -4
  64. package/node_modules/@groove-dev/gui/src/components/settings/ssh-wizard.jsx +81 -99
  65. package/node_modules/@groove-dev/gui/src/components/ui/sheet.jsx +5 -2
  66. package/node_modules/@groove-dev/gui/src/lib/cron.js +64 -0
  67. package/node_modules/@groove-dev/gui/src/lib/status.js +24 -24
  68. package/node_modules/@groove-dev/gui/src/lib/theme-hex.js +1 -0
  69. package/node_modules/@groove-dev/gui/src/stores/groove.js +34 -3144
  70. package/node_modules/@groove-dev/gui/src/stores/helpers.js +10 -0
  71. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +452 -0
  72. package/node_modules/@groove-dev/gui/src/stores/slices/automations-slice.js +96 -0
  73. package/node_modules/@groove-dev/gui/src/stores/slices/chat-slice.js +227 -0
  74. package/node_modules/@groove-dev/gui/src/stores/slices/editor-slice.js +285 -0
  75. package/node_modules/@groove-dev/gui/src/stores/slices/marketplace-slice.js +461 -0
  76. package/node_modules/@groove-dev/gui/src/stores/slices/network-slice.js +361 -0
  77. package/node_modules/@groove-dev/gui/src/stores/slices/preview-slice.js +109 -0
  78. package/node_modules/@groove-dev/gui/src/stores/slices/providers-slice.js +897 -0
  79. package/node_modules/@groove-dev/gui/src/stores/slices/teams-slice.js +413 -0
  80. package/node_modules/@groove-dev/gui/src/stores/slices/ui-slice.js +98 -0
  81. package/node_modules/@groove-dev/gui/src/views/agents.jsx +5 -5
  82. package/node_modules/@groove-dev/gui/src/views/dashboard.jsx +12 -13
  83. package/node_modules/@groove-dev/gui/src/views/marketplace.jsx +191 -3
  84. package/node_modules/@groove-dev/gui/src/views/model-lab.jsx +17 -6
  85. package/node_modules/@groove-dev/gui/src/views/models.jsx +410 -509
  86. package/node_modules/@groove-dev/gui/src/views/network.jsx +3 -3
  87. package/node_modules/@groove-dev/gui/src/views/settings.jsx +81 -94
  88. package/node_modules/@groove-dev/gui/src/views/teams.jsx +40 -483
  89. package/package.json +1 -1
  90. package/packages/cli/package.json +1 -1
  91. package/packages/daemon/package.json +1 -1
  92. package/packages/daemon/src/api.js +1086 -6532
  93. package/packages/daemon/src/gateways/manager.js +35 -1
  94. package/packages/daemon/src/index.js +3 -0
  95. package/packages/daemon/src/journalist.js +23 -13
  96. package/packages/daemon/src/mlx-server.js +365 -0
  97. package/packages/daemon/src/model-lab.js +308 -12
  98. package/packages/daemon/src/pm.js +1 -1
  99. package/packages/daemon/src/process.js +2 -2
  100. package/packages/daemon/src/providers/local.js +36 -8
  101. package/packages/daemon/src/registry.js +21 -5
  102. package/packages/daemon/src/routes/agents.js +889 -0
  103. package/packages/daemon/src/routes/coordination.js +318 -0
  104. package/packages/daemon/src/routes/files.js +751 -0
  105. package/packages/daemon/src/routes/integrations.js +485 -0
  106. package/packages/daemon/src/routes/network.js +1784 -0
  107. package/packages/daemon/src/routes/providers.js +755 -0
  108. package/packages/daemon/src/routes/schedules.js +110 -0
  109. package/packages/daemon/src/routes/teams.js +650 -0
  110. package/packages/daemon/src/scheduler.js +456 -24
  111. package/packages/daemon/src/teams.js +1 -1
  112. package/packages/daemon/src/validate.js +38 -1
  113. package/packages/daemon/templates/mlx-setup.json +12 -0
  114. package/packages/daemon/templates/tgi-setup.json +1 -1
  115. package/packages/daemon/templates/vllm-setup.json +1 -1
  116. package/packages/gui/dist/assets/index-BcoF6_eF.js +1012 -0
  117. package/packages/gui/dist/assets/index-Dd7qhiEd.css +1 -0
  118. package/packages/gui/dist/index.html +2 -2
  119. package/packages/gui/package.json +1 -1
  120. package/{node_modules/@groove-dev/gui/src/app.jsx → packages/gui/src/App.jsx} +0 -2
  121. package/packages/gui/src/app.css +35 -0
  122. package/packages/gui/src/components/agents/agent-config.jsx +1 -128
  123. package/packages/gui/src/components/agents/agent-feed.jsx +144 -31
  124. package/packages/gui/src/components/agents/agent-node.jsx +8 -13
  125. package/packages/gui/src/components/agents/code-review.jsx +159 -122
  126. package/packages/gui/src/components/agents/diff-viewer.jsx +23 -23
  127. package/packages/gui/src/components/agents/journalist-panel.jsx +1 -1
  128. package/packages/gui/src/components/agents/spawn-wizard.jsx +2 -135
  129. package/packages/gui/src/components/automations/automation-card.jsx +274 -0
  130. package/packages/gui/src/components/automations/automation-wizard.jsx +1136 -0
  131. package/packages/gui/src/components/dashboard/activity-feed.jsx +3 -3
  132. package/packages/gui/src/components/dashboard/cache-ring.jsx +5 -5
  133. package/packages/gui/src/components/dashboard/context-gauges.jsx +6 -8
  134. package/packages/gui/src/components/dashboard/fleet-panel.jsx +8 -14
  135. package/packages/gui/src/components/dashboard/intel-panel.jsx +238 -656
  136. package/packages/gui/src/components/dashboard/kpi-card.jsx +3 -3
  137. package/packages/gui/src/components/dashboard/routing-chart.jsx +3 -3
  138. package/packages/gui/src/components/dashboard/team-burn-panel.jsx +1 -1
  139. package/packages/gui/src/components/dashboard/token-chart.jsx +4 -4
  140. package/packages/gui/src/components/editor/selection-menu.jsx +2 -0
  141. package/packages/gui/src/components/lab/lab-assistant.jsx +316 -82
  142. package/packages/gui/src/components/lab/metrics-panel.jsx +187 -32
  143. package/packages/gui/src/components/lab/parameter-panel.jsx +195 -14
  144. package/packages/gui/src/components/lab/runtime-config.jsx +286 -102
  145. package/packages/gui/src/components/layout/activity-bar.jsx +2 -4
  146. package/packages/gui/src/components/layout/terminal-panel.jsx +4 -2
  147. package/packages/gui/src/components/layout/welcome-splash.jsx +137 -108
  148. package/packages/gui/src/components/network/network-health.jsx +2 -2
  149. package/packages/gui/src/components/network/performance-dashboard.jsx +4 -4
  150. package/packages/gui/src/components/settings/ssh-wizard.jsx +81 -99
  151. package/packages/gui/src/components/ui/sheet.jsx +5 -2
  152. package/packages/gui/src/lib/cron.js +64 -0
  153. package/packages/gui/src/lib/status.js +24 -24
  154. package/packages/gui/src/lib/theme-hex.js +1 -0
  155. package/packages/gui/src/stores/groove.js +34 -3144
  156. package/packages/gui/src/stores/helpers.js +10 -0
  157. package/packages/gui/src/stores/slices/agents-slice.js +452 -0
  158. package/packages/gui/src/stores/slices/automations-slice.js +96 -0
  159. package/packages/gui/src/stores/slices/chat-slice.js +227 -0
  160. package/packages/gui/src/stores/slices/editor-slice.js +285 -0
  161. package/packages/gui/src/stores/slices/marketplace-slice.js +461 -0
  162. package/packages/gui/src/stores/slices/network-slice.js +361 -0
  163. package/packages/gui/src/stores/slices/preview-slice.js +109 -0
  164. package/packages/gui/src/stores/slices/providers-slice.js +897 -0
  165. package/packages/gui/src/stores/slices/teams-slice.js +413 -0
  166. package/packages/gui/src/stores/slices/ui-slice.js +98 -0
  167. package/packages/gui/src/views/agents.jsx +5 -5
  168. package/packages/gui/src/views/dashboard.jsx +12 -13
  169. package/packages/gui/src/views/marketplace.jsx +191 -3
  170. package/packages/gui/src/views/model-lab.jsx +17 -6
  171. package/packages/gui/src/views/models.jsx +410 -509
  172. package/packages/gui/src/views/network.jsx +3 -3
  173. package/packages/gui/src/views/settings.jsx +81 -94
  174. package/packages/gui/src/views/teams.jsx +40 -483
  175. package/SECURITY_SWEEP.md +0 -228
  176. package/TRAINING_DATA_v4.md +0 -6
  177. package/node_modules/@groove-dev/gui/dist/assets/index-Bjd91ufV.js +0 -984
  178. package/node_modules/@groove-dev/gui/dist/assets/index-BqdwIFn4.css +0 -1
  179. package/node_modules/@groove-dev/gui/src/components/agents/agent-chat.jsx +0 -322
  180. package/node_modules/@groove-dev/gui/src/views/preview.jsx +0 -6
  181. package/node_modules/@groove-dev/gui/src/views/subscription-panel.jsx +0 -327
  182. package/packages/gui/dist/assets/index-Bjd91ufV.js +0 -984
  183. package/packages/gui/dist/assets/index-BqdwIFn4.css +0 -1
  184. package/packages/gui/src/components/agents/agent-chat.jsx +0 -322
  185. package/packages/gui/src/views/preview.jsx +0 -6
  186. package/packages/gui/src/views/subscription-panel.jsx +0 -327
  187. package/test.py +0 -571
@@ -0,0 +1,755 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+ import { resolve } from 'path';
3
+ import { existsSync, readFileSync, statSync, writeFileSync, unlinkSync } from 'fs';
4
+ import { spawn, execFileSync } from 'child_process';
5
+ import { homedir } from 'os';
6
+ import { isAbsolute } from 'path';
7
+ import { listProviders, getProvider, clearInstallCache, getProviderMetadata, getProviderPath, setProviderPaths } from '../providers/index.js';
8
+ import { OllamaProvider } from '../providers/ollama.js';
9
+ import { ClaudeCodeProvider } from '../providers/claude-code.js';
10
+
11
+ export function registerProviderRoutes(app, daemon) {
12
+
13
+ // List available providers
14
+ app.get('/api/providers', (req, res) => {
15
+ const providers = listProviders();
16
+ for (const p of providers) {
17
+ p.hasKey = daemon.credentials.hasKey(p.id);
18
+ if (p.id === 'claude-code') {
19
+ p.authStatus = ClaudeCodeProvider.getAuthStatus();
20
+ }
21
+ const meta = getProviderMetadata(p.id);
22
+ if (meta) {
23
+ p.setupGuide = meta.setupGuide;
24
+ p.authMethods = meta.authMethods;
25
+ }
26
+ const customPath = getProviderPath(p.id);
27
+ if (customPath) p.providerPath = customPath;
28
+
29
+ // Enrich local provider with GGUF models + lab runtime status
30
+ if (p.id === 'local' && daemon.modelManager && daemon.modelLab) {
31
+ const ollamaModels = p.models || [];
32
+ const ollamaIds = new Set(ollamaModels.map(m => m.id));
33
+ const runtimes = daemon.modelLab.listRuntimes();
34
+ const ggufModels = daemon.modelManager.getInstalled()
35
+ .filter(m => m.exists)
36
+ .map(m => {
37
+ const rt = runtimes.find(r =>
38
+ r._localModelId === m.id ||
39
+ r.models?.some(rm => rm.id === m.filename || rm.name === m.filename)
40
+ );
41
+ return {
42
+ id: `gguf:${m.id}`,
43
+ name: m.filename.replace(/\.gguf$/i, ''),
44
+ tier: m.tier || 'medium',
45
+ category: m.category || 'general',
46
+ source: 'gguf',
47
+ sizeBytes: m.sizeBytes || null,
48
+ quantization: m.quantization || null,
49
+ parameters: m.parameters || null,
50
+ runtimeId: rt?.id || null,
51
+ runtimeEndpoint: rt?.endpoint || null,
52
+ runtimeType: rt?.type || null,
53
+ hasRuntime: !!rt,
54
+ };
55
+ });
56
+ // Also surface models from lab runtimes not backed by a local GGUF
57
+ const runtimeModels = [];
58
+ for (const rt of runtimes) {
59
+ if (rt.type === 'ollama') continue;
60
+ for (const rm of (rt.models || [])) {
61
+ const alreadyGguf = ggufModels.some(g => g.runtimeId === rt.id);
62
+ const alreadyOllama = ollamaIds.has(rm.id) || ollamaIds.has(rm.name);
63
+ if (!alreadyGguf && !alreadyOllama) {
64
+ runtimeModels.push({
65
+ id: `runtime:${rt.id}:${rm.id}`,
66
+ name: rm.name || rm.id,
67
+ tier: 'medium',
68
+ category: 'general',
69
+ source: 'runtime',
70
+ runtimeId: rt.id,
71
+ runtimeEndpoint: rt.endpoint,
72
+ runtimeType: rt.type,
73
+ hasRuntime: true,
74
+ });
75
+ }
76
+ }
77
+ }
78
+ p.models = [...ollamaModels.map(m => ({ ...m, source: 'ollama', hasRuntime: true })), ...ggufModels, ...runtimeModels];
79
+ p.installed = p.installed || ggufModels.length > 0 || runtimeModels.length > 0;
80
+ }
81
+ }
82
+ res.json(providers);
83
+ });
84
+
85
+ // --- Claude Code Auth ---
86
+
87
+ app.get('/api/providers/claude-code/auth', (req, res) => {
88
+ res.json(ClaudeCodeProvider.getAuthStatus());
89
+ });
90
+
91
+ app.post('/api/providers/claude-code/login', (req, res) => {
92
+ ClaudeCodeProvider.triggerLogin();
93
+ daemon.audit.log('claude-code.login.started', {});
94
+ res.json({ ok: true });
95
+ });
96
+
97
+ // --- Ollama ---
98
+
99
+ const isValidModelId = (id) => typeof id === 'string' && id.length > 0 && id.length < 200 && /^[a-zA-Z0-9._:/-]+$/.test(id);
100
+
101
+ app.get('/api/providers/ollama/hardware', (req, res) => {
102
+ res.json(OllamaProvider.getSystemHardware());
103
+ });
104
+
105
+ app.get('/api/providers/ollama/models', (req, res) => {
106
+ const installed = OllamaProvider.isInstalled() ? OllamaProvider.getInstalledModels() : [];
107
+ const catalog = OllamaProvider.catalog;
108
+ const hardware = OllamaProvider.getSystemHardware();
109
+ res.json({ installed, catalog, hardware });
110
+ });
111
+
112
+ app.post('/api/providers/ollama/pull', async (req, res) => {
113
+ const { model } = req.body;
114
+ if (!model) return res.status(400).json({ error: 'model is required' });
115
+ if (!isValidModelId(model)) return res.status(400).json({ error: 'Invalid model ID' });
116
+ if (!OllamaProvider.isInstalled()) {
117
+ const install = OllamaProvider.installCommand();
118
+ return res.status(400).json({ error: `Ollama is not installed. Install with: ${install.command}` });
119
+ }
120
+ const broadcast = daemon.broadcast.bind(daemon);
121
+ try {
122
+ // Auto-start Ollama server if not running
123
+ const running = await OllamaProvider.isServerRunning();
124
+ if (!running) {
125
+ broadcast({ type: 'ollama:serve:starting' });
126
+ OllamaProvider.startServer();
127
+ // Wait for server to be ready (up to 10s)
128
+ for (let i = 0; i < 20; i++) {
129
+ await new Promise((r) => setTimeout(r, 500));
130
+ if (await OllamaProvider.isServerRunning()) break;
131
+ }
132
+ if (!(await OllamaProvider.isServerRunning())) {
133
+ return res.status(500).json({ error: 'Could not start Ollama server. Run `ollama serve` manually.' });
134
+ }
135
+ }
136
+ broadcast({ type: 'ollama:pull:start', model });
137
+ await OllamaProvider.pullModel(model, (progress) => {
138
+ broadcast({ type: 'ollama:pull:progress', model, progress: progress.trim() });
139
+ });
140
+ broadcast({ type: 'ollama:pull:complete', model });
141
+ daemon.audit.log('ollama.pull', { model });
142
+ res.json({ ok: true, model });
143
+ } catch (err) {
144
+ broadcast({ type: 'ollama:pull:error', model, error: err.message });
145
+ res.status(500).json({ error: `Pull failed: ${err.message}` });
146
+ }
147
+ });
148
+
149
+ app.delete('/api/providers/ollama/models/:model', (req, res) => {
150
+ if (!isValidModelId(req.params.model)) return res.status(400).json({ error: 'Invalid model ID' });
151
+ if (!OllamaProvider.isInstalled()) return res.status(400).json({ error: 'Ollama is not installed' });
152
+ const success = OllamaProvider.deleteModel(req.params.model);
153
+ if (success) {
154
+ daemon.audit.log('ollama.delete', { model: req.params.model });
155
+ res.json({ ok: true });
156
+ } else {
157
+ res.status(500).json({ error: 'Failed to delete model' });
158
+ }
159
+ });
160
+
161
+ app.post('/api/providers/ollama/check', async (req, res) => {
162
+ const installed = OllamaProvider.isInstalled();
163
+ const serverRunning = installed ? await OllamaProvider.isServerRunning() : false;
164
+ const install = OllamaProvider.installCommand();
165
+ const hardware = OllamaProvider.getSystemHardware();
166
+ const requirements = OllamaProvider.hardwareRequirements();
167
+ res.json({ installed, serverRunning, install, hardware, requirements });
168
+ });
169
+
170
+ app.post('/api/providers/ollama/serve', async (req, res) => {
171
+ if (!OllamaProvider.isInstalled()) return res.status(400).json({ error: 'Ollama is not installed' });
172
+ const already = await OllamaProvider.isServerRunning();
173
+ if (already) return res.json({ ok: true, alreadyRunning: true });
174
+ const result = OllamaProvider.startServer();
175
+ if (result.started) {
176
+ // Wait a moment for server to come up
177
+ await new Promise((r) => setTimeout(r, 2000));
178
+ const running = await OllamaProvider.isServerRunning();
179
+ res.json({ ok: running, method: result.method });
180
+ } else {
181
+ res.status(500).json({ error: 'Could not start server', command: result.command });
182
+ }
183
+ });
184
+
185
+ app.post('/api/providers/ollama/stop', async (req, res) => {
186
+ if (!OllamaProvider.isInstalled()) return res.status(400).json({ error: 'Ollama is not installed' });
187
+ const running = await OllamaProvider.isServerRunning();
188
+ if (!running) return res.json({ ok: true, alreadyStopped: true });
189
+ const result = OllamaProvider.stopServer();
190
+ await new Promise((r) => setTimeout(r, 1000));
191
+ const stillRunning = await OllamaProvider.isServerRunning();
192
+ res.json({ ok: !stillRunning, method: result.method });
193
+ });
194
+
195
+ app.post('/api/providers/ollama/restart', async (req, res) => {
196
+ if (!OllamaProvider.isInstalled()) return res.status(400).json({ error: 'Ollama is not installed' });
197
+ // Stop
198
+ const running = await OllamaProvider.isServerRunning();
199
+ if (running) {
200
+ OllamaProvider.stopServer();
201
+ await new Promise((r) => setTimeout(r, 1500));
202
+ }
203
+ // Start
204
+ const result = OllamaProvider.startServer();
205
+ if (result.started) {
206
+ await new Promise((r) => setTimeout(r, 2000));
207
+ const nowRunning = await OllamaProvider.isServerRunning();
208
+ res.json({ ok: nowRunning, method: result.method });
209
+ } else {
210
+ res.status(500).json({ error: 'Could not restart server' });
211
+ }
212
+ });
213
+
214
+ app.get('/api/providers/ollama/running', async (req, res) => {
215
+ if (!OllamaProvider.isInstalled()) return res.json({ models: [] });
216
+ const serverRunning = await OllamaProvider.isServerRunning();
217
+ if (!serverRunning) return res.json({ models: [] });
218
+ try {
219
+ const controller = new AbortController();
220
+ const timeout = setTimeout(() => controller.abort(), 5000);
221
+ const apiRes = await fetch('http://localhost:11434/api/ps', { signal: controller.signal });
222
+ clearTimeout(timeout);
223
+ if (!apiRes.ok) return res.json({ models: [] });
224
+ const data = await apiRes.json();
225
+ const models = (data.models || []).map((m) => ({
226
+ name: m.name || m.model || '',
227
+ size: m.size || 0,
228
+ vram: m.size_vram ?? m.size ?? 0,
229
+ expires: m.expires_at || null,
230
+ }));
231
+ res.json({ models });
232
+ } catch {
233
+ res.json({ models: OllamaProvider.getRunningModels() });
234
+ }
235
+ });
236
+
237
+ app.post('/api/providers/ollama/load', async (req, res) => {
238
+ const { model } = req.body;
239
+ if (!model) return res.status(400).json({ error: 'model is required' });
240
+ if (!isValidModelId(model)) return res.status(400).json({ error: 'Invalid model ID' });
241
+ if (!OllamaProvider.isInstalled()) return res.status(400).json({ error: 'Ollama is not installed' });
242
+ const serverRunning = await OllamaProvider.isServerRunning();
243
+ if (!serverRunning) return res.status(400).json({ error: 'Ollama server is not running' });
244
+ try {
245
+ await OllamaProvider.loadModel(model);
246
+ daemon.broadcast({ type: 'ollama:model:loaded', model });
247
+ daemon.audit.log('ollama.model.load', { model });
248
+ res.json({ ok: true, model });
249
+ } catch (err) {
250
+ daemon.broadcast({ type: 'model:error', model, error: err.message });
251
+ res.status(500).json({ error: `Failed to load model: ${err.message}` });
252
+ }
253
+ });
254
+
255
+ app.post('/api/providers/ollama/unload', async (req, res) => {
256
+ const { model } = req.body;
257
+ if (!model) return res.status(400).json({ error: 'model is required' });
258
+ if (!isValidModelId(model)) return res.status(400).json({ error: 'Invalid model ID' });
259
+ if (!OllamaProvider.isInstalled()) return res.status(400).json({ error: 'Ollama is not installed' });
260
+ const serverRunning = await OllamaProvider.isServerRunning();
261
+ if (!serverRunning) return res.status(400).json({ error: 'Ollama server is not running' });
262
+ try {
263
+ await OllamaProvider.unloadModel(model);
264
+ daemon.broadcast({ type: 'ollama:model:unloaded', model });
265
+ daemon.audit.log('ollama.model.unload', { model });
266
+ res.json({ ok: true });
267
+ } catch (err) {
268
+ daemon.broadcast({ type: 'model:error', model, error: err.message });
269
+ res.status(500).json({ error: `Failed to unload model: ${err.message}` });
270
+ }
271
+ });
272
+
273
+ // --- Provider Management (install, login, set-path, verify) ---
274
+
275
+ const MANAGEABLE_PROVIDERS = new Set(['claude-code', 'codex', 'gemini']);
276
+
277
+ app.post('/api/providers/:id/install', (req, res) => {
278
+ const { id } = req.params;
279
+ if (!MANAGEABLE_PROVIDERS.has(id)) {
280
+ return res.status(400).json({ error: `Invalid provider. Valid: ${[...MANAGEABLE_PROVIDERS].join(', ')}` });
281
+ }
282
+
283
+ const INSTALL_PACKAGES = {
284
+ 'claude-code': '@anthropic-ai/claude-code',
285
+ 'codex': '@openai/codex',
286
+ 'gemini': '@google/gemini-cli',
287
+ };
288
+ const pkg = INSTALL_PACKAGES[id];
289
+
290
+ res.setHeader('Content-Type', 'application/x-ndjson');
291
+ res.setHeader('Transfer-Encoding', 'chunked');
292
+ res.setHeader('Cache-Control', 'no-cache');
293
+
294
+ const write = (obj) => {
295
+ try { res.write(JSON.stringify(obj) + '\n'); } catch { /* client disconnected */ }
296
+ };
297
+
298
+ write({ status: 'installing', output: `Installing ${pkg}...`, progress: 0 });
299
+
300
+ const proc = spawn('bash', ['-lc', `npm install -g ${pkg}`], {
301
+ stdio: ['ignore', 'pipe', 'pipe'],
302
+ env: { ...process.env, NODE_ENV: undefined },
303
+ });
304
+
305
+ let output = '';
306
+ let errOutput = '';
307
+
308
+ proc.stdout.on('data', (data) => {
309
+ output += data.toString();
310
+ write({ status: 'installing', output: data.toString().trim(), progress: 50 });
311
+ });
312
+
313
+ proc.stderr.on('data', (data) => {
314
+ errOutput += data.toString();
315
+ const line = data.toString().trim();
316
+ if (line) write({ status: 'installing', output: line, progress: 50 });
317
+ });
318
+
319
+ proc.on('close', (code) => {
320
+ clearInstallCache();
321
+ const providerObj = getProvider(id);
322
+ const installed = providerObj ? providerObj.constructor.isInstalled() : false;
323
+
324
+ if (code === 0 && installed) {
325
+ write({ status: 'complete', output: `${pkg} installed successfully`, progress: 100, installed: true });
326
+ daemon.audit.log('provider.install', { provider: id, pkg, success: true });
327
+ daemon.broadcast({ type: 'provider:status-changed', provider: id });
328
+ } else {
329
+ const reason = code !== 0
330
+ ? (errOutput || output).slice(-500)
331
+ : 'Install succeeded but provider binary not found in PATH';
332
+ write({ status: 'error', output: reason, progress: 100, installed: false });
333
+ daemon.audit.log('provider.install', { provider: id, pkg, success: false, code });
334
+ }
335
+ res.end();
336
+ });
337
+
338
+ proc.on('error', (err) => {
339
+ write({ status: 'error', output: `Failed to start npm: ${err.message}`, progress: 100, installed: false });
340
+ res.end();
341
+ });
342
+
343
+ req.on('close', () => {
344
+ try { proc.kill(); } catch { /* already exited */ }
345
+ });
346
+ });
347
+
348
+ app.post('/api/providers/:id/login', async (req, res) => {
349
+ const { id } = req.params;
350
+ if (!MANAGEABLE_PROVIDERS.has(id)) {
351
+ return res.status(400).json({ error: `Invalid provider. Valid: ${[...MANAGEABLE_PROVIDERS].join(', ')}` });
352
+ }
353
+
354
+ if (id === 'gemini') {
355
+ return res.json({ status: 'not-supported', message: 'Gemini uses API key authentication. Set your key in Settings.' });
356
+ }
357
+
358
+ if (id === 'claude-code') {
359
+ const providerObj = getProvider(id);
360
+ if (!providerObj || !providerObj.constructor.isInstalled()) {
361
+ return res.status(400).json({ error: 'Claude Code is not installed. Install it first.' });
362
+ }
363
+ daemon.audit.log('provider.login.started', { provider: id });
364
+ try {
365
+ const result = await ClaudeCodeProvider.startLogin();
366
+ clearInstallCache();
367
+ daemon.broadcast({ type: 'provider:status-changed', provider: id });
368
+ return res.json(result);
369
+ } catch (err) {
370
+ return res.status(500).json({ status: 'error', error: err.message });
371
+ }
372
+ }
373
+
374
+ if (id === 'codex') {
375
+ const providerObj = getProvider(id);
376
+ if (!providerObj || !providerObj.constructor.isInstalled()) {
377
+ return res.status(400).json({ error: 'Codex is not installed. Install it first.' });
378
+ }
379
+
380
+ const { method, key } = req.body || {};
381
+
382
+ if (key) {
383
+ daemon.audit.log('provider.login.started', { provider: id, method: 'api-key' });
384
+ try {
385
+ const result = await providerObj.constructor.onKeySet(key);
386
+ clearInstallCache();
387
+ daemon.broadcast({ type: 'provider:status-changed', provider: id });
388
+ return res.json({ status: result.ok ? 'authenticated' : 'error', ...result });
389
+ } catch (err) {
390
+ return res.status(500).json({ status: 'error', error: err.message });
391
+ }
392
+ }
393
+
394
+ if (method === 'chatgpt-plus') {
395
+ daemon.audit.log('provider.login.started', { provider: id, method: 'chatgpt-plus' });
396
+ return new Promise((resolve) => {
397
+ let responded = false;
398
+ const respond = (data, status) => {
399
+ if (responded) return;
400
+ responded = true;
401
+ clearInstallCache();
402
+ daemon.broadcast({ type: 'provider:status-changed', provider: id });
403
+ if (status) res.status(status).json(data);
404
+ else res.json(data);
405
+ resolve();
406
+ };
407
+
408
+ const proc = spawn('codex', ['login'], {
409
+ stdio: ['pipe', 'pipe', 'pipe'],
410
+ shell: true,
411
+ });
412
+ proc.stdin.on('error', () => {});
413
+ let stdout = '';
414
+ let stderr = '';
415
+ proc.stdout.on('data', (d) => { stdout += d.toString(); });
416
+ proc.stderr.on('data', (d) => { stderr += d.toString(); });
417
+
418
+ const timeout = setTimeout(() => {
419
+ const urlMatch = (stdout + stderr).match(/https:\/\/\S+/);
420
+ respond(urlMatch
421
+ ? { status: 'pending', url: urlMatch[0], browserOpened: true }
422
+ : { status: 'pending', message: 'Login started — check your browser', browserOpened: true });
423
+ }, 5000);
424
+
425
+ proc.on('close', (code) => {
426
+ clearTimeout(timeout);
427
+ if (code === 0) {
428
+ let hasKey = false;
429
+ try {
430
+ const authPath = resolve(homedir(), '.codex', 'auth.json');
431
+ if (existsSync(authPath)) {
432
+ const auth = JSON.parse(readFileSync(authPath, 'utf8'));
433
+ const token = auth.OPENAI_API_KEY
434
+ || (auth.auth_mode === 'chatgpt' && auth.tokens?.id_token)
435
+ || null;
436
+ if (token) {
437
+ daemon.credentials.setKey('codex', token);
438
+ hasKey = true;
439
+ }
440
+ }
441
+ } catch { /* auth.json missing or malformed — login still succeeded */ }
442
+ respond({ status: 'authenticated', hasKey });
443
+ } else {
444
+ respond({ status: 'error', error: stderr.slice(-200) || `Login failed (exit ${code})` });
445
+ }
446
+ });
447
+
448
+ proc.on('error', (err) => {
449
+ clearTimeout(timeout);
450
+ respond({ status: 'error', error: err.message }, 500);
451
+ });
452
+ });
453
+ }
454
+
455
+ return res.status(400).json({ error: 'Provide either { key: "..." } or { method: "chatgpt-plus" }' });
456
+ }
457
+ });
458
+
459
+ app.post('/api/providers/:id/set-path', async (req, res) => {
460
+ const { id } = req.params;
461
+ if (!MANAGEABLE_PROVIDERS.has(id)) {
462
+ return res.status(400).json({ error: `Invalid provider. Valid: ${[...MANAGEABLE_PROVIDERS].join(', ')}` });
463
+ }
464
+
465
+ const { path: customPath } = req.body || {};
466
+ if (!customPath || typeof customPath !== 'string') {
467
+ return res.status(400).json({ error: 'path is required' });
468
+ }
469
+ if (customPath.length > 500) {
470
+ return res.status(400).json({ error: 'Path too long' });
471
+ }
472
+ if (!isAbsolute(customPath)) {
473
+ return res.status(400).json({ error: 'Path must be absolute' });
474
+ }
475
+
476
+ if (!existsSync(customPath)) {
477
+ return res.status(400).json({ error: `Path does not exist: ${customPath}` });
478
+ }
479
+
480
+ try {
481
+ const stat = statSync(customPath);
482
+ if (!stat.isFile()) {
483
+ return res.status(400).json({ error: 'Path must point to a file, not a directory' });
484
+ }
485
+ const mode = stat.mode;
486
+ const isExecutable = !!(mode & 0o111);
487
+ if (!isExecutable) {
488
+ return res.status(400).json({ error: 'File is not executable' });
489
+ }
490
+ } catch (err) {
491
+ return res.status(400).json({ error: `Cannot stat path: ${err.message}` });
492
+ }
493
+
494
+ if (!daemon.config.providerPaths) daemon.config.providerPaths = {};
495
+ daemon.config.providerPaths[id] = customPath;
496
+
497
+ const { saveConfig } = await import('../firstrun.js');
498
+ saveConfig(daemon.grooveDir, daemon.config);
499
+
500
+ setProviderPaths(daemon.config.providerPaths);
501
+ clearInstallCache();
502
+
503
+ daemon.audit.log('provider.setPath', { provider: id, path: customPath });
504
+ daemon.broadcast({ type: 'provider:status-changed', provider: id });
505
+
506
+ res.json({ ok: true, path: customPath });
507
+ });
508
+
509
+ app.post('/api/providers/:id/verify', async (req, res) => {
510
+ const { id } = req.params;
511
+ if (!MANAGEABLE_PROVIDERS.has(id)) {
512
+ return res.status(400).json({ error: `Invalid provider. Valid: ${[...MANAGEABLE_PROVIDERS].join(', ')}` });
513
+ }
514
+
515
+ clearInstallCache();
516
+ const providerObj = getProvider(id);
517
+ if (!providerObj) {
518
+ return res.json({ installed: false, authenticated: false, version: null, error: 'Unknown provider' });
519
+ }
520
+
521
+ const installed = providerObj.constructor.isInstalled();
522
+ let authenticated = false;
523
+ let version = null;
524
+ let error = null;
525
+
526
+ if (installed) {
527
+ const authStatus = providerObj.constructor.isAuthenticated?.();
528
+ authenticated = !!(authStatus?.authenticated);
529
+
530
+ const command = providerObj.constructor.command;
531
+ const customPath = getProviderPath(id);
532
+ const bin = customPath || command;
533
+
534
+ try {
535
+ version = execFileSync(bin, ['--version'], {
536
+ encoding: 'utf8',
537
+ timeout: 5000,
538
+ stdio: ['pipe', 'pipe', 'pipe'],
539
+ shell: true,
540
+ }).trim();
541
+ } catch (err) {
542
+ version = null;
543
+ error = `Version check failed: ${err.message?.slice(0, 200) || 'unknown error'}`;
544
+ }
545
+ } else {
546
+ error = 'Provider not installed';
547
+ }
548
+
549
+ daemon.broadcast({ type: 'provider:status-changed', provider: id });
550
+
551
+ res.json({ installed, authenticated, version, error });
552
+ });
553
+
554
+ // --- Local Models (GGUF via HuggingFace) ---
555
+
556
+ app.get('/api/models/installed', (req, res) => {
557
+ const installed = daemon.modelManager.getInstalled();
558
+ const llamaStatus = daemon.llamaServer.getStatus();
559
+ res.json({ models: installed, llamaServer: llamaStatus });
560
+ });
561
+
562
+ app.get('/api/models/search', async (req, res) => {
563
+ try {
564
+ const query = req.query.q || req.query.query || '';
565
+ if (!query) return res.status(400).json({ error: 'query parameter (q) is required' });
566
+ const results = await daemon.modelManager.search(query, {
567
+ limit: parseInt(req.query.limit) || 20,
568
+ });
569
+ res.json(results);
570
+ } catch (err) {
571
+ res.status(500).json({ error: err.message });
572
+ }
573
+ });
574
+
575
+ app.get('/api/models/:repoId(*)/files', async (req, res) => {
576
+ try {
577
+ const files = await daemon.modelManager.getModelFiles(req.params.repoId);
578
+ res.json(files);
579
+ } catch (err) {
580
+ res.status(500).json({ error: err.message });
581
+ }
582
+ });
583
+
584
+ app.post('/api/models/download', async (req, res) => {
585
+ try {
586
+ const { repoId, filename } = req.body;
587
+ if (!repoId || !filename) return res.status(400).json({ error: 'repoId and filename are required' });
588
+ // Start download in background — progress via WebSocket
589
+ daemon.modelManager.download(repoId, filename).catch(() => {});
590
+ daemon.audit.log('model.download', { repoId, filename });
591
+ res.json({ started: true, filename, repoId });
592
+ } catch (err) {
593
+ res.status(400).json({ error: err.message });
594
+ }
595
+ });
596
+
597
+ app.post('/api/models/download/cancel', (req, res) => {
598
+ const { filename } = req.body;
599
+ if (!filename) return res.status(400).json({ error: 'filename is required' });
600
+ const cancelled = daemon.modelManager.cancelDownload(filename);
601
+ res.json({ cancelled });
602
+ });
603
+
604
+ app.get('/api/models/downloads', (req, res) => {
605
+ res.json(daemon.modelManager.getActiveDownloads());
606
+ });
607
+
608
+ app.delete('/api/models/:id', (req, res) => {
609
+ const deleted = daemon.modelManager.deleteModel(req.params.id);
610
+ if (deleted) {
611
+ daemon.audit.log('model.delete', { id: req.params.id });
612
+ res.json({ ok: true });
613
+ } else {
614
+ res.status(404).json({ error: 'Model not found' });
615
+ }
616
+ });
617
+
618
+ app.post('/api/models/:id/import-to-ollama', async (req, res) => {
619
+ const model = daemon.modelManager.getModel(req.params.id);
620
+ if (!model) return res.status(404).json({ error: 'Model not found' });
621
+ const ggufPath = daemon.modelManager.getModelPath(req.params.id);
622
+ if (!ggufPath) return res.status(404).json({ error: 'Model file not found on disk' });
623
+ if (!OllamaProvider.isInstalled()) return res.status(400).json({ error: 'Ollama is not installed' });
624
+
625
+ const ollamaName = (model.id || model.filename.replace('.gguf', '')).toLowerCase().replace(/[^a-z0-9._-]/g, '-');
626
+ const modelfilePath = resolve(ggufPath + '.Modelfile');
627
+ try {
628
+ writeFileSync(modelfilePath, `FROM ${ggufPath}\n`);
629
+ const { execFileSync } = await import('child_process');
630
+ execFileSync('ollama', ['create', ollamaName, '-f', modelfilePath], { timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'] });
631
+ try { unlinkSync(modelfilePath); } catch {}
632
+ daemon.audit.log('model.import-ollama', { id: model.id, ollamaName });
633
+ daemon.broadcast({ type: 'ollama:model:imported', model: ollamaName });
634
+ res.json({ ok: true, ollamaName });
635
+ } catch (err) {
636
+ try { unlinkSync(modelfilePath); } catch {}
637
+ res.status(500).json({ error: `Import failed: ${err.message}` });
638
+ }
639
+ });
640
+
641
+ app.get('/api/models/recommend', (req, res) => {
642
+ const ramGb = parseInt(req.query.ram) || 16;
643
+ const quant = daemon.modelManager.recommendQuantization('7B', ramGb);
644
+ res.json({ recommendedQuantization: quant, ramGb });
645
+ });
646
+
647
+ app.get('/api/models/recommended', (req, res) => {
648
+ const hardware = OllamaProvider.getSystemHardware();
649
+ const catalog = OllamaProvider.catalog;
650
+ // Filter to models that fit in RAM — same threshold as hardware recommendation
651
+ // Apple Silicon unified memory handles these well, no aggressive headroom needed
652
+ const recommended = catalog
653
+ .filter((m) => m.ramGb <= hardware.totalRamGb)
654
+ .sort((a, b) => b.ramGb - a.ramGb) // Biggest that fits = best quality
655
+ .slice(0, 12);
656
+ res.json({ models: recommended, hardware });
657
+ });
658
+
659
+ // --- Ollama Running Models ---
660
+
661
+ app.get('/api/models/status', async (req, res) => {
662
+ const installed = OllamaProvider.isInstalled();
663
+ if (!installed) return res.json({ serverRunning: false, runningModels: [], installedModels: [], hardware: OllamaProvider.getSystemHardware() });
664
+ const serverRunning = await OllamaProvider.isServerRunning();
665
+ const runningModels = serverRunning ? OllamaProvider.getRunningModels() : [];
666
+ const installedModels = OllamaProvider.getInstalledModels();
667
+ const hardware = OllamaProvider.getSystemHardware();
668
+ res.json({ serverRunning, runningModels, installedModels, hardware });
669
+ });
670
+
671
+ app.get('/api/models/running', async (req, res) => {
672
+ if (!OllamaProvider.isInstalled()) return res.json([]);
673
+ const serverRunning = await OllamaProvider.isServerRunning();
674
+ if (!serverRunning) return res.json([]);
675
+ res.json(OllamaProvider.getRunningModels());
676
+ });
677
+
678
+ app.post('/api/models/:id/load', async (req, res) => {
679
+ const modelId = req.params.id;
680
+ if (!modelId) return res.status(400).json({ error: 'model id is required' });
681
+ if (!OllamaProvider.isInstalled()) return res.status(400).json({ error: 'Ollama is not installed' });
682
+ const serverRunning = await OllamaProvider.isServerRunning();
683
+ if (!serverRunning) return res.status(400).json({ error: 'Ollama server is not running' });
684
+ try {
685
+ const result = await OllamaProvider.loadModel(modelId);
686
+ daemon.broadcast({ type: 'model:loaded', model: modelId });
687
+ daemon.audit.log('model.load', { model: modelId });
688
+ res.json(result);
689
+ } catch (err) {
690
+ daemon.broadcast({ type: 'model:error', model: modelId, error: err.message });
691
+ res.status(500).json({ error: `Failed to load model: ${err.message}` });
692
+ }
693
+ });
694
+
695
+ app.post('/api/models/:id/unload', async (req, res) => {
696
+ const modelId = req.params.id;
697
+ if (!modelId) return res.status(400).json({ error: 'model id is required' });
698
+ if (!OllamaProvider.isInstalled()) return res.status(400).json({ error: 'Ollama is not installed' });
699
+ const serverRunning = await OllamaProvider.isServerRunning();
700
+ if (!serverRunning) return res.status(400).json({ error: 'Ollama server is not running' });
701
+ try {
702
+ const result = await OllamaProvider.unloadModel(modelId);
703
+ daemon.broadcast({ type: 'model:unloaded', model: modelId });
704
+ daemon.audit.log('model.unload', { model: modelId });
705
+ res.json(result);
706
+ } catch (err) {
707
+ daemon.broadcast({ type: 'model:error', model: modelId, error: err.message });
708
+ res.status(500).json({ error: `Failed to unload model: ${err.message}` });
709
+ }
710
+ });
711
+
712
+ app.get('/api/llama/status', (req, res) => {
713
+ res.json(daemon.llamaServer.getStatus());
714
+ });
715
+
716
+ app.get('/api/mlx/status', (req, res) => {
717
+ res.json(daemon.mlxServer.getStatus());
718
+ });
719
+
720
+ app.get('/api/lab/tools', (req, res) => {
721
+ res.json(daemon.modelLab.getInstalledTools());
722
+ });
723
+
724
+ app.post('/api/lab/tools/refresh', (req, res) => {
725
+ res.json(daemon.modelLab.refreshInstalledTools());
726
+ });
727
+
728
+ // --- Credentials ---
729
+
730
+ app.get('/api/credentials', (req, res) => {
731
+ res.json(daemon.credentials.listProviders());
732
+ });
733
+
734
+ app.post('/api/credentials/:provider', async (req, res) => {
735
+ if (!req.body.key) return res.status(400).json({ error: 'key is required' });
736
+ daemon.credentials.setKey(req.params.provider, req.body.key);
737
+ daemon.audit.log('credential.set', { provider: req.params.provider });
738
+
739
+ // Provider-specific auth setup (e.g., Codex auto-login)
740
+ const provider = getProvider(req.params.provider);
741
+ let authResult = null;
742
+ if (provider?.constructor?.onKeySet) {
743
+ try { authResult = await provider.constructor.onKeySet(req.body.key); } catch { /* best effort */ }
744
+ }
745
+
746
+ res.json({ ok: true, masked: daemon.credentials.mask(req.body.key), auth: authResult });
747
+ });
748
+
749
+ app.delete('/api/credentials/:provider', (req, res) => {
750
+ daemon.credentials.deleteKey(req.params.provider);
751
+ daemon.audit.log('credential.delete', { provider: req.params.provider });
752
+ res.json({ ok: true });
753
+ });
754
+
755
+ }