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
package/src/files.js ADDED
@@ -0,0 +1,431 @@
1
+ 'use strict';
2
+
3
+ // File editor — read/write text files inside a project folder.
4
+ //
5
+ // Implements the editor popup described in docs/features/chat-ui.md
6
+ // (file editor button next to the message composer). The popup is a
7
+ // CodeMirror-based UI served by the mobile web client; this module
8
+ // is the matching server surface.
9
+ //
10
+ // Public surface (all paths must live under a project folder that
11
+ // itself lives under the user home unless MOUAIF_ALLOW_ANY_ROOT=1):
12
+ //
13
+ // listDir(projectDir, dir) -> { projectDir, dir, entries: [...] }
14
+ // readFile(projectDir, path) -> { projectDir, path, relPath, size, ext, content }
15
+ // writeFile(projectDir, path, content) -> { projectDir, path, relPath, size, bytesWritten }
16
+ //
17
+ // `projectDir` and `dir` may be the same: listing the project root
18
+ // is the default. `path` for read/write may be absolute or
19
+ // project-relative.
20
+ //
21
+ // Text-only: the editor never opens binary files. A list entry has a
22
+ // `binary: true` flag so the UI can grey those out; read/write reject
23
+ // non-text files with a typed EBINARY error so the caller can show
24
+ // "this file cannot be edited here" instead of a 500.
25
+
26
+ const fs = require('fs');
27
+ const fsp = require('fs').promises;
28
+ const path = require('path');
29
+
30
+ const { err } = require('./util.js');
31
+ const projects = require('./projects.js');
32
+
33
+ // Default per-file size cap. The editor is for small fixes (config
34
+ // tweaks, prompts, READMEs), not 50 MB log dumps. Overridable per
35
+ // call when we need to (tests).
36
+ const DEFAULT_MAX_BYTES = 1024 * 1024; // 1 MiB
37
+
38
+ // Same allowlist as the file-tagging scan (docs/decisions.md §15).
39
+ // Anything outside is treated as binary for editor purposes unless
40
+ // the small-file text sniff in `isProbablyText` recognises it.
41
+ const TEXT_EXTS = new Set([
42
+ '.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.json', '.md', '.mdx', '.txt',
43
+ '.py', '.rb', '.go', '.rs', '.java', '.kt', '.swift', '.c', '.h',
44
+ '.cpp', '.hpp', '.cc', '.cxx', '.hxx', '.cs', '.m', '.mm',
45
+ '.css', '.scss', '.sass', '.less', '.html', '.htm',
46
+ '.yml', '.yaml', '.toml', '.sh', '.bash', '.zsh', '.fish',
47
+ '.xml', '.svg', '.ini', '.cfg', '.conf', '.env', '.gitignore',
48
+ '.editorconfig', '.dockerignore', '.ps1', '.psm1', '.psd1',
49
+ '.lua', '.pl', '.r', '.dart', '.php', '.phtml',
50
+ '.sql', '.csv', '.tsv', '.vue', '.svelte',
51
+ '.graphql', '.gql', '.proto', '.prisma', '.gradle', '.kts',
52
+ '.rst', '.tex', '.diff', '.patch', '.log',
53
+ '.lock', '.sum', '.mod', '.properties',
54
+ '.nim', '.zig', '.v', '.sv', '.svh',
55
+ '.ex', '.exs', '.erl', '.hrl', '.clj', '.cljs', '.cljc',
56
+ '.tf', '.hcl', '.nix', '.dhall'
57
+ ]);
58
+ // Image extensions the file modal can preview inline. The same home
59
+ // + MOUAIF_ALLOW_ANY_ROOT guard protects reads, just like the text
60
+ // editor. SVG is included even though it's text — the editor still
61
+ // refuses to *edit* SVG, and treating it as a media file here means
62
+ // the user gets a visual preview in the modal.
63
+ const IMAGE_EXTS = new Set([
64
+ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico'
65
+ ]);
66
+ const EXT_TO_MIME = {
67
+ '.png': 'image/png',
68
+ '.jpg': 'image/jpeg',
69
+ '.jpeg': 'image/jpeg',
70
+ '.gif': 'image/gif',
71
+ '.webp': 'image/webp',
72
+ '.svg': 'image/svg+xml',
73
+ '.bmp': 'image/bmp',
74
+ '.ico': 'image/x-icon'
75
+ };
76
+
77
+ // Names that the walker never descends into. Mirrors the file-tagging
78
+ // scan so the editor popup is consistent with the existing project
79
+ // views.
80
+ const SKIP_DIRS = new Set([
81
+ 'node_modules', '.git', '.mouaif', 'dist', 'build', '.next',
82
+ '.cache', '.parcel-cache', '.turbo', '.vercel', '.nuxt',
83
+ 'coverage', '.pytest_cache', '__pycache__', 'target'
84
+ ]);
85
+
86
+ // ---- Errors ------------------------------------------------------------
87
+
88
+ // err() is shared from src/util.js (files.js, tags.js and mcp.js used to
89
+ // each carry an identical private copy).
90
+
91
+ function isTextExt(ext) {
92
+ if (!ext) return false;
93
+ return TEXT_EXTS.has(ext.toLowerCase());
94
+ }
95
+ function isImageExt(ext) {
96
+ if (!ext) return false;
97
+ return IMAGE_EXTS.has(ext.toLowerCase());
98
+ }
99
+ function mimeForExt(ext) {
100
+ if (!ext) return 'application/octet-stream';
101
+ return EXT_TO_MIME[ext.toLowerCase()] || 'application/octet-stream';
102
+ }
103
+ // Sniff the first ~8 KiB of a small file and decide whether it looks
104
+ // like text. A NUL byte is the cheap, reliable tell: real text never
105
+ // has one in the first KB, but every binary format (PNG, ELF, ZIP,
106
+ // PDF, ...) starts with one. We only run this on files that fit under
107
+ // the editor size cap, so the read is bounded and the editor never
108
+ // tries to render a 50 MB file as text. The fallback exists so the
109
+ // user can open plain-text files with unfamiliar extensions (LICENSE
110
+ // without an extension, .gitattributes, dotfiles) instead of being
111
+ // forced to edit them in a different tool.
112
+ async function isProbablyText(abs) {
113
+ let fd;
114
+ try {
115
+ fd = await fsp.open(abs, 'r');
116
+ const buf = Buffer.alloc(8192);
117
+ const { bytesRead } = await fd.read(buf, 0, buf.length, 0);
118
+ if (bytesRead === 0) return true; // empty file is "text"
119
+ for (let i = 0; i < bytesRead; i++) {
120
+ if (buf[i] === 0x00) return false;
121
+ }
122
+ // Also reject if the UTF-8 decode throws - a control-heavy
123
+ // binary will fail the decode even without an explicit NUL.
124
+ buf.toString('utf8', 0, bytesRead);
125
+ return true;
126
+ } catch {
127
+ return false;
128
+ } finally {
129
+ if (fd) { try { await fd.close(); } catch { /* ignore */ } }
130
+ }
131
+ }
132
+ // Sync version for use inside listDir, which is itself sync. Same
133
+ // 8 KiB NUL-byte heuristic. We do not call this on every entry; it
134
+ // is only used when the extension is missing or unknown and the file
135
+ // fits under the editor cap, so a one-off read here is fine.
136
+ function isProbablyTextSync(abs, maxBytes) {
137
+ let fd;
138
+ try {
139
+ fd = fs.openSync(abs, 'r');
140
+ const buf = Buffer.alloc(8192);
141
+ const bytesRead = fs.readSync(fd, buf, 0, buf.length, 0);
142
+ if (bytesRead === 0) return true; // empty file is "text"
143
+ for (let i = 0; i < bytesRead; i++) {
144
+ if (buf[i] === 0x00) return false;
145
+ }
146
+ buf.toString('utf8', 0, bytesRead);
147
+ return true;
148
+ } catch {
149
+ return false;
150
+ } finally {
151
+ if (fd != null) { try { fs.closeSync(fd); } catch { /* ignore */ } }
152
+ }
153
+ }
154
+
155
+ // ---- Path safety -------------------------------------------------------
156
+
157
+ // Resolve any incoming path to an absolute path. The projectDir itself
158
+ // is validated via projects.ensureSafeRoot (home / MOUAIF_ALLOW_ANY_ROOT),
159
+ // and — because the file editor can now browse above a project folder —
160
+ // the target path is validated with the same home guard rather than being
161
+ // confined to the project root. So the editor can list/read/write anywhere
162
+ // under the user home (or anywhere at all when MOUAIF_ALLOW_ANY_ROOT=1),
163
+ // mirroring the project-folder picker.
164
+ //
165
+ // `incoming` may be absolute or relative; relative is resolved against
166
+ // projectDir. Throws typed errors (EBADPATH, EOUTSIDE_HOME, ENOENT)
167
+ // instead of letting the underlying fs throw something the UI cannot
168
+ // render.
169
+ function resolveSafe(projectDir, incoming) {
170
+ if (typeof projectDir !== 'string' || !projectDir) {
171
+ throw err('EBADPATH', 'projectDir is required');
172
+ }
173
+ // Validate the project root. Throws EOUTSIDE_HOME etc.
174
+ const root = projects.ensureSafeRoot(projectDir);
175
+ if (!incoming || typeof incoming !== 'string') {
176
+ throw err('EBADPATH', 'path is required');
177
+ }
178
+ const abs = path.isAbsolute(incoming) ? path.resolve(incoming) : path.resolve(root, incoming);
179
+ // The browse/edit boundary is home / ALLOW_ANY_ROOT, not the project
180
+ // root. When ALLOW_ANY_ROOT is off this throws EOUTSIDE_HOME for any
181
+ // target outside the user home; when on, it allows the whole tree.
182
+ projects.ensureSafeRoot(abs);
183
+ // `rel` stays project-relative. For in-project paths it is a clean
184
+ // relative path; when browsing above the project root it becomes a
185
+ // `..`-prefixed string used to build relPath. Callers that need an
186
+ // absolute path use `abs`.
187
+ const rel = path.relative(root, abs);
188
+ return { root, abs, rel: rel || '.' };
189
+ }
190
+
191
+ function ensureDir(root, abs, kind) {
192
+ // kind: 'list' (must be a dir), 'parent' (must be a dir, used for writes)
193
+ let st;
194
+ try { st = fs.statSync(abs); }
195
+ catch (e) {
196
+ if (e.code === 'ENOENT') {
197
+ throw err('ENOENT', kind === 'parent' ? 'Parent directory does not exist' : 'Directory does not exist', { path: abs });
198
+ }
199
+ throw e;
200
+ }
201
+ if (!st.isDirectory()) {
202
+ throw err('ENOTDIR', 'Path is not a directory', { path: abs });
203
+ }
204
+ return st;
205
+ }
206
+
207
+ function hasImmediateSubdirs(abs) {
208
+ let names;
209
+ try { names = fs.readdirSync(abs); }
210
+ catch { return false; }
211
+ for (const n of names) {
212
+ if (n.startsWith('.') || SKIP_DIRS.has(n)) continue;
213
+ try {
214
+ const st = fs.statSync(path.join(abs, n));
215
+ if (st.isDirectory()) return true;
216
+ } catch { /* ignore */ }
217
+ }
218
+ return false;
219
+ }
220
+
221
+ // ---- Public: list ------------------------------------------------------
222
+
223
+ function listDir(projectDir, dir) {
224
+ const target = (dir && typeof dir === 'string') ? dir : projectDir;
225
+ const { root, abs, rel } = resolveSafe(projectDir, target);
226
+ ensureDir(root, abs, 'list');
227
+ let names;
228
+ try { names = fs.readdirSync(abs); }
229
+ catch (e) {
230
+ throw err(e.code === 'EACCES' ? 'EACCES' : 'EREAD', e.message, { path: abs });
231
+ }
232
+ const entries = [];
233
+ for (const name of names) {
234
+ // Hidden dotfiles (e.g. .gitignore, .env, .editorconfig) are real
235
+ // text files the editor is meant to open — see the feature doc. We
236
+ // still skip the known-junk dot-dirs (.git, .mouaif, .cache, …) via
237
+ // SKIP_DIRS below, but we no longer blanket-hide every `.` entry.
238
+ if (SKIP_DIRS.has(name)) continue;
239
+ const full = path.join(abs, name);
240
+ let st;
241
+ try { st = fs.statSync(full); }
242
+ catch { continue; }
243
+ if (st.isDirectory()) {
244
+ entries.push({
245
+ name,
246
+ path: full,
247
+ relPath: rel === '.' ? name : (rel + '/' + name),
248
+ type: 'dir',
249
+ hasChildren: hasImmediateSubdirs(full)
250
+ });
251
+ continue;
252
+ }
253
+ if (!st.isFile()) continue;
254
+ const ext = path.extname(name).toLowerCase();
255
+ let text = isTextExt(ext);
256
+ const image = isImageExt(ext);
257
+ // Sniff small files with an unknown extension (e.g. LICENSE,
258
+ // .gitattributes) so the modal offers to open them as text. A
259
+ // known image extension is never treated as text even if the
260
+ // first 8 KiB happen to contain no NUL bytes.
261
+ if (!text && !image && st.size <= DEFAULT_MAX_BYTES) {
262
+ text = isProbablyTextSync(full, DEFAULT_MAX_BYTES);
263
+ }
264
+ entries.push({
265
+ name,
266
+ path: full,
267
+ relPath: rel === '.' ? name : (rel + '/' + name),
268
+ type: 'file',
269
+ size: st.size,
270
+ ext: ext || '',
271
+ binary: !text,
272
+ text,
273
+ image
274
+ });
275
+ }
276
+ // Dirs first, then files; both sorted case-insensitively.
277
+ entries.sort((a, b) => {
278
+ if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
279
+ return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
280
+ });
281
+ return { projectDir: root, dir: abs, relDir: rel, browseTop: projects.browseTop(abs), entries };
282
+ }
283
+
284
+ // ---- Public: read ------------------------------------------------------
285
+
286
+ async function readFile(projectDir, filePath, opts) {
287
+ const { root, abs, rel } = resolveSafe(projectDir, filePath);
288
+ const max = (opts && Number.isInteger(opts.maxBytes) && opts.maxBytes > 0) ? opts.maxBytes : DEFAULT_MAX_BYTES;
289
+ let st;
290
+ try { st = await fsp.stat(abs); }
291
+ catch (e) {
292
+ if (e.code === 'ENOENT') throw err('ENOENT', 'File does not exist', { path: abs });
293
+ throw e;
294
+ }
295
+ if (!st.isFile()) throw err('ENOTFILE', 'Path is not a file', { path: abs });
296
+ const ext = path.extname(abs).toLowerCase();
297
+ if (!isTextExt(ext) && (isImageExt(ext) || !(st.size <= max && await isProbablyText(abs)))) {
298
+ throw err('EBINARY', 'Binary files cannot be edited here', { path: abs, ext });
299
+ }
300
+ if (st.size > max) {
301
+ throw err('ETOOLARGE', 'File exceeds the editor size cap', { path: abs, size: st.size, maxBytes: max });
302
+ }
303
+ let content;
304
+ try { content = await fsp.readFile(abs, 'utf8'); }
305
+ catch (e) {
306
+ if (e.code === 'EACCES') throw err('EACCES', e.message, { path: abs });
307
+ throw e;
308
+ }
309
+ return { projectDir: root, path: abs, relPath: rel, size: st.size, ext: ext || '', content };
310
+ }
311
+ // ---- Public: readMedia -------------------------------------------------
312
+ // Read an image (or any binary the modal can preview) as a
313
+ // base64-encoded data URL. The file editor is the only caller: it
314
+ // builds a <img src="data:..."> from the result so the preview does
315
+ // not need a separate auth-bearing URL. Files larger than the
316
+ // editor cap (1 MiB) are rejected with the same ETOOLARGE code the
317
+ // text read uses, so the UI shows a consistent error pill.
318
+ async function readMedia(projectDir, filePath, opts) {
319
+ const { root, abs, rel } = resolveSafe(projectDir, filePath);
320
+ const max = (opts && Number.isInteger(opts.maxBytes) && opts.maxBytes > 0) ? opts.maxBytes : DEFAULT_MAX_BYTES;
321
+ let st;
322
+ try { st = await fsp.stat(abs); }
323
+ catch (e) {
324
+ if (e.code === 'ENOENT') throw err('ENOENT', 'File does not exist', { path: abs });
325
+ throw e;
326
+ }
327
+ if (!st.isFile()) throw err('ENOTFILE', 'Path is not a file', { path: abs });
328
+ const ext = path.extname(abs).toLowerCase();
329
+ if (!isImageExt(ext)) {
330
+ throw err('ENOTIMAGE', 'File is not a previewable image', { path: abs, ext });
331
+ }
332
+ if (st.size > max) {
333
+ throw err('ETOOLARGE', 'Image exceeds the preview size cap', { path: abs, size: st.size, maxBytes: max });
334
+ }
335
+ let buf;
336
+ try { buf = await fsp.readFile(abs); }
337
+ catch (e) {
338
+ if (e.code === 'EACCES') throw err('EACCES', e.message, { path: abs });
339
+ throw e;
340
+ }
341
+ const mime = mimeForExt(ext);
342
+ return {
343
+ projectDir: root,
344
+ path: abs,
345
+ relPath: rel,
346
+ size: st.size,
347
+ ext: ext || '',
348
+ mime,
349
+ dataUrl: 'data:' + mime + ';base64,' + buf.toString('base64')
350
+ };
351
+ }
352
+
353
+ // ---- Public: write -----------------------------------------------------
354
+
355
+ // Atomic write: stage to <file>.mouaif-tmp then rename. Same pattern
356
+ // used by src/tools/files.js so a crash mid-write cannot truncate the
357
+ // real file. Preserves the existing file mode when overwriting.
358
+ async function writeFile(projectDir, filePath, content) {
359
+ if (typeof content !== 'string') {
360
+ throw err('EBADINPUT', 'content must be a string');
361
+ }
362
+ const { root, abs, rel } = resolveSafe(projectDir, filePath);
363
+ // The parent must exist; we do not auto-create folders. The editor
364
+ // is for "edit existing files", not "create new project trees".
365
+ const parent = path.dirname(abs);
366
+ ensureDir(root, parent, 'parent');
367
+
368
+ // Refuse to clobber an existing directory at the target path BEFORE
369
+ // checking the extension allowlist: "this is a directory" is the
370
+ // real reason the write is invalid, not "the directory has no
371
+ // extension so it looks binary".
372
+ let existing;
373
+ try { existing = await fsp.stat(abs); }
374
+ catch (e) { if (e.code !== 'ENOENT') throw e; }
375
+ if (existing && existing.isDirectory()) {
376
+ throw err('EISDIR', 'Target is a directory', { path: abs });
377
+ }
378
+
379
+ const ext = path.extname(abs).toLowerCase();
380
+ // Match readFile: known images are never text, everything else
381
+ // is allowed if it has a text extension or has no extension. A
382
+ // brand-new file (existing is undefined) cannot be sniffed, so we
383
+ // fall back to the extension allowlist only - this matches the
384
+ // original "must have a text extension" behavior for new files
385
+ // and means the user can save to .LICENSE, .gitattributes, etc.,
386
+ // by re-opening an existing one.
387
+ if (existing && !isTextExt(ext) && (isImageExt(ext) || !(await isProbablyText(abs)))) {
388
+ throw err('EBINARY', 'Binary files cannot be edited here', { path: abs, ext });
389
+ }
390
+ if (!existing && !isTextExt(ext)) {
391
+ throw err('EBINARY', 'Refusing to write to a non-text extension', { path: abs, ext });
392
+ }
393
+ const size = Buffer.byteLength(content, 'utf8');
394
+ if (size > DEFAULT_MAX_BYTES) {
395
+ throw err('ETOOLARGE', 'Refusing to write file larger than the editor size cap', { path: abs, size, maxBytes: DEFAULT_MAX_BYTES });
396
+ }
397
+
398
+ const tmp = abs + '.mouaif-tmp';
399
+ let st;
400
+ try { st = await fsp.stat(abs); }
401
+ catch (e) { if (e.code !== 'ENOENT') throw e; }
402
+ try {
403
+ await fsp.writeFile(tmp, content, { encoding: 'utf8', mode: st ? st.mode : 0o644 });
404
+ await fsp.rename(tmp, abs);
405
+ } catch (e) {
406
+ // Clean up the staging file on failure.
407
+ try { await fsp.unlink(tmp); } catch { /* ignore */ }
408
+ if (e.code === 'EACCES') throw err('EACCES', e.message, { path: abs });
409
+ throw e;
410
+ }
411
+ return { projectDir: root, path: abs, relPath: rel, size, bytesWritten: size };
412
+ }
413
+
414
+ module.exports = {
415
+ // operations
416
+ listDir,
417
+ readFile,
418
+ readMedia,
419
+ writeFile,
420
+ // helpers (exported for tests)
421
+ resolveSafe,
422
+ isTextExt,
423
+ isImageExt,
424
+ mimeForExt,
425
+ isProbablyText,
426
+ isProbablyTextSync,
427
+ TEXT_EXTS,
428
+ IMAGE_EXTS,
429
+ SKIP_DIRS,
430
+ DEFAULT_MAX_BYTES
431
+ };