@gethmy/mcp 3.8.0 → 3.9.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.
package/src/tui/writer.ts CHANGED
@@ -204,6 +204,112 @@ export function appendToToml(
204
204
  }
205
205
  }
206
206
 
207
+ /**
208
+ * Markers delimiting the Harmony-owned section of a shared markdown file.
209
+ * Everything between them is package-generated and refreshed on reinstall;
210
+ * everything outside them belongs to the project and is never rewritten.
211
+ */
212
+ export const MARKDOWN_SECTION_START = "<!-- harmony:start -->";
213
+ export const MARKDOWN_SECTION_END = "<!-- harmony:end -->";
214
+
215
+ /**
216
+ * Locate the Harmony-owned section: the first WELL-FORMED marker pair — a
217
+ * START, the first END after it, and no second START in between.
218
+ *
219
+ * **This choice is the whole safety argument of `mergeMarkdownSection`,** and
220
+ * the two obvious rules are each unsafe in one direction. First-START-first-END
221
+ * swallows everything between a stray START above our section and our real END.
222
+ * Last-START-first-END-after does the mirror: a project quoting both markers
223
+ * BELOW our section loses whatever sits between those two mentions. A file that
224
+ * merely shows the markers inside a code fence hits one or the other.
225
+ *
226
+ * Requiring the span to hold no START defeats both, because a stray marker
227
+ * disqualifies the candidate it sits inside and the scan moves past it. What
228
+ * this does NOT do is notice a file holding two complete sections: it updates
229
+ * the first and leaves the second stale. That is a duplicate to spot by eye,
230
+ * not content destroyed, which is the trade this function exists to make.
231
+ */
232
+ function findSection(text: string): { start: number; end: number } | null {
233
+ let from = 0;
234
+ while (true) {
235
+ const start = text.indexOf(MARKDOWN_SECTION_START, from);
236
+ if (start === -1) return null;
237
+ const bodyFrom = start + MARKDOWN_SECTION_START.length;
238
+ const end = text.indexOf(MARKDOWN_SECTION_END, bodyFrom);
239
+ if (end === -1) return null;
240
+ const nextStart = text.indexOf(MARKDOWN_SECTION_START, bodyFrom);
241
+ if (nextStart === -1 || nextStart > end) return { start, end };
242
+ // `start` is unpaired. `nextStart > from` always, so the scan terminates.
243
+ from = nextStart;
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Merge a Harmony-owned section into a shared markdown file (AGENTS.md).
249
+ *
250
+ * `AGENTS.md` is the project's file, not ours. It may already carry the
251
+ * docs-step scaffold written earlier in the same run, or a file the user has
252
+ * maintained by hand for months. A plain `writeFile` destroyed both: setup runs
253
+ * with `force: true` on every fresh install (`needsSkills`), which bypasses the
254
+ * exists-skip, and the agent files are pushed onto `allFiles` AFTER the docs
255
+ * scaffold — so the last write of `AGENTS.md` won and the scaffold vanished in
256
+ * the same breath as the line announcing it. Hence markers and a merge (#1124).
257
+ *
258
+ * - File absent → create it holding just the section.
259
+ * - A section is present (see `findSection`) → replace what lies between its
260
+ * markers, and only under `force` (matching `appendToToml`) — the section is
261
+ * package-generated, so a reinstall is when it should be refreshed.
262
+ * - No section → append. The file's existing content is never rewritten.
263
+ */
264
+ export function mergeMarkdownSection(
265
+ filePath: string,
266
+ content: string,
267
+ options: WriteOptions = {},
268
+ ): FileResult {
269
+ const section = `${MARKDOWN_SECTION_START}\n${content.trim()}\n${MARKDOWN_SECTION_END}\n`;
270
+
271
+ if (!existsSync(filePath)) {
272
+ try {
273
+ ensureDir(dirname(filePath));
274
+ writeFileSync(filePath, section, { mode: 0o644 });
275
+ return { path: filePath, action: "create" };
276
+ } catch (error) {
277
+ return {
278
+ path: filePath,
279
+ action: "skip",
280
+ error: error instanceof Error ? error.message : String(error),
281
+ };
282
+ }
283
+ }
284
+
285
+ try {
286
+ const existing = readFileSync(filePath, "utf-8");
287
+ const found = findSection(existing);
288
+
289
+ if (found) {
290
+ if (!options.force) return { path: filePath, action: "skip" };
291
+ const updated =
292
+ existing.slice(0, found.start) +
293
+ section.trimEnd() +
294
+ existing.slice(found.end + MARKDOWN_SECTION_END.length);
295
+ writeFileSync(filePath, updated, { mode: 0o644 });
296
+ return { path: filePath, action: "update" };
297
+ }
298
+
299
+ // No section we can identify — every byte of this file is the project's.
300
+ // Append, never overwrite, however forceful the caller is.
301
+ const separator = existing.endsWith("\n") ? "\n" : "\n\n";
302
+ writeFileSync(filePath, existing + separator + section, { mode: 0o644 });
303
+ return { path: filePath, action: "merge" };
304
+ } catch (error) {
305
+ return {
306
+ path: filePath,
307
+ action: "skip",
308
+ error: error instanceof Error ? error.message : String(error),
309
+ };
310
+ }
311
+ }
312
+
207
313
  /**
208
314
  * Write multiple files with progress display
209
315
  */
@@ -211,7 +317,7 @@ export async function writeFilesWithProgress(
211
317
  files: Array<{
212
318
  path: string;
213
319
  content: string;
214
- type: "text" | "json" | "toml";
320
+ type: "text" | "json" | "toml" | "markdown";
215
321
  jsonKey?: string;
216
322
  tomlSection?: string;
217
323
  mode?: number;
@@ -232,6 +338,8 @@ export async function writeFilesWithProgress(
232
338
  result = mergeJsonFile(file.path, jsonContent, options);
233
339
  } else if (file.type === "toml" && file.tomlSection) {
234
340
  result = appendToToml(file.path, file.tomlSection, file.content, options);
341
+ } else if (file.type === "markdown") {
342
+ result = mergeMarkdownSection(file.path, file.content, options);
235
343
  } else {
236
344
  result = writeFile(file.path, file.content, {
237
345
  ...options,
@@ -256,7 +364,15 @@ export async function writeFilesWithProgress(
256
364
  } else if (result.action === "skip") {
257
365
  console.log(messages.fileSkipped(displayPath));
258
366
  } else {
259
- const actionLabel = result.action === "merge" ? "updated" : "created";
367
+ // `update` used to fall through to "created" harmless while it was rare,
368
+ // and a lie the moment AGENTS.md started taking the merge path on every
369
+ // reinstall, telling the user a months-old file was just created (#1124).
370
+ const actionLabel =
371
+ result.action === "create"
372
+ ? "created"
373
+ : result.action === "merge"
374
+ ? "merged"
375
+ : "updated";
260
376
  console.log(
261
377
  ` ${colors.success("\u2713")} ${colors.dim(displayPath)} ${colors.dim(`(${actionLabel})`)}`,
262
378
  );