From 3a4e4bcbe4b29d30873eb75db58483773c2b72f5 Mon Sep 17 00:00:00 2001 From: advancedresearcharray Date: Mon, 8 Jun 2026 03:04:15 +0000 Subject: [PATCH] feat(cli): add mex export to bundle scaffold into one Markdown file Adds a new `mex export` command that discovers scaffold files via findScaffoldFiles, concatenates them with per-file section headers, and writes to stdout or an optional output path. Closes #56 Co-authored-by: Cursor --- src/cli.ts | 19 ++++++++- src/drift/index.ts | 2 +- src/export/index.ts | 70 +++++++++++++++++++++++++++++++ test/export.test.ts | 100 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 src/export/index.ts create mode 100644 test/export.test.ts diff --git a/src/cli.ts b/src/cli.ts index 38eb06e..7b293eb 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -201,6 +201,21 @@ program } }); +program + .command("export") + .description("Bundle scaffold files into a single Markdown document") + .option("-o, --output ", "Write bundled markdown to a file (default: stdout)") + .action(async (opts) => { + try { + const config = findConfig(); + const { runExport } = await import("./export/index.js"); + await runExport(config, { output: opts.output }); + } catch (err) { + console.error((err as Error).message); + process.exit(1); + } + }); + // ── Layer 3: Targeted Sync ── program .command("sync") @@ -292,6 +307,8 @@ program console.log(" mex timeline Show recent event log entries"); console.log(" mex heartbeat Run lightweight agent-memory health checks"); console.log(" mex doctor Friendly scaffold health summary"); + console.log(" mex export Bundle scaffold into one Markdown file"); + console.log(" mex export -o Write bundled markdown to a file"); console.log(" mex tui Open the interactive mex dashboard"); console.log(" mex pattern add Create a new pattern file"); console.log(" mex watch Install post-commit hook for auto drift score"); @@ -321,7 +338,7 @@ if (isMainModule) { function buildCompletion(shell: string): string { const commands = [ "setup", "check", "init", "sync", "pattern", "log", "timeline", - "heartbeat", "doctor", "watch", "tui", "commands", "completion", + "heartbeat", "doctor", "export", "watch", "tui", "commands", "completion", ]; if (shell === "bash") { return `_mex_completion() { diff --git a/src/drift/index.ts b/src/drift/index.ts index e47bf4e..5132e32 100644 --- a/src/drift/index.ts +++ b/src/drift/index.ts @@ -144,7 +144,7 @@ export async function runDriftCheck( } /** Find all markdown files that are part of the scaffold */ -function findScaffoldFiles( +export function findScaffoldFiles( projectRoot: string, scaffoldRoot: string, patterns: readonly string[] = DEFAULT_SCAFFOLD_PATTERNS diff --git a/src/export/index.ts b/src/export/index.ts new file mode 100644 index 0000000..1570bae --- /dev/null +++ b/src/export/index.ts @@ -0,0 +1,70 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { relative } from "node:path"; +import type { MexConfig } from "../types.js"; +import { findScaffoldFiles, DEFAULT_SCAFFOLD_PATTERNS } from "../drift/index.js"; + +export interface RunExportOpts { + output?: string; + /** Override scaffold discovery globs (relative to `config.scaffoldRoot`). */ + scaffoldPatterns?: readonly string[]; +} + +/** Sort scaffold files for readable export: ROUTER first, then root, context, patterns. */ +export function sortScaffoldFiles( + files: string[], + projectRoot: string, +): string[] { + const rank = (filePath: string): number => { + const rel = relative(projectRoot, filePath); + const base = rel.replace(/^\.mex\//, ""); + if (base === "ROUTER.md") return 0; + if (base.startsWith("context/")) return 2; + if (base.startsWith("patterns/")) return 3; + return 1; + }; + + return [...files].sort((a, b) => { + const ra = rank(a); + const rb = rank(b); + if (ra !== rb) return ra - rb; + return relative(projectRoot, a).localeCompare(relative(projectRoot, b)); + }); +} + +/** Bundle scaffold markdown files into one document with a header per source file. */ +export function bundleScaffoldMarkdown( + config: MexConfig, + scaffoldPatterns: readonly string[] = DEFAULT_SCAFFOLD_PATTERNS, +): string { + const { projectRoot, scaffoldRoot } = config; + const files = sortScaffoldFiles( + findScaffoldFiles(projectRoot, scaffoldRoot, scaffoldPatterns), + projectRoot, + ); + + const sections = files.map((filePath) => { + const rel = relative(projectRoot, filePath); + const content = readFileSync(filePath, "utf8").trimEnd(); + return `## ${rel}\n\n${content}`; + }); + + return sections.length > 0 ? `${sections.join("\n\n")}\n` : ""; +} + +/** Export the scaffold as a single Markdown document (stdout or file). */ +export async function runExport( + config: MexConfig, + opts: RunExportOpts = {}, +): Promise { + const markdown = bundleScaffoldMarkdown( + config, + opts.scaffoldPatterns ?? DEFAULT_SCAFFOLD_PATTERNS, + ); + + if (opts.output) { + writeFileSync(opts.output, markdown, "utf8"); + return; + } + + process.stdout.write(markdown); +} diff --git a/test/export.test.ts b/test/export.test.ts new file mode 100644 index 0000000..002337b --- /dev/null +++ b/test/export.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + mkdtempSync, + mkdirSync, + rmSync, + writeFileSync, + readFileSync, + existsSync, +} from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + bundleScaffoldMarkdown, + runExport, + sortScaffoldFiles, +} from "../src/export/index.js"; +import type { MexConfig } from "../src/types.js"; + +let tmpDir: string; +let config: MexConfig; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "mex-export-")); + const scaffoldRoot = join(tmpDir, ".mex"); + mkdirSync(join(scaffoldRoot, "context"), { recursive: true }); + mkdirSync(join(scaffoldRoot, "patterns"), { recursive: true }); + + writeFileSync(join(scaffoldRoot, "ROUTER.md"), "# Router\n"); + writeFileSync(join(scaffoldRoot, "AGENTS.md"), "# Agents\n"); + writeFileSync(join(scaffoldRoot, "context", "architecture.md"), "# Architecture\n"); + writeFileSync(join(scaffoldRoot, "patterns", "alpha.md"), "# Alpha pattern\n"); + + config = { + projectRoot: tmpDir, + scaffoldRoot, + aiTools: [], + }; +}); + +afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("bundleScaffoldMarkdown", () => { + it("includes every scaffold file under a section header", () => { + const bundled = bundleScaffoldMarkdown(config); + + expect(bundled).toContain("## .mex/ROUTER.md\n\n# Router"); + expect(bundled).toContain("## .mex/AGENTS.md\n\n# Agents"); + expect(bundled).toContain("## .mex/context/architecture.md\n\n# Architecture"); + expect(bundled).toContain("## .mex/patterns/alpha.md\n\n# Alpha pattern"); + }); + + it("orders ROUTER.md before context and patterns", () => { + const bundled = bundleScaffoldMarkdown(config); + const routerIdx = bundled.indexOf("## .mex/ROUTER.md"); + const contextIdx = bundled.indexOf("## .mex/context/architecture.md"); + const patternsIdx = bundled.indexOf("## .mex/patterns/alpha.md"); + + expect(routerIdx).toBeGreaterThanOrEqual(0); + expect(contextIdx).toBeGreaterThan(routerIdx); + expect(patternsIdx).toBeGreaterThan(contextIdx); + }); + + it("returns empty string when no scaffold files match", () => { + const emptyConfig: MexConfig = { + projectRoot: tmpDir, + scaffoldRoot: join(tmpDir, "empty-scaffold"), + aiTools: [], + }; + mkdirSync(emptyConfig.scaffoldRoot); + + expect(bundleScaffoldMarkdown(emptyConfig, [])).toBe(""); + }); +}); + +describe("sortScaffoldFiles", () => { + it("ranks ROUTER.md ahead of other files", () => { + const files = [ + join(config.scaffoldRoot, "patterns", "alpha.md"), + join(config.scaffoldRoot, "ROUTER.md"), + join(config.scaffoldRoot, "context", "architecture.md"), + ]; + + const sorted = sortScaffoldFiles(files, config.projectRoot); + expect(sorted[0]).toBe(join(config.scaffoldRoot, "ROUTER.md")); + }); +}); + +describe("runExport", () => { + it("writes bundled markdown to a file when --output is set", async () => { + const outPath = join(tmpDir, "bundle.md"); + await runExport(config, { output: outPath }); + + expect(existsSync(outPath)).toBe(true); + const written = readFileSync(outPath, "utf8"); + expect(written).toContain("## .mex/ROUTER.md"); + expect(written).toContain("# Alpha pattern"); + }); +});