#!/usr/bin/env node /** * README.md marker-handling library + CLI for wiki-readme-agent. * * Library API: * import { extractMarkerBlock, replaceMarkerBlock, insertMarkers } from "./readme_sync.js"; * * CLI: * node readme_sync.js extract --readme * node readme_sync.js write --readme --block-file * node readme_sync.js init --readme --depth * * Mirrors agents/wiki-claude-md-agent/scripts/claude_md_gen.ts patterns: * library-first, custom error classes with `error_code`. */ import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { parseFlags as parseSharedFlags } from "../../../skills/doc-wiki/scripts/_cli_args.js"; // ── Constants ─────────────────────────────────────────────────────── export const MARKER_START = ""; export const MARKER_END = ""; // CommonMark allows up to 3 leading spaces before ATX headings, and // permits trailing content after the heading word: optional qualifiers // (`## Installation (npm)`), an em-dash subtitle (`## Getting Started — Docker`), // or the ATX closing `##` sequence (`## Install ##`). Match by keyword // prefix with a word boundary so all of those resolve. const INSTALL_HEADINGS = [ /^ {0,3}##\s+install\b/im, /^ {0,3}##\s+installation\b/im, /^ {0,3}##\s+setup\b/im, /^ {0,3}##\s+get\s+started\b/im, /^ {0,3}##\s+getting\s+started\b/im, ]; // ── Errors ────────────────────────────────────────────────────────── export class MarkersMissingError extends Error { readonly error_code = "MARKERS_MISSING"; constructor() { super( `README has no wiki-managed quickstart markers. Run with action: "init" to insert them.`, ); this.name = "MarkersMissingError"; } } export class MarkersCorruptError extends Error { readonly error_code = "MARKERS_CORRUPT"; readonly starts: number; readonly ends: number; constructor(starts: number, ends: number) { super( `Corrupted wiki-managed quickstart markers: ${starts} start marker(s) and ${ends} end marker(s) (expected exactly 1 of each).`, ); this.name = "MarkersCorruptError"; this.starts = starts; this.ends = ends; } } function countOccurrences(haystack: string, needle: string): number { if (needle.length === 0) return 0; let count = 0; let i = 0; while (true) { const hit = haystack.indexOf(needle, i); if (hit < 0) break; count++; i = hit + needle.length; } return count; } // ── Library API ──────────────────────────────────────────────────── export interface MarkerExtraction { /** Everything up to (but NOT including) the start marker. */ before: string; /** Inner content between markers, with leading/trailing newlines stripped. */ between: string; /** Everything after (but NOT including) the end marker. */ after: string; } export function extractMarkerBlock(readme: string): MarkerExtraction { const starts = countOccurrences(readme, MARKER_START); const ends = countOccurrences(readme, MARKER_END); if (starts === 0 && ends === 0) throw new MarkersMissingError(); if (starts !== 1 || ends !== 1) throw new MarkersCorruptError(starts, ends); const sIdx = readme.indexOf(MARKER_START); const eIdx = readme.indexOf(MARKER_END); if (eIdx < sIdx) throw new MarkersCorruptError(starts, ends); // before: everything up to (not including) the start marker // after: everything after (not including) the end marker const before = readme.substring(0, sIdx); const innerStart = sIdx + MARKER_START.length; const eEnd = eIdx + MARKER_END.length; const inner = readme.substring(innerStart, eIdx); const between = inner.replace(/^\n/, "").replace(/\n$/, ""); const after = readme.substring(eEnd); return { before, between, after }; } export function replaceMarkerBlock(readme: string, newBlock: string): string { const { before, after } = extractMarkerBlock(readme); return `${before}${MARKER_START}\n${newBlock}\n${MARKER_END}${after}`; } /** * Walk the README line-by-line, tracking ``` fence state, and return the * 0-based index of the first line that satisfies `predicate` while OUTSIDE * any fenced code block. Returns -1 if no such line exists. * * Toggling on lines that begin with three (or more) backticks covers the * common case. Handles both backtick and tilde fences. Doesn't handle * 4-space indented code blocks — rare in modern Markdown READMEs and out * of scope for this heuristic. * * Tracks fence delimiter LENGTH so a 4-backtick fence (commonly used to * show triple-backtick examples inside) is closed only by another fence * of >=4 backticks — not by a 3-backtick content line. Per CommonMark, * the closing fence must be at least as long as the opening one. */ function findHeadingLineOutsideFence( readme: string, predicate: (line: string) => boolean, ): number { const lines = readme.split("\n"); // Fence state: // openFenceLen === 0 → outside any fence // openFenceLen >= 3 → inside a fence (openFenceChar is the delimiter char) // CommonMark rules applied: // - Up to 3 leading spaces of indentation on the fence line. // - Both backtick (`) and tilde (~) fences are recognised. // - Opening fence: 3+ identical chars followed by an optional info string. // - Closing fence: SAME character, length >= opening, followed by ONLY // whitespace (an info string makes it content, not a closer). let openFenceLen = 0; let openFenceChar = ""; const FENCE_RE = /^ {0,3}((?:`{3,}|~{3,}))(.*)$/; for (let i = 0; i < lines.length; i++) { // Loop bound guarantees the index is valid; cast suppresses // noUncheckedIndexedAccess. const line = lines[i] as string; const fenceMatch = line.match(FENCE_RE); if (fenceMatch) { const delim = fenceMatch[1] as string; const char = delim[0] as string; // '`' or '~' const len = delim.length; const rest = fenceMatch[2] ?? ""; if (openFenceLen === 0) { // Opening fence — info string allowed. openFenceChar = char; openFenceLen = len; continue; } // Closing requires same char, length >= opening, whitespace-only rest. if ( char === openFenceChar && len >= openFenceLen && /^\s*$/.test(rest) ) { openFenceChar = ""; openFenceLen = 0; continue; } // Otherwise: different char, shorter run, or non-whitespace — content. } if (openFenceLen > 0) continue; if (predicate(line)) return i; } return -1; } export function insertMarkers(readme: string, placeholder: string): string { // Try install-heading regexes in priority order, skipping fenced code. let anchorLine = -1; for (const re of INSTALL_HEADINGS) { anchorLine = findHeadingLineOutsideFence(readme, (line) => re.test(line)); if (anchorLine !== -1) break; } if (anchorLine === -1) { // Fallback: first ## heading outside any fence (CommonMark allows up to // 3 leading spaces of indentation on ATX headings). anchorLine = findHeadingLineOutsideFence(readme, (line) => /^ {0,3}##\s+/.test(line), ); } if (anchorLine === -1) { // Last resort: append at the end. return `${readme}\n${MARKER_START}\n${placeholder}\n${MARKER_END}\n`; } const lines = readme.split("\n"); const before = lines.slice(0, anchorLine + 1).join("\n"); const after = lines.slice(anchorLine + 1).join("\n"); return `${before}\n\n${MARKER_START}\n${placeholder}\n${MARKER_END}${after.length > 0 ? "\n" + after : ""}`; } // ── CLI ───────────────────────────────────────────────────────────── const HELP_TEXT = `usage: readme_sync.js {extract,write,init} [...] Subcommands: extract --readme Print the marker block as JSON: { before, between, after }. Errors emit { status: "error", error_code, message }. write --readme --block-file Replace the marker block with the contents of . Preserves text outside the markers. init --readme --depth {minimal|standard|generous} Insert markers if missing. Idempotent when markers exist. The depth seeds a one-line placeholder (real content lands on the next /doc-wiki:atlas run). `; // All three depths currently seed the same one-line placeholder. Real depth // differentiation happens at sync time, when the agent generates the new // quickstart block from wiki/getting-started.md per the depth template. const PLACEHOLDERS: Record = { minimal: "> Quickstart synced from wiki/getting-started.md on next /doc-wiki:atlas run.", standard: "> Quickstart synced from wiki/getting-started.md on next /doc-wiki:atlas run.", generous: "> Quickstart synced from wiki/getting-started.md on next /doc-wiki:atlas run.", }; interface FlagMap { readme?: string; blockFile?: string; depth?: string; help?: boolean; } const FLAG_SPEC = { "--readme": "readme", "--block-file": "blockFile", "--depth": "depth", } as const; function parseLocalFlags(argv: readonly string[]): FlagMap { const parsed = parseSharedFlags(argv, FLAG_SPEC); return { readme: typeof parsed.values.readme === "string" ? parsed.values.readme : undefined, blockFile: typeof parsed.values.blockFile === "string" ? parsed.values.blockFile : undefined, depth: typeof parsed.values.depth === "string" ? parsed.values.depth : undefined, help: parsed.help, }; } function emitError(error_code: string, message: string, details?: object): void { process.stdout.write( JSON.stringify({ status: "error", error_code, message, ...(details ?? {}) }, null, 2) + "\n", ); } function cmdExtract(flags: FlagMap): number { if (!flags.readme) { process.stderr.write("--readme is required\n"); return 2; } if (!fs.existsSync(flags.readme)) { emitError("README_MISSING", `README not found: ${flags.readme}`); return 1; } const readme = fs.readFileSync(flags.readme, "utf-8"); try { const out = extractMarkerBlock(readme); process.stdout.write(JSON.stringify(out, null, 2) + "\n"); return 0; } catch (e) { if (e instanceof MarkersMissingError) { emitError("MARKERS_MISSING", e.message); return 1; } if (e instanceof MarkersCorruptError) { emitError("MARKERS_CORRUPT", e.message, { starts: e.starts, ends: e.ends, }); return 1; } throw e; } } function cmdWrite(flags: FlagMap): number { if (!flags.readme || !flags.blockFile) { process.stderr.write("--readme and --block-file are required\n"); return 2; } if (!fs.existsSync(flags.readme)) { emitError("README_MISSING", `README not found: ${flags.readme}`); return 1; } if (!fs.existsSync(flags.blockFile)) { emitError("BLOCK_FILE_MISSING", `Block file not found: ${flags.blockFile}`); return 1; } const readme = fs.readFileSync(flags.readme, "utf-8"); const block = fs.readFileSync(flags.blockFile, "utf-8").replace(/\n$/, ""); try { const out = replaceMarkerBlock(readme, block); fs.writeFileSync(flags.readme, out); process.stdout.write( JSON.stringify({ status: "success", written: flags.readme }, null, 2) + "\n", ); return 0; } catch (e) { if (e instanceof MarkersMissingError) { emitError("MARKERS_MISSING", e.message); return 1; } if (e instanceof MarkersCorruptError) { emitError("MARKERS_CORRUPT", e.message, { starts: e.starts, ends: e.ends, }); return 1; } throw e; } } function cmdInit(flags: FlagMap): number { if (!flags.readme) { process.stderr.write("--readme is required\n"); return 2; } const depth = flags.depth ?? "generous"; if (!["minimal", "standard", "generous"].includes(depth)) { process.stderr.write(`invalid --depth: ${depth}\n`); return 2; } if (!fs.existsSync(flags.readme)) { emitError("README_MISSING", `README not found: ${flags.readme}`); return 1; } const readme = fs.readFileSync(flags.readme, "utf-8"); // Idempotent — if markers exist, do nothing. try { extractMarkerBlock(readme); process.stdout.write( JSON.stringify({ status: "noop", reason: "markers already present" }, null, 2) + "\n", ); return 0; } catch (e) { if (!(e instanceof MarkersMissingError)) { // Corrupt markers — surface error rather than overwriting if (e instanceof MarkersCorruptError) { emitError("MARKERS_CORRUPT", e.message, { starts: e.starts, ends: e.ends, }); return 1; } throw e; } } const out = insertMarkers(readme, PLACEHOLDERS[depth] as string); fs.writeFileSync(flags.readme, out); process.stdout.write( JSON.stringify({ status: "success", written: flags.readme, depth }, null, 2) + "\n", ); return 0; } export function main(argv: readonly string[] = process.argv.slice(2)): number { if (argv.length === 0 || argv[0] === "-h" || argv[0] === "--help") { process.stdout.write(HELP_TEXT); return 0; } const sub = argv[0]; let flags: FlagMap; try { flags = parseLocalFlags(argv.slice(1)); } catch (e) { process.stderr.write(`${(e as Error).message}\n`); return 2; } if (flags.help) { process.stdout.write(HELP_TEXT); return 0; } switch (sub) { case "extract": return cmdExtract(flags); case "write": return cmdWrite(flags); case "init": return cmdInit(flags); default: process.stderr.write(`unknown subcommand: ${sub}\n`); return 2; } } const thisFile = fileURLToPath(import.meta.url); if (process.argv[1] && path.resolve(process.argv[1]) === thisFile) { process.exit(main()); }