Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,21 @@ program
}
});

program
.command("export")
.description("Bundle scaffold files into a single Markdown document")
.option("-o, --output <path>", "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")
Expand Down Expand Up @@ -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 <path> Write bundled markdown to a file");
console.log(" mex tui Open the interactive mex dashboard");
console.log(" mex pattern add <name> Create a new pattern file");
console.log(" mex watch Install post-commit hook for auto drift score");
Expand Down Expand Up @@ -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() {
Expand Down
2 changes: 1 addition & 1 deletion src/drift/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
70 changes: 70 additions & 0 deletions src/export/index.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
const markdown = bundleScaffoldMarkdown(
config,
opts.scaffoldPatterns ?? DEFAULT_SCAFFOLD_PATTERNS,
);

if (opts.output) {
writeFileSync(opts.output, markdown, "utf8");
return;
}

process.stdout.write(markdown);
}
100 changes: 100 additions & 0 deletions test/export.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading