mouaif 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/bin/mouaif.js +281 -0
  4. package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
  5. package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
  6. package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
  7. package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
  8. package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
  9. package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
  10. package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
  11. package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
  12. package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
  13. package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
  14. package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
  15. package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
  16. package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
  17. package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
  18. package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
  19. package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
  20. package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
  21. package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
  22. package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
  23. package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
  24. package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
  25. package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
  26. package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
  27. package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
  28. package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
  29. package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
  30. package/frontend/dist/assets/index-CANPYzQg.css +1 -0
  31. package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
  32. package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
  33. package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
  34. package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
  35. package/frontend/dist/icons/favicon-32.png +0 -0
  36. package/frontend/dist/icons/icon-180-apple.png +0 -0
  37. package/frontend/dist/icons/icon-192.png +0 -0
  38. package/frontend/dist/icons/icon-512.png +0 -0
  39. package/frontend/dist/icons/icon-maskable-512.png +0 -0
  40. package/frontend/dist/index.html +83 -0
  41. package/frontend/dist/manifest.webmanifest +33 -0
  42. package/frontend/dist/sw.js +482 -0
  43. package/package.json +98 -0
  44. package/scripts/patch-zimmerframe.js +58 -0
  45. package/src/access-auth.js +515 -0
  46. package/src/agentFeatures.js +294 -0
  47. package/src/agentFiles.js +164 -0
  48. package/src/agentSkills.js +147 -0
  49. package/src/agents.js +230 -0
  50. package/src/ai-chat.js +21 -0
  51. package/src/ai-endpoints.js +1880 -0
  52. package/src/ai-stream.js +2048 -0
  53. package/src/ai.js +68 -0
  54. package/src/auth.js +391 -0
  55. package/src/chatdb.js +816 -0
  56. package/src/chats.js +275 -0
  57. package/src/custom-actions.js +65 -0
  58. package/src/files.js +431 -0
  59. package/src/hideFileContent.js +327 -0
  60. package/src/http-server.js +535 -0
  61. package/src/index.js +15 -0
  62. package/src/inspector.js +731 -0
  63. package/src/inspectorProfiles.js +503 -0
  64. package/src/live-chat.js +107 -0
  65. package/src/mcp.js +1517 -0
  66. package/src/messages.js +238 -0
  67. package/src/modelList.js +137 -0
  68. package/src/notifications.js +52 -0
  69. package/src/oauth-anthropic.js +280 -0
  70. package/src/oauth-github-copilot.js +417 -0
  71. package/src/oauth-mcp.js +216 -0
  72. package/src/oauth-openrouter.js +285 -0
  73. package/src/package-version.js +20 -0
  74. package/src/projects.js +285 -0
  75. package/src/promptProfiles.js +256 -0
  76. package/src/prompts.js +384 -0
  77. package/src/providerShapes.js +44 -0
  78. package/src/providers/base.js +41 -0
  79. package/src/providers/index.js +25 -0
  80. package/src/push.js +315 -0
  81. package/src/qr.js +192 -0
  82. package/src/restart.js +47 -0
  83. package/src/server-handlers-access.js +306 -0
  84. package/src/server-handlers-actions.js +100 -0
  85. package/src/server-handlers-ai.js +248 -0
  86. package/src/server-handlers-auth.js +273 -0
  87. package/src/server-handlers-chats.js +1436 -0
  88. package/src/server-handlers-git.js +467 -0
  89. package/src/server-handlers-mcp-oauth.js +56 -0
  90. package/src/server-handlers-misc.js +783 -0
  91. package/src/server-handlers-projects.js +289 -0
  92. package/src/server-handlers-prompts.js +259 -0
  93. package/src/server-handlers-push.js +102 -0
  94. package/src/server-handlers-settings.js +406 -0
  95. package/src/server-handlers-tools.js +654 -0
  96. package/src/server-handlers-transcribe.js +399 -0
  97. package/src/server-shared.js +780 -0
  98. package/src/server-web-static.js +191 -0
  99. package/src/settings.js +898 -0
  100. package/src/statusBar.js +541 -0
  101. package/src/tags.js +414 -0
  102. package/src/toolFeedback.js +225 -0
  103. package/src/tools/ask.js +154 -0
  104. package/src/tools/authorization.js +932 -0
  105. package/src/tools/files.js +1150 -0
  106. package/src/tools/progress.js +71 -0
  107. package/src/tools/restart.js +32 -0
  108. package/src/tools/searchEngine.js +957 -0
  109. package/src/tools/shell.js +341 -0
  110. package/src/tools/subagent.js +47 -0
  111. package/src/tools/task.js +234 -0
  112. package/src/tools/webpreview.js +448 -0
  113. package/src/trace.js +103 -0
  114. package/src/transcribe.js +683 -0
  115. package/src/usage.js +389 -0
  116. package/src/util.js +151 -0
