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,289 @@
1
+ 'use strict';
2
+
3
+ // Projects + file editor + file tagging REST handlers. Extracted from
4
+ // the original single-file http-server.js. Shared helpers live in
5
+ // src/server-shared.js.
6
+
7
+ const os = require('os');
8
+ const path = require('path');
9
+ const {
10
+ sendJSON,
11
+ qs,
12
+ readJsonOr400,
13
+ errCodeToHttpStatus,
14
+ settings,
15
+ projects,
16
+ chats,
17
+ files,
18
+ tags,
19
+ safeDecode
20
+ } = require('./server-shared.js');
21
+
22
+ function projectsErrorStatus(err) {
23
+ return errCodeToHttpStatus(err && err.code, 400);
24
+ }
25
+
26
+ async function handleProjects(req, res, parsed) {
27
+ const urlPath = parsed.pathname;
28
+ const method = req.method;
29
+ const q = parsed.query || {};
30
+
31
+ // File editor (in-app CodeMirror popup). See handleFileEditor for the
32
+ // contract; dispatched here so /api/file, /api/files, and the
33
+ // image-preview /api/file-media route all win over the generic
34
+ // /api/projects routes below.
35
+ if (urlPath === '/api/file' || urlPath === '/api/files' || urlPath === '/api/file-media') {
36
+ return handleFileEditor(req, res, parsed);
37
+ }
38
+
39
+ // File tagging (docs/decisions.md §15). Routes live under a
40
+ // registered project id: /api/projects/:id/tags[/...]. Delegated to
41
+ // handleTags before the folder-picker / registered-project routes so
42
+ // the more specific path wins.
43
+ if (/^\/api\/projects\/[^/]+\/tags(\/.*)?$/.test(urlPath)) {
44
+ return handleTags(req, res, parsed);
45
+ }
46
+
47
+ // GET /api/projects?dir=<abs> -> list subdirs
48
+ if (urlPath === '/api/projects' && method === 'GET') {
49
+ const dir = typeof q.dir === 'string' && q.dir ? q.dir : os.homedir();
50
+ try {
51
+ const result = projects.listDir(dir);
52
+ return sendJSON(res, 200, result);
53
+ } catch (e) {
54
+ return sendJSON(res, projectsErrorStatus(e), { error: e.message, code: e.code, path: e.path || dir });
55
+ }
56
+ }
57
+
58
+ // GET /api/projects/registered
59
+ if (urlPath === '/api/projects/registered' && method === 'GET') {
60
+ return sendJSON(res, 200, { projects: projects.listProjects() });
61
+ }
62
+
63
+ // DELETE /api/projects/registered/:id
64
+ const delMatch = urlPath.match(/^\/api\/projects\/registered\/([A-Za-z0-9_-]+)$/);
65
+ if (delMatch && method === 'DELETE') {
66
+ const ok = projects.removeProject(delMatch[1]);
67
+ if (!ok) return sendJSON(res, 404, { error: 'Not found', id: delMatch[1] });
68
+ return sendJSON(res, 200, { ok: true });
69
+ }
70
+
71
+ // PATCH /api/projects/registered/:id body: { name }
72
+ if (delMatch && method === 'PATCH') {
73
+ const body = await readJsonOr400(req, res);
74
+ if (!body) return;
75
+ const name = body && typeof body.name === 'string' ? body.name : '';
76
+ if (!name.trim()) return sendJSON(res, 400, { error: 'name is required' });
77
+ const updated = projects.renameProject(delMatch[1], name);
78
+ if (!updated) return sendJSON(res, 404, { error: 'Not found', id: delMatch[1] });
79
+ return sendJSON(res, 200, { project: updated });
80
+ }
81
+
82
+ // POST /api/projects body: { action, ... }
83
+ if (urlPath === '/api/projects' && method === 'POST') {
84
+ const body = await readJsonOr400(req, res);
85
+ if (!body) return;
86
+ const action = body && body.action;
87
+ try {
88
+ if (action === 'list') {
89
+ const dir = typeof body.dir === 'string' && body.dir ? body.dir : os.homedir();
90
+ return sendJSON(res, 200, projects.listDir(dir));
91
+ }
92
+ if (action === 'create') {
93
+ if (typeof body.parent !== 'string' || typeof body.name !== 'string' || !body.name) {
94
+ return sendJSON(res, 400, { error: 'parent and name are required' });
95
+ }
96
+ const target = path.join(body.parent, body.name);
97
+ const out = projects.createDir(target);
98
+ return sendJSON(res, 201, { ...out, parent: body.parent, name: body.name });
99
+ }
100
+ if (action === 'register') {
101
+ if (typeof body.dir !== 'string' || !body.dir) {
102
+ return sendJSON(res, 400, { error: 'dir is required' });
103
+ }
104
+ const row = projects.registerProject(body.dir);
105
+ // Opt the project into DB-backed settings before seeding the cost so
106
+ // the working tree is never touched (see project-settings-storage.md).
107
+ // Use the REGISTERED path (projects.js canonicalizes it) rather than the
108
+ // raw request value: a trailing slash or a `..` segment would otherwise
109
+ // key the DB row under a path nothing else looks up, and the opt-in
110
+ // would silently do nothing.
111
+ if (body.dbBacked === true) {
112
+ settings.setDbBacked(row.path, true);
113
+ }
114
+ // Seed persisted chat and project totals once for existing histories.
115
+ try { chats.recomputeProjectTotalCost(body.dir); } catch { /* non-fatal */ }
116
+ return sendJSON(res, 200, { project: row, dbBacked: !!body.dbBacked });
117
+ }
118
+ return sendJSON(res, 400, { error: 'Unknown action', action });
119
+ } catch (e) {
120
+ return sendJSON(res, projectsErrorStatus(e), { error: e.message, code: e.code, path: e.path });
121
+ }
122
+ }
123
+
124
+ return sendJSON(res, 404, { error: 'Not found', scope: 'projects' });
125
+ }
126
+
127
+ // ---- File editor API ---------------------------------------------------
128
+ // Read + write text files inside a registered project folder, for the
129
+ // in-app CodeMirror editor popup. All paths are project-relative (or
130
+ // absolute under the project root). Both the project root and every
131
+ // read/write/list path go through files.resolveSafe, so the same
132
+ // home + MOUAIF_ALLOW_ANY_ROOT guard that protects the rest of the
133
+ // server applies here too.
134
+ //
135
+ // Endpoints:
136
+ // GET /api/files?projectDir=<abs>&dir=<abs> -> list a folder
137
+ // GET /api/file?projectDir=<abs>&path=<abs|rel> -> read a text file
138
+ // PUT /api/file body { projectDir, path, content } -> write a text file
139
+ // GET /api/file-media?projectDir=<abs>&path=<abs|rel> -> read an image as data URL
140
+ //
141
+ // Errors map to typed codes so the UI can render the right message
142
+ // (EBINARY -> "binary file, cannot edit", ETOOLARGE -> "file too big",
143
+ // EOUTSIDE_PROJECT -> 403, ENOTIMAGE -> "file is not an image", etc.).
144
+
145
+ function filesErrorStatus(err) {
146
+ return errCodeToHttpStatus(err && err.code, 400);
147
+ }
148
+
149
+ async function handleFileEditor(req, res, parsed) {
150
+ const urlPath = parsed.pathname;
151
+ const method = req.method;
152
+ const q = parsed.query || {};
153
+
154
+ // GET /api/files?projectDir=<abs>&dir=<abs> -> list a folder
155
+ if (urlPath === '/api/files' && method === 'GET') {
156
+ const projectDir = qs(q, 'projectDir');
157
+ const dir = qs(q, 'dir');
158
+ try {
159
+ return sendJSON(res, 200, files.listDir(projectDir, dir));
160
+ } catch (e) {
161
+ return sendJSON(res, filesErrorStatus(e), { error: e.message, code: e.code, path: e.path });
162
+ }
163
+ }
164
+
165
+ // GET /api/file?projectDir=<abs>&path=<abs|rel> -> read a text file
166
+ if (urlPath === '/api/file' && method === 'GET') {
167
+ const projectDir = qs(q, 'projectDir');
168
+ const path = qs(q, 'path');
169
+ try {
170
+ const out = await files.readFile(projectDir, path);
171
+ return sendJSON(res, 200, out);
172
+ } catch (e) {
173
+ return sendJSON(res, filesErrorStatus(e), { error: e.message, code: e.code, path: e.path, size: e.size, maxBytes: e.maxBytes });
174
+ }
175
+ }
176
+
177
+ // PUT /api/file body { projectDir, path, content } -> write a text file
178
+ if (urlPath === '/api/file' && method === 'PUT') {
179
+ const body = await readJsonOr400(req, res);
180
+ if (!body) return;
181
+ const projectDir = body && typeof body.projectDir === 'string' ? body.projectDir : '';
182
+ const path = body && typeof body.path === 'string' ? body.path : '';
183
+ const content = body && typeof body.content === 'string' ? body.content : null;
184
+ try {
185
+ const out = await files.writeFile(projectDir, path, content);
186
+ return sendJSON(res, 200, out);
187
+ } catch (e) {
188
+ return sendJSON(res, filesErrorStatus(e), { error: e.message, code: e.code, path: e.path, size: e.size, maxBytes: e.maxBytes });
189
+ }
190
+ }
191
+
192
+ // GET /api/file-media?projectDir=<abs>&path=<abs|rel>
193
+ // Read an image file (or other previewable binary) as a base64
194
+ // data URL. Used by the file editor popup to render an <img>
195
+ // preview for .png/.jpg/.gif/.webp/.svg/.bmp/.ico without needing
196
+ // a separate auth-bearing URL. The data URL is small (<= 1 MiB
197
+ // cap, same as the text read).
198
+ if (urlPath === '/api/file-media' && method === 'GET') {
199
+ const projectDir = qs(q, 'projectDir');
200
+ const path = qs(q, 'path');
201
+ try {
202
+ const out = await files.readMedia(projectDir, path);
203
+ return sendJSON(res, 200, out);
204
+ } catch (e) {
205
+ return sendJSON(res, filesErrorStatus(e), { error: e.message, code: e.code, path: e.path, size: e.size, maxBytes: e.maxBytes });
206
+ }
207
+ }
208
+ return sendJSON(res, 404, { error: 'Not found', scope: 'fileEditor' });
209
+ }
210
+
211
+ // ---- File tagging API ---------------------------------------------------
212
+ // Per-project file tags (docs/decisions.md §15). All routes hang off a
213
+ // registered project id so the UI never has to pass an absolute path:
214
+ // GET /api/projects/:id/tags -> { tags: { ... } }
215
+ // PUT /api/projects/:id/tags body { tags: {...} }
216
+ // POST /api/projects/:id/tags/scan body { exts? }
217
+ // DELETE /api/projects/:id/tags/files/<relPath>
218
+ // The project id resolves to its absolute dir via projects.getProject.
219
+ // Tag CRUD requires a registered project (404 otherwise). Path escapes
220
+ // return 403 EOUTSIDE_PROJECT.
221
+
222
+ function tagsErrorStatus(err) {
223
+ return errCodeToHttpStatus(err && err.code);
224
+ }
225
+
226
+ async function handleTags(req, res, parsed) {
227
+ const urlPath = parsed.pathname;
228
+ const method = req.method;
229
+
230
+ // Pull the project id out of the path and resolve to an absolute dir.
231
+ const idMatch = urlPath.match(/^\/api\/projects\/([^/]+)\/tags/);
232
+ if (!idMatch) return sendJSON(res, 404, { error: 'Not found', scope: 'tags' });
233
+ const projectId = safeDecode(idMatch[1]);
234
+ const project = projects.getProject(projectId);
235
+ if (!project) return sendJSON(res, 404, { error: 'Project not registered', id: projectId });
236
+ const dir = project.path;
237
+
238
+ const rest = urlPath.slice(idMatch[0].length); // '' | '/scan' | '/files/<rel>'
239
+
240
+ // GET /api/projects/:id/tags
241
+ if (rest === '' && method === 'GET') {
242
+ try {
243
+ return sendJSON(res, 200, { tags: tags.getTags(dir) });
244
+ } catch (e) {
245
+ return sendJSON(res, tagsErrorStatus(e), { error: e.message, code: e.code || 'INTERNAL' });
246
+ }
247
+ }
248
+
249
+ // PUT /api/projects/:id/tags body: { tags: { ... } }
250
+ if (rest === '' && method === 'PUT') {
251
+ const body = await readJsonOr400(req, res);
252
+ if (!body) return;
253
+ const map = body && typeof body.tags === 'object' && body.tags ? body.tags : {};
254
+ try {
255
+ return sendJSON(res, 200, { tags: tags.setTags(dir, map) });
256
+ } catch (e) {
257
+ return sendJSON(res, tagsErrorStatus(e), { error: e.message, code: e.code || 'INTERNAL' });
258
+ }
259
+ }
260
+
261
+ // POST /api/projects/:id/tags/scan body: { exts?: [...] }
262
+ if (rest === '/scan' && method === 'POST') {
263
+ const body = await readJsonOr400(req, res);
264
+ if (!body) return;
265
+ const exts = body && Array.isArray(body.exts) ? body.exts : null;
266
+ try {
267
+ return sendJSON(res, 200, { files: tags.scanFiles(dir, exts) });
268
+ } catch (e) {
269
+ return sendJSON(res, tagsErrorStatus(e), { error: e.message, code: e.code || 'INTERNAL' });
270
+ }
271
+ }
272
+
273
+ // DELETE /api/projects/:id/tags/files/<relPath>
274
+ const fileMatch = rest.match(/^\/files\/(.+)$/);
275
+ if (fileMatch && method === 'DELETE') {
276
+ const relPath = safeDecode(fileMatch[1]);
277
+ try {
278
+ const removed = tags.removeTag(dir, relPath);
279
+ if (!removed) return sendJSON(res, 404, { error: 'Tag entry not found', path: relPath });
280
+ return sendJSON(res, 200, { ok: true, removed: relPath });
281
+ } catch (e) {
282
+ return sendJSON(res, tagsErrorStatus(e), { error: e.message, code: e.code || 'INTERNAL' });
283
+ }
284
+ }
285
+
286
+ return sendJSON(res, 404, { error: 'Not found', scope: 'tags' });
287
+ }
288
+
289
+ module.exports = { handleProjects, handleFileEditor, handleTags };
@@ -0,0 +1,259 @@
1
+ 'use strict';
2
+ // Prompts + agent features + project agents REST handlers. Extracted
3
+ // from the original single-file http-server.js. Shared helpers live in
4
+ // src/server-shared.js.
5
+ const {
6
+ sendJSON,
7
+ qs,
8
+ readJsonOr400,
9
+ errCodeToHttpStatus,
10
+ settings,
11
+ prompts,
12
+ chats,
13
+ agents,
14
+ agentFeatures,
15
+ safeDecode
16
+ } = require('./server-shared.js');
17
+
18
+ // ---- Prompts API ---------------------------------------------------------
19
+ // Custom prompts. Stored in the app SQLite database or in project.prompts.
20
+ // Routes:
21
+ // GET /api/prompts[?projectDir=<abs>][&scope=app|project] -> { prompts }
22
+ // GET /api/prompts/:id[?projectDir=<abs>][&scope=app|project] -> { prompt }
23
+ // POST /api/prompts body: { projectDir?, scope?, title?, content, role? } -> { prompt }
24
+ // PATCH /api/prompts/:id body: { projectDir?, scope?, title?, content?, role? } -> { prompt }
25
+ // DELETE /api/prompts/:id[?projectDir=<abs>][&scope=app|project] -> { ok: true }
26
+ async function handlePrompts(req, res, parsed) {
27
+ const urlPath = parsed.pathname;
28
+ const method = req.method;
29
+ const q = parsed.query || {};
30
+
31
+ function projectDirFrom(body) {
32
+ const fromQuery = qs(q, 'projectDir');
33
+ const fromBody = body && typeof body.projectDir === 'string' ? body.projectDir : '';
34
+ return fromQuery || fromBody;
35
+ }
36
+
37
+ function promptError(e) {
38
+ return errCodeToHttpStatus(e && e.code);
39
+ }
40
+
41
+ // GET /api/prompts[?projectDir=<abs>]
42
+ if (urlPath === '/api/prompts' && method === 'GET') {
43
+ const dir = qs(q, 'projectDir');
44
+ const scope = qs(q, 'scope');
45
+ try {
46
+ return sendJSON(res, 200, { prompts: prompts.listPrompts(dir, { scope }) });
47
+ } catch (e) {
48
+ return sendJSON(res, promptError(e), { error: e.message, code: e.code || 'INTERNAL' });
49
+ }
50
+ }
51
+
52
+ // GET /api/prompts/:id[?projectDir=<abs>]
53
+ const getMatch = urlPath.match(/^\/api\/prompts\/([^/]+)$/);
54
+ if (getMatch && method === 'GET') {
55
+ const id = safeDecode(getMatch[1]);
56
+ const dir = qs(q, 'projectDir');
57
+ const scope = qs(q, 'scope');
58
+ try {
59
+ const p = prompts.getPrompt(dir, id, { scope });
60
+ if (!p) return sendJSON(res, 404, { error: 'Prompt not found', id });
61
+ return sendJSON(res, 200, { prompt: p });
62
+ } catch (e) {
63
+ return sendJSON(res, promptError(e), { error: e.message, code: e.code || 'INTERNAL' });
64
+ }
65
+ }
66
+
67
+ // POST /api/prompts body: { projectDir?, scope?, title?, content, role? }
68
+ if (urlPath === '/api/prompts' && method === 'POST') {
69
+ const body = await readJsonOr400(req, res);
70
+ if (!body) return;
71
+ const dir = projectDirFrom(body);
72
+ const scope = body.scope || (dir ? 'project' : 'app');
73
+ if (!dir && scope === 'project') {
74
+ return sendJSON(res, 400, { error: 'projectDir is required for project-scoped prompts' });
75
+ }
76
+ try {
77
+ const p = prompts.createPrompt(dir, body || {});
78
+ return sendJSON(res, 201, { prompt: p });
79
+ } catch (e) {
80
+ return sendJSON(res, promptError(e), { error: e.message, code: e.code || 'INTERNAL' });
81
+ }
82
+ }
83
+
84
+ // PATCH /api/prompts/:id body: { projectDir?, scope?, title?, content?, role? }
85
+ if (getMatch && method === 'PATCH') {
86
+ const id = safeDecode(getMatch[1]);
87
+ const body = await readJsonOr400(req, res);
88
+ if (!body) return;
89
+ const dir = projectDirFrom(body);
90
+ try {
91
+ const p = prompts.updatePrompt(dir, id, body || {});
92
+ if (!p) return sendJSON(res, 404, { error: 'Prompt not found', id });
93
+ return sendJSON(res, 200, { prompt: p });
94
+ } catch (e) {
95
+ return sendJSON(res, promptError(e), { error: e.message, code: e.code || 'INTERNAL' });
96
+ }
97
+ }
98
+
99
+ // DELETE /api/prompts/:id[?projectDir=<abs>]
100
+ if (getMatch && method === 'DELETE') {
101
+ const id = safeDecode(getMatch[1]);
102
+ const dir = qs(q, 'projectDir');
103
+ const scope = qs(q, 'scope');
104
+ try {
105
+ let clearedChats = 0;
106
+ const removed = prompts.deletePrompt(dir, id, {
107
+ scope,
108
+ onRemoved: (deletedId) => {
109
+ if (dir) {
110
+ clearedChats = chats.clearPromptId(dir, deletedId);
111
+ } else {
112
+ try {
113
+ const allProjects = require('./projects.js').listProjects();
114
+ for (const prj of allProjects) {
115
+ if (prj && prj.path) {
116
+ clearedChats += chats.clearPromptId(prj.path, deletedId);
117
+ }
118
+ }
119
+ } catch { /* non-fatal */ }
120
+ }
121
+ }
122
+ });
123
+ if (!removed) return sendJSON(res, 404, { error: 'Prompt not found', id });
124
+ return sendJSON(res, 200, { ok: true, removed: id, clearedChats });
125
+ } catch (e) {
126
+ return sendJSON(res, promptError(e), { error: e.message, code: e.code || 'INTERNAL' });
127
+ }
128
+ }
129
+
130
+ return sendJSON(res, 404, { error: 'Not found', scope: 'prompts' });
131
+ }
132
+
133
+ // ---- Agent features API ------------------------------------------------
134
+ // Structured state of every mouaif feature for a project. Returns the
135
+ // same data the `list_features` tool provides, without requiring a chat.
136
+ // Routes:
137
+ // GET /api/features?projectDir=<abs> -> { features: { ... } }
138
+ async function handleFeatures(req, res, parsed) {
139
+ const dir = (parsed.query && parsed.query.projectDir || '').trim();
140
+ if (!dir) return sendJSON(res, 400, { error: 'projectDir query param is required' });
141
+ try {
142
+ const project = require('./settings.js').getProject(dir);
143
+ let authz = null;
144
+ try { authz = require('./tools/authorization.js').getAuthorization(dir); } catch { /* safe default */ }
145
+ let mcpServers = null;
146
+ try { mcpServers = require('./mcp.js').listServers(dir); } catch { /* safe default */ }
147
+ const featureContent = agentFeatures.buildFeatureSummary({ projectDir: dir, project, authz, mcpServers });
148
+ const jsonState = await agentFeatures.dispatchListFeatures({}, { projectDir: dir });
149
+ sendJSON(res, 200, {
150
+ features: (jsonState && jsonState.result) || {},
151
+ summary: featureContent
152
+ });
153
+ } catch (e) {
154
+ sendJSON(res, 500, { error: e.message, code: 'EINTERNAL' });
155
+ }
156
+ }
157
+
158
+ // ---- Project agents API -----------------------------------------------
159
+ // Project-scoped named personas stored in .mouaif.json under `agents`.
160
+ // Each agent = { name, content, tools?, modelId? } — a delegation target
161
+ // for the native `subagent` tool, nothing else. See docs/features/agents.md.
162
+ // Routes:
163
+ // GET /api/agents?projectDir=<abs> -> { agents }
164
+ // POST /api/agents body: { projectDir, name, content, tools?, modelId? }
165
+ // GET /api/agents/:name?projectDir=<abs> -> { agent } | 404
166
+ // PATCH /api/agents/:name body: { projectDir, name?, content?, tools?, modelId? }
167
+ // DELETE /api/agents/:name?projectDir=<abs>
168
+ async function handleAgents(req, res, parsed) {
169
+ const urlPath = parsed.pathname;
170
+ const method = req.method;
171
+ const q = parsed.query || {};
172
+
173
+ function agentDirFrom(body) {
174
+ const fromQuery = qs(q, 'projectDir');
175
+ const fromBody = body && typeof body.projectDir === 'string' ? body.projectDir : '';
176
+ return fromQuery || fromBody;
177
+ }
178
+
179
+ function agentError(e) {
180
+ return errCodeToHttpStatus(e && e.code);
181
+ }
182
+
183
+ if (method !== 'GET' && method !== 'POST' && method !== 'PATCH' && method !== 'DELETE') {
184
+ return sendJSON(res, 405, { error: 'Method not allowed' });
185
+ }
186
+
187
+ // GET/DELETE carry projectDir in the query string; POST/PATCH carry
188
+ // it in the JSON body. Only the query-string routes can 400 up front.
189
+ const dir = qs(q, 'projectDir');
190
+
191
+ // GET /api/agents?projectDir=...
192
+ if (urlPath === '/api/agents' && method === 'GET') {
193
+ if (!dir) return sendJSON(res, 400, { error: 'projectDir query param is required' });
194
+ try {
195
+ return sendJSON(res, 200, { agents: agents.list(dir) });
196
+ } catch (e) {
197
+ return sendJSON(res, agentError(e), { error: e.message, code: e.code || 'INTERNAL' });
198
+ }
199
+ }
200
+
201
+ // POST /api/agents body: { projectDir, name, content, tools? }
202
+ if (urlPath === '/api/agents' && method === 'POST') {
203
+ const body = await readJsonOr400(req, res);
204
+ if (!body) return;
205
+ const projectDir = agentDirFrom(body);
206
+ if (!projectDir) return sendJSON(res, 400, { error: 'projectDir is required' });
207
+ try {
208
+ const agent = agents.create(projectDir, body || {});
209
+ return sendJSON(res, 201, { agent });
210
+ } catch (e) {
211
+ const status = e.code === 'EBADINPUT' ? 400 : 500;
212
+ return sendJSON(res, status, { error: e.message, code: e.code || 'INTERNAL' });
213
+ }
214
+ }
215
+
216
+ // GET|PATCH|DELETE /api/agents/:name
217
+ let getMatch = urlPath.match(/^\/api\/agents\/([^/]+)$/);
218
+ if (getMatch) {
219
+ const name = safeDecode(getMatch[1]);
220
+ if (method === 'GET') {
221
+ if (!dir) return sendJSON(res, 400, { error: 'projectDir query param is required' });
222
+ try {
223
+ const agent = agents.get(dir, name);
224
+ if (!agent) return sendJSON(res, 404, { error: 'Agent not found', name });
225
+ return sendJSON(res, 200, { agent });
226
+ } catch (e) {
227
+ return sendJSON(res, agentError(e), { error: e.message, code: e.code || 'INTERNAL' });
228
+ }
229
+ }
230
+ if (method === 'PATCH') {
231
+ const body = await readJsonOr400(req, res);
232
+ if (!body) return;
233
+ const patchDir = dir || (body && typeof body.projectDir === 'string' ? body.projectDir : '');
234
+ if (!patchDir) return sendJSON(res, 400, { error: 'projectDir is required' });
235
+ try {
236
+ const agent = agents.update(patchDir, name, body || {});
237
+ if (!agent) return sendJSON(res, 404, { error: 'Agent not found', name });
238
+ return sendJSON(res, 200, { agent });
239
+ } catch (e) {
240
+ return sendJSON(res, agentError(e), { error: e.message, code: e.code || 'INTERNAL' });
241
+ }
242
+ }
243
+ if (method === 'DELETE') {
244
+ if (!dir) return sendJSON(res, 400, { error: 'projectDir query param is required' });
245
+ try {
246
+ const ok = agents.remove(dir, name);
247
+ if (!ok) return sendJSON(res, 404, { error: 'Agent not found', name });
248
+ return sendJSON(res, 200, { ok: true, removed: name });
249
+ } catch (e) {
250
+ return sendJSON(res, agentError(e), { error: e.message, code: e.code || 'INTERNAL' });
251
+ }
252
+ }
253
+ return sendJSON(res, 405, { error: 'Method not allowed' });
254
+ }
255
+
256
+ return sendJSON(res, 404, { error: 'Not found', scope: 'agents' });
257
+ }
258
+
259
+ module.exports = { handlePrompts, handleFeatures, handleAgents };
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+
3
+ // Browser push notification REST handlers. Extracted from the original
4
+ // single-file http-server.js. Shared helpers live in src/server-shared.js.
5
+
6
+ const { sendJSON, readJsonOr400, push } = require('./server-shared.js');
7
+
8
+ async function handlePush(req, res, parsed, sessionToken, servedOrigin) {
9
+ const urlPath = parsed.pathname;
10
+ const method = req.method;
11
+ const sid = push.sessionIdFromToken(sessionToken);
12
+
13
+ // GET /api/push/vapid-public-key
14
+ if (urlPath === '/api/push/vapid-public-key' && method === 'GET') {
15
+ const publicKey = push.getVapidPublicKey();
16
+ if (!publicKey) return sendJSON(res, 500, { error: 'VAPID keys not initialised', code: 'ENOVAPID' });
17
+ return sendJSON(res, 200, { publicKey });
18
+ }
19
+
20
+ // GET /api/push/config — safe notification configuration. Opening the
21
+ // settings screen also repairs a missing VAPID pair. The private key never
22
+ // leaves the server.
23
+ if (urlPath === '/api/push/config' && method === 'GET') {
24
+ try {
25
+ return sendJSON(res, 200, push.getPushConfig(servedOrigin));
26
+ } catch (e) {
27
+ return sendJSON(res, 500, { error: e.message, code: 'EVAPID' });
28
+ }
29
+ }
30
+
31
+ // POST /api/push/subscribe
32
+ if (urlPath === '/api/push/subscribe' && method === 'POST') {
33
+ if (!sid) return sendJSON(res, 401, { error: 'No session', code: 'ESESSION' });
34
+ const body = await readJsonOr400(req, res);
35
+ if (!body) return;
36
+ if (!body.subscription || !body.subscription.endpoint) {
37
+ return sendJSON(res, 400, { error: 'subscription with endpoint is required', code: 'EBADINPUT' });
38
+ }
39
+ const { endpoint, keys } = body.subscription;
40
+ if (!keys || !keys.p256dh || !keys.auth) {
41
+ return sendJSON(res, 400, { error: 'subscription must include keys.p256dh and keys.auth', code: 'EBADINPUT' });
42
+ }
43
+ // How this device presents a notification. The status bar is sized from
44
+ // these facts (src/statusBar.js): `chars` is the measured body line,
45
+ // `viewportWidth` keeps a measurement honest and scales the fallback,
46
+ // and `os` + `osVersion` pick how many lines the platform previews and
47
+ // the platform's own fallback width. `style` is 'collapsed' or
48
+ // 'expanded' so a one-line presentation puts the bar and message on one
49
+ // row. All optional; an omitted field keeps whatever this device
50
+ // reported last. `statusBarMaxChars` is the older, bare-count shape and
51
+ // stays supported.
52
+ const statusBarMaxChars = Number(body.subscription.statusBarMaxChars);
53
+ const statusBarProfile = body.subscription.statusBarProfile;
54
+ try {
55
+ const result = push.addSubscription({
56
+ sessionId: sid,
57
+ endpoint,
58
+ p256dh: keys.p256dh,
59
+ auth: keys.auth,
60
+ origin: servedOrigin || null,
61
+ statusBarMaxChars: Number.isFinite(statusBarMaxChars) && statusBarMaxChars > 0 ? statusBarMaxChars : null,
62
+ statusBarProfile
63
+ });
64
+ return sendJSON(res, 200, { ok: true, id: result.id });
65
+ } catch (e) {
66
+ return sendJSON(res, 500, { error: e.message, code: 'EDB' });
67
+ }
68
+ }
69
+
70
+ // DELETE /api/push/subscribe body: { endpoint }
71
+ if (urlPath === '/api/push/subscribe' && method === 'DELETE') {
72
+ if (!sid) return sendJSON(res, 401, { error: 'No session', code: 'ESESSION' });
73
+ const body = await readJsonOr400(req, res);
74
+ if (!body) return;
75
+ const endpoint = body && body.endpoint;
76
+ if (!endpoint) return sendJSON(res, 400, { error: 'endpoint is required', code: 'EBADINPUT' });
77
+ const removed = push.removeSubscription(endpoint, sid);
78
+ return sendJSON(res, 200, { ok: true, removed });
79
+ }
80
+
81
+ // GET /api/push/subscriptions
82
+ if (urlPath === '/api/push/subscriptions' && method === 'GET') {
83
+ if (!sid) return sendJSON(res, 200, { subscriptions: [] });
84
+ return sendJSON(res, 200, { subscriptions: push.listSubscriptions(sid) });
85
+ }
86
+
87
+ // POST /api/push/test
88
+ if (urlPath === '/api/push/test' && method === 'POST') {
89
+ if (!sid) return sendJSON(res, 401, { error: 'No session', code: 'ESESSION' });
90
+ push.sendPushToSession(sid, {
91
+ title: 'mouaif notifications',
92
+ body: 'Notifications are ready on this browser.',
93
+ tag: 'mouaif-notification-test',
94
+ data: { kind: 'test', url: '/#/settings/notifications' }
95
+ });
96
+ return sendJSON(res, 200, { ok: true });
97
+ }
98
+
99
+ return sendJSON(res, 404, { error: 'Not found', scope: 'push' });
100
+ }
101
+
102
+ module.exports = { handlePush };