@@ -0,0 +1,406 @@
1
+ 'use strict';
2
+
3
+ // Settings REST handlers. Extracted from the original single-file
4
+ // http-server.js so no file stays above ~2 000 lines. See
5
+ // src/server-shared.js for the shared helpers (sendJSON, readJsonBody,
6
+ // settingsForClient, sanitizeClientEntries, ...).
7
+
8
+ const {
9
+ sendJSON,
10
+ qs,
11
+ readJsonOr400,
12
+ settingsForClient,
13
+ projectForClient,
14
+ sanitizeClientEntries,
15
+ connectionForClient,
16
+ modelForClient,
17
+ RESETTABLE_APP_KEYS,
18
+ settings,
19
+ ai
20
+ } = require('./server-shared.js');
21
+
22
+ async function handleSettings(req, res, parsed) {
23
+ const urlPath = parsed.pathname;
24
+ const method = req.method;
25
+ const q = parsed.query || {};
26
+
27
+ // GET /api/settings -> { app, defaults, home }
28
+ if (urlPath === '/api/settings' && method === 'GET') {
29
+ return sendJSON(res, 200, {
30
+ home: settings.MOUAIF_HOME,
31
+ defaults: settingsForClient(settings.DEFAULTS),
32
+ app: settingsForClient(settings.getApp())
33
+ });
34
+ }
35
+
36
+ // GET /api/settings/resolved?projectDir=<abs path>
37
+ if (urlPath === '/api/settings/resolved' && method === 'GET') {
38
+ const dir = qs(q, 'projectDir');
39
+ if (!dir) return sendJSON(res, 400, { error: 'projectDir query param is required' });
40
+ try {
41
+ return sendJSON(res, 200, { resolved: projectForClient(settings.getResolved(dir)) });
42
+ } catch (e) {
43
+ const status = e.code === 'MOUAIF_PROJECT_PARSE_ERROR' ? 422 : 500;
44
+ return sendJSON(res, status, { error: e.message, code: e.code || 'INTERNAL' });
45
+ }
46
+ }
47
+
48
+ // GET /api/settings/project?projectDir=<abs path> -> { project, path, dbBacked }
49
+ // Raw project file (no app merge, no defaults). The UI uses this to
50
+ // show the project-level values separately from the resolved view.
51
+ if (urlPath === '/api/settings/project' && method === 'GET') {
52
+ const dir = qs(q, 'projectDir');
53
+ if (!dir) return sendJSON(res, 400, { error: 'projectDir query param is required' });
54
+ try {
55
+ const dbBacked = settings.isDbBacked(dir);
56
+ return sendJSON(res, 200, {
57
+ project: projectForClient(settings.getProject(dir)),
58
+ path: dbBacked ? null : settings.getProjectPath(dir),
59
+ dbBacked
60
+ });
61
+ } catch (e) {
62
+ const status = e.code === 'MOUAIF_PROJECT_PARSE_ERROR' ? 422 : 500;
63
+ return sendJSON(res, status, { error: e.message, code: e.code || 'INTERNAL' });
64
+ }
65
+ }
66
+
67
+ // PUT /api/settings/app body: { ...patch } (shallow merge into app store)
68
+ if (urlPath === '/api/settings/app' && method === 'PUT') {
69
+ const patch = await readJsonOr400(req, res);
70
+ if (!patch) return;
71
+ try {
72
+ for (const key of ['providers', 'models']) {
73
+ sanitizeClientEntries(patch, key, settings.getApp()[key]);
74
+ }
75
+ const next = settings.setApp(patch);
76
+ return sendJSON(res, 200, { app: settingsForClient(next) });
77
+ } catch (e) {
78
+ return sendJSON(res, 400, { error: e.message });
79
+ }
80
+ }
81
+
82
+ // PUT /api/settings/project body: { projectDir, ...patch }
83
+ if (urlPath === '/api/settings/project' && method === 'PUT') {
84
+ const body = await readJsonOr400(req, res);
85
+ if (!body) return;
86
+ const { projectDir, unset, ...patch } = body || {};
87
+ if (!projectDir || typeof projectDir !== 'string') {
88
+ return sendJSON(res, 400, { error: 'projectDir is required' });
89
+ }
90
+ try {
91
+ // Same redaction contract as the app store: a round-tripped project
92
+ // snapshot must not persist the response-only `hasApiKey` marker,
93
+ // and an entry re-submitted without `apiKey` keeps the stored one.
94
+ const currentProject = settings.getProject(projectDir);
95
+ for (const key of ['providers', 'models']) {
96
+ sanitizeClientEntries(patch, key, currentProject[key]);
97
+ }
98
+ let next = Object.keys(patch).length ? settings.setProject(projectDir, patch) : currentProject;
99
+ if (Array.isArray(unset) && unset.length) next = settings.unsetProjectKeys(projectDir, unset);
100
+ const dbBacked = settings.isDbBacked(projectDir);
101
+ return sendJSON(res, 200, {
102
+ project: projectForClient(next),
103
+ path: dbBacked ? null : settings.getProjectPath(projectDir),
104
+ dbBacked
105
+ });
106
+ } catch (e) {
107
+ return sendJSON(res, 400, { error: e.message });
108
+ }
109
+ }
110
+
111
+ // POST /api/settings/app/providers body: { id, baseUrl?, apiKey?, auth?, oauthAccount? }
112
+ // Provider connections are app-level. Project model records reference
113
+ // them by `provider`, keeping credentials out of project files.
114
+ if (urlPath === '/api/settings/app/providers' && method === 'POST') {
115
+ const body = await readJsonOr400(req, res);
116
+ if (!body) return;
117
+ if (!body || typeof body !== 'object' || typeof body.id !== 'string' || !body.id.trim()) {
118
+ return sendJSON(res, 400, { error: 'id is required' });
119
+ }
120
+ if (!Object.prototype.hasOwnProperty.call(ai.ENDPOINTS, body.id)) {
121
+ return sendJSON(res, 400, { error: 'Unknown provider', id: body.id });
122
+ }
123
+ if (body.auth !== undefined && body.auth !== 'apikey' && body.auth !== 'oauth') {
124
+ return sendJSON(res, 400, { error: 'auth must be "apikey" or "oauth"' });
125
+ }
126
+ if (body.oauthAccount !== undefined && (typeof body.oauthAccount !== 'string' || body.oauthAccount.length > 256)) {
127
+ return sendJSON(res, 400, { error: 'oauthAccount must be a string' });
128
+ }
129
+ const app = settings.getApp();
130
+ const providers = Array.isArray(app.providers) ? app.providers.slice() : [];
131
+ const idx = providers.findIndex(p => p && p.id === body.id);
132
+ const merged = Object.assign({}, idx >= 0 ? providers[idx] : {}, body, { id: body.id.trim() });
133
+ delete merged.hasApiKey;
134
+ if (!Object.prototype.hasOwnProperty.call(body, 'apiKey') && idx >= 0 && providers[idx].apiKey) {
135
+ merged.apiKey = providers[idx].apiKey;
136
+ }
137
+ if (merged.auth === 'oauth') delete merged.apiKey;
138
+ if (idx >= 0) providers[idx] = merged; else providers.push(merged);
139
+ try {
140
+ const next = settings.setApp({ providers });
141
+ return sendJSON(res, 200, {
142
+ provider: connectionForClient(merged),
143
+ providers: next.providers.map(connectionForClient)
144
+ });
145
+ } catch (e) {
146
+ return sendJSON(res, 400, { error: e.message });
147
+ }
148
+ }
149
+
150
+ // DELETE /api/settings/app/providers/:id
151
+ const delProviderMatch = urlPath.match(/^\/api\/settings\/app\/providers\/([A-Za-z0-9._-]+)$/);
152
+ if (delProviderMatch && method === 'DELETE') {
153
+ const id = delProviderMatch[1];
154
+ const app = settings.getApp();
155
+ const providers = Array.isArray(app.providers) ? app.providers.slice() : [];
156
+ const idx = providers.findIndex(p => p && p.id === id);
157
+ if (idx < 0) return sendJSON(res, 404, { error: 'Provider not found', id });
158
+ const [removed] = providers.splice(idx, 1);
159
+ try {
160
+ settings.setApp({ providers });
161
+ return sendJSON(res, 200, {
162
+ ok: true,
163
+ removed: connectionForClient(removed),
164
+ providers: providers.map(connectionForClient)
165
+ });
166
+ } catch (e) {
167
+ return sendJSON(res, 400, { error: e.message });
168
+ }
169
+ }
170
+
171
+ // Legacy compatibility: app-level model CRUD is retained for older
172
+ // clients. New clients configure providers globally and models per project.
173
+ // POST /api/settings/app/models body: { ...model }
174
+ // Adds a model to the app-level models array, or merges into an
175
+ // existing entry with the same id. `id` is required; `provider` is
176
+ // required on add but optional on re-add (the existing entry is the
177
+ // source of truth for that field). Returns the merged model and the
178
+ // updated models array.
179
+ if (urlPath === '/api/settings/app/models' && method === 'POST') {
180
+ const body = await readJsonOr400(req, res);
181
+ if (!body) return;
182
+ if (!body || typeof body !== 'object' || !body.id || typeof body.id !== 'string') {
183
+ return sendJSON(res, 400, { error: 'id is required' });
184
+ }
185
+ // Light validation: auth enum, oauthAccount shape, and a sanity
186
+ // check that OAuth models don't carry a stale apiKey. We don't
187
+ // enforce provider↔auth combos here (e.g. anthropic + oauth is
188
+ // valid, openai-compatible + apikey is valid); that's the AI
189
+ // client's job at request time.
190
+ if (body.auth !== undefined && body.auth !== 'apikey' && body.auth !== 'oauth') {
191
+ return sendJSON(res, 400, { error: 'auth must be "apikey" or "oauth"' });
192
+ }
193
+ if (body.oauthAccount !== undefined && (typeof body.oauthAccount !== 'string' || body.oauthAccount.length > 256)) {
194
+ return sendJSON(res, 400, { error: 'oauthAccount must be a string' });
195
+ }
196
+ if (body.auth === 'oauth' && body.apiKey) {
197
+ // OAuth models must not carry a leftover apiKey from a previous
198
+ // apikey-mode entry. Drop it on merge rather than 400 — the user
199
+ // may have toggled auth modes and forgotten to clear the key.
200
+ delete body.apiKey;
201
+ }
202
+ const app = settings.getApp();
203
+ const models = Array.isArray(app.models) ? app.models.slice() : [];
204
+ const idx = models.findIndex(m => m && m.id === body.id);
205
+ if (idx < 0 && !body.provider) {
206
+ return sendJSON(res, 400, { error: 'provider is required when adding a new model' });
207
+ }
208
+ const merged = Object.assign({}, idx >= 0 ? models[idx] : {}, body);
209
+ // Same dedup for the merged result (an existing apikey record
210
+ // toggled to oauth would still carry apiKey from the prior entry).
211
+ if (merged.auth === 'oauth') delete merged.apiKey;
212
+ if (idx >= 0) models[idx] = merged; else models.push(merged);
213
+ try {
214
+ const next = settings.setApp({ models });
215
+ return sendJSON(res, 200, {
216
+ model: modelForClient(merged),
217
+ models: next.models.map(modelForClient)
218
+ });
219
+ } catch (e) {
220
+ return sendJSON(res, 400, { error: e.message });
221
+ }
222
+ }
223
+
224
+ // DELETE /api/settings/app/models/:id -> { ok, removed, models }
225
+ const delModelMatch = urlPath.match(/^\/api\/settings\/app\/models\/([A-Za-z0-9._-]+)$/);
226
+ if (delModelMatch && method === 'DELETE') {
227
+ const id = delModelMatch[1];
228
+ const app = settings.getApp();
229
+ const models = Array.isArray(app.models) ? app.models.slice() : [];
230
+ const idx = models.findIndex(m => m && m.id === id);
231
+ if (idx < 0) return sendJSON(res, 404, { error: 'Model not found', id });
232
+ const [removed] = models.splice(idx, 1);
233
+ try {
234
+ settings.setApp({ models });
235
+ return sendJSON(res, 200, {
236
+ ok: true,
237
+ removed: modelForClient(removed),
238
+ models: models.map(modelForClient)
239
+ });
240
+ } catch (e) {
241
+ return sendJSON(res, 400, { error: e.message });
242
+ }
243
+ }
244
+
245
+ // GET /api/settings/models/recent?projectDir=<abs>
246
+ // Returns the recent models list for the given project (newest first, capped at 20).
247
+ if (urlPath === '/api/settings/models/recent' && method === 'GET') {
248
+ const dir = qs(q, 'projectDir');
249
+ if (!dir) return sendJSON(res, 400, { error: 'projectDir query param is required' });
250
+ return sendJSON(res, 200, { recent: settings.getRecentModels(dir) });
251
+ }
252
+
253
+ // POST /api/settings/models/recent body: { projectDir, provider, modelId }
254
+ // Records a model as recently used (touches timestamp, deduplicates, caps at 20).
255
+ if (urlPath === '/api/settings/models/recent' && method === 'POST') {
256
+ const body = await readJsonOr400(req, res);
257
+ if (!body) return;
258
+ const { projectDir, provider, modelId } = body || {};
259
+ if (!projectDir || !provider || !modelId) {
260
+ return sendJSON(res, 400, { error: 'projectDir, provider, and modelId are required' });
261
+ }
262
+ if (!settings.canonicalProjectDir(projectDir)) {
263
+ return sendJSON(res, 400, { error: 'projectDir must be an absolute path' });
264
+ }
265
+ settings.touchRecentModel(projectDir, provider, modelId);
266
+ return sendJSON(res, 200, { ok: true });
267
+ }
268
+
269
+ // DELETE /api/settings/models/recent?projectDir=<abs>
270
+ // Clears the recent models list for the given project.
271
+ if (urlPath === '/api/settings/models/recent' && method === 'DELETE') {
272
+ const dir = qs(q, 'projectDir');
273
+ if (!dir) return sendJSON(res, 400, { error: 'projectDir query param is required' });
274
+ settings.clearRecentModels(dir);
275
+ return sendJSON(res, 200, { ok: true });
276
+ }
277
+
278
+ // POST /api/settings/app/reset body: { keys: ['models', 'flags'] }
279
+ // Clears the listed app-level keys, restoring them to defaults.
280
+ // Implementation: build a fresh patch that contains only the keys
281
+ // NOT in the reset list. setApp shallow-merges, so omitting a key
282
+ // from the patch is a no-op — we need a stronger reset, so we
283
+ // explicitly delete the keys from a clone of the current app and
284
+ // write that clone back.
285
+ if (urlPath === '/api/settings/app/reset' && method === 'POST') {
286
+ const body = await readJsonOr400(req, res);
287
+ if (!body) return;
288
+ const keys = Array.isArray(body && body.keys) ? body.keys : [];
289
+ const bad = keys.filter(k => !RESETTABLE_APP_KEYS.has(k));
290
+ if (bad.length) return sendJSON(res, 400, { error: 'Unknown key(s)', bad });
291
+ // Build a fresh object that omits the reset keys, then write it
292
+ // through. SQLite stores the new object verbatim (setApp uses
293
+ // { ...current, ...patch } internally; here we pass a patch that
294
+ // does NOT include the reset keys, but the merged result still
295
+ // contains them because the stored `current` does). So we need a
296
+ // explicit delete: a special-cased "replace" path. The simplest
297
+ // correct approach is to use the app store's underlying SQL: an
298
+ // UPSERT of an object that has the keys removed.
299
+ const current = settings.getApp();
300
+ const next = Object.assign({}, current);
301
+ for (const k of keys) delete next[k];
302
+ try {
303
+ // setApp is shallow-merge; passing the trimmed object and using
304
+ // a direct write would be ideal, but we don't expose a
305
+ // replace-at-the-root method. Cheapest correct option: pass an
306
+ // empty patch alongside a sentinel, OR just deep-copy the app
307
+ // and write the trimmed version through a new method.
308
+ // -> we add setAppReplace for this.
309
+ const result = settings.setAppReplace(next);
310
+ return sendJSON(res, 200, { app: settingsForClient(result), reset: keys });
311
+ } catch (e) {
312
+ return sendJSON(res, 400, { error: e.message });
313
+ }
314
+ }
315
+
316
+ // GET /api/settings/project/storage?projectDir=<abs>
317
+ // Reports where this project's settings live. `dbBacked: true` means the
318
+ // settings are persisted in the app SQLite store (no .mouaif.json written);
319
+ // `dbBacked: false` means the .mouaif.json file on disk.
320
+ if (urlPath === '/api/settings/project/storage' && method === 'GET') {
321
+ const dir = qs(q, 'projectDir');
322
+ if (!dir) return sendJSON(res, 400, { error: 'projectDir query param is required' });
323
+ try {
324
+ return sendJSON(res, 200, { dbBacked: settings.isDbBacked(dir) });
325
+ } catch (e) {
326
+ return sendJSON(res, 500, { error: e.message, code: e.code || 'INTERNAL' });
327
+ }
328
+ }
329
+
330
+ // PUT /api/settings/project/storage body: { projectDir, dbBacked }
331
+ // Moves a project between file-backed and DB-backed storage. Toggling on
332
+ // copies the current project object into the DB and leaves any existing
333
+ // .mouaif.json untouched (nothing is deleted). Toggling off writes the DB
334
+ // copy back to .mouaif.json and clears the DB row.
335
+ if (urlPath === '/api/settings/project/storage' && method === 'PUT') {
336
+ const body = await readJsonOr400(req, res);
337
+ if (!body) return;
338
+ const projectDir = body && typeof body.projectDir === 'string' ? body.projectDir : '';
339
+ if (!projectDir) return sendJSON(res, 400, { error: 'projectDir is required' });
340
+ if (typeof body.dbBacked !== 'boolean') {
341
+ return sendJSON(res, 400, { error: 'dbBacked must be a boolean' });
342
+ }
343
+ try {
344
+ if (body.dbBacked) {
345
+ // Seed from the existing file (if any), then flag as DB-backed.
346
+ settings.setDbProject(projectDir, { ...settings.getProjectRaw(projectDir), __dbBacked: true });
347
+ } else {
348
+ const current = settings.getDbProjectRaw(projectDir);
349
+ delete current.__dbBacked;
350
+ settings.writeProjectJson(settings.getProjectPath(projectDir), current);
351
+ // Delete through settings so the key is canonicalized: a raw
352
+ // `DELETE ... WHERE project_dir = ?` on the request path would miss
353
+ // the row when the client sent a trailing slash or `..` segment,
354
+ // leaving isDbBacked() true and the project reading stale DB settings.
355
+ settings.deleteDbProject(projectDir);
356
+ }
357
+ const dbBacked = settings.isDbBacked(projectDir);
358
+ return sendJSON(res, 200, {
359
+ dbBacked,
360
+ project: projectForClient(settings.getProject(projectDir)),
361
+ path: dbBacked ? null : settings.getProjectPath(projectDir)
362
+ });
363
+ } catch (e) {
364
+ return sendJSON(res, 400, { error: e.message });
365
+ }
366
+ }
367
+
368
+ // ---- Hide file content (redaction) --------------------------------------
369
+ // Per-project "hide file content" rules (docs/features/hide-file-content.md).
370
+ // The user marks line ranges of a project file that the agent file tools
371
+ // should not reveal. Stored on the project object under `hideFileContent`.
372
+ // GET /api/settings/hide-file-content?projectDir=<abs> -> { rules, project }
373
+ // PUT /api/settings/hide-file-content body { projectDir, rules }
374
+ // GET reads the normalized rules so the UI always sees a clean shape.
375
+ const hideFileContent = require('./hideFileContent.js');
376
+ if (urlPath === '/api/settings/hide-file-content' && method === 'GET') {
377
+ const dir = qs(q, 'projectDir');
378
+ if (!dir) return sendJSON(res, 400, { error: 'projectDir query param is required' });
379
+ try {
380
+ return sendJSON(res, 200, { rules: hideFileContent.getRules(dir) });
381
+ } catch (e) {
382
+ return sendJSON(res, 500, { error: e.message, code: e.code || 'INTERNAL' });
383
+ }
384
+ }
385
+ if (urlPath === '/api/settings/hide-file-content' && method === 'PUT') {
386
+ const body = await readJsonOr400(req, res);
387
+ if (!body) return;
388
+ const dir = body && typeof body.projectDir === 'string' ? body.projectDir : '';
389
+ if (!dir) return sendJSON(res, 400, { error: 'projectDir is required' });
390
+ if (!Array.isArray(body.rules)) {
391
+ return sendJSON(res, 400, { error: 'rules must be an array' });
392
+ }
393
+ // Normalize each entry; drop malformed ones. This keeps the stored value
394
+ // canonical so the file-tool reader and this endpoint never disagree.
395
+ const normalized = body.rules.map(hideFileContent.normalizeEntry).filter(Boolean);
396
+ try {
397
+ settings.setProject(dir, { hideFileContent: normalized });
398
+ return sendJSON(res, 200, { rules: hideFileContent.getRules(dir) });
399
+ } catch (e) {
400
+ return sendJSON(res, 400, { error: e.message });
401
+ }
402
+ }
403
+ return sendJSON(res, 404, { error: 'Not found', scope: 'settings' });
404
+ }
405
+
406
+ module.exports = { handleSettings };