@mandujs/core 0.51.0 → 0.53.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.51.0",
3
+ "version": "0.53.0",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Issue #245 M5 — AGENTS.md / CLAUDE.md linker tests.
3
+ */
4
+
5
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
6
+ import { promises as fs } from "node:fs";
7
+ import path from "node:path";
8
+ import os from "node:os";
9
+ import {
10
+ buildAgentsDesignBlock,
11
+ DESIGN_LINK_MARKER_END,
12
+ DESIGN_LINK_MARKER_START,
13
+ linkAgentsToDesignMd,
14
+ } from "../agents-link";
15
+
16
+ describe("buildAgentsDesignBlock", () => {
17
+ it("contains markers, the canonical heading, and all 8 MCP tools", () => {
18
+ const block = buildAgentsDesignBlock();
19
+ expect(block).toContain(DESIGN_LINK_MARKER_START);
20
+ expect(block).toContain(DESIGN_LINK_MARKER_END);
21
+ expect(block).toContain("## Design System");
22
+ for (const tool of [
23
+ "mandu.design.get",
24
+ "mandu.design.prompt",
25
+ "mandu.component.list",
26
+ "mandu.design.check",
27
+ "mandu.design.extract",
28
+ "mandu.design.propose",
29
+ "mandu.design.patch",
30
+ "mandu.design.diff_upstream",
31
+ ]) {
32
+ expect(block).toContain(tool);
33
+ }
34
+ });
35
+
36
+ it("respects custom DESIGN.md filename", () => {
37
+ expect(buildAgentsDesignBlock("Stripe-DESIGN.md")).toContain("Stripe-DESIGN.md");
38
+ });
39
+ });
40
+
41
+ describe("linkAgentsToDesignMd", () => {
42
+ let TEST_DIR: string;
43
+ beforeEach(async () => {
44
+ TEST_DIR = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-design-link-"));
45
+ });
46
+ afterEach(async () => {
47
+ await fs.rm(TEST_DIR, { recursive: true, force: true });
48
+ });
49
+
50
+ it("inserts the block at end of an existing AGENTS.md", async () => {
51
+ await fs.writeFile(path.join(TEST_DIR, "AGENTS.md"), "# Existing\n\nSome notes.\n");
52
+ const result = await linkAgentsToDesignMd({ rootDir: TEST_DIR });
53
+ expect(result.changed).toBe(true);
54
+ expect(result.files.find((f) => f.path.endsWith("AGENTS.md"))?.action).toBe("inserted");
55
+ const after = await fs.readFile(path.join(TEST_DIR, "AGENTS.md"), "utf8");
56
+ expect(after).toContain("# Existing");
57
+ expect(after).toContain("Some notes.");
58
+ expect(after).toContain("## Design System");
59
+ });
60
+
61
+ it("is idempotent — running twice produces no diff on the second run", async () => {
62
+ await fs.writeFile(path.join(TEST_DIR, "AGENTS.md"), "# X\n");
63
+ await linkAgentsToDesignMd({ rootDir: TEST_DIR });
64
+ const after1 = await fs.readFile(path.join(TEST_DIR, "AGENTS.md"), "utf8");
65
+ const second = await linkAgentsToDesignMd({ rootDir: TEST_DIR });
66
+ const after2 = await fs.readFile(path.join(TEST_DIR, "AGENTS.md"), "utf8");
67
+ expect(after1).toBe(after2);
68
+ expect(second.files.find((f) => f.path.endsWith("AGENTS.md"))?.action).toBe("unchanged");
69
+ });
70
+
71
+ it("replaces an existing markered block when the body changed", async () => {
72
+ await fs.writeFile(
73
+ path.join(TEST_DIR, "AGENTS.md"),
74
+ `# X\n\n${DESIGN_LINK_MARKER_START}\n\n## Old block\n\n${DESIGN_LINK_MARKER_END}\n`,
75
+ );
76
+ await linkAgentsToDesignMd({ rootDir: TEST_DIR });
77
+ const after = await fs.readFile(path.join(TEST_DIR, "AGENTS.md"), "utf8");
78
+ expect(after).not.toContain("## Old block");
79
+ expect(after).toContain("## Design System");
80
+ });
81
+
82
+ it("touches CLAUDE.md too when both exist", async () => {
83
+ await fs.writeFile(path.join(TEST_DIR, "AGENTS.md"), "# A\n");
84
+ await fs.writeFile(path.join(TEST_DIR, "CLAUDE.md"), "# C\n");
85
+ const result = await linkAgentsToDesignMd({ rootDir: TEST_DIR });
86
+ expect(result.files.filter((f) => f.action === "inserted")).toHaveLength(2);
87
+ expect(await fs.readFile(path.join(TEST_DIR, "CLAUDE.md"), "utf8")).toContain(
88
+ "## Design System",
89
+ );
90
+ });
91
+
92
+ it("creates AGENTS.md when neither file exists and createIfMissing is true", async () => {
93
+ const result = await linkAgentsToDesignMd({
94
+ rootDir: TEST_DIR,
95
+ createIfMissing: true,
96
+ });
97
+ expect(result.changed).toBe(true);
98
+ expect(result.files.find((f) => f.action === "created")).toBeDefined();
99
+ expect(await fs.readFile(path.join(TEST_DIR, "AGENTS.md"), "utf8")).toContain(
100
+ "## Design System",
101
+ );
102
+ });
103
+
104
+ it("is a no-op when neither file exists and createIfMissing is false", async () => {
105
+ const result = await linkAgentsToDesignMd({ rootDir: TEST_DIR });
106
+ expect(result.changed).toBe(false);
107
+ await expect(fs.access(path.join(TEST_DIR, "AGENTS.md"))).rejects.toThrow();
108
+ });
109
+ });
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Issue #245 M4 §3.5 — extract / patch / diff helper tests.
3
+ *
4
+ * Cover the three pure surfaces the MCP write-tools layer over:
5
+ * - `extractDesignTokens()` — color/font/component proposals
6
+ * - `patchDesignMd()` — section-safe add/update/remove
7
+ * - `diffDesignSpecs()` — local ↔ upstream per-section diff
8
+ */
9
+
10
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
11
+ import { promises as fs } from "node:fs";
12
+ import path from "node:path";
13
+ import os from "node:os";
14
+ import { extractDesignTokens } from "../extract";
15
+ import { patchDesignMd, patchDesignMdBatch } from "../patch";
16
+ import { diffDesignSpecs } from "../diff";
17
+ import { parseDesignMd } from "../parser";
18
+
19
+ // ─── extract ──────────────────────────────────────────────────────────
20
+
21
+ async function setupFixture(): Promise<{ root: string; cleanup: () => Promise<void> }> {
22
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-extract-"));
23
+ await fs.mkdir(path.join(root, "src"), { recursive: true });
24
+ await fs.mkdir(path.join(root, "app"), { recursive: true });
25
+ await fs.writeFile(
26
+ path.join(root, "src/a.tsx"),
27
+ `const a = "#FF8C42"; const b = "#FF8C42"; const c = "#FF8C42";
28
+ const elem = <div className="rounded-lg bg-orange-500 px-4 py-2 text-white" />;`,
29
+ );
30
+ await fs.writeFile(
31
+ path.join(root, "src/b.tsx"),
32
+ `const a = "#FF8C42"; const card = "#FFF8F0";
33
+ const elem = <div className="rounded-lg bg-orange-500 px-4 py-2 text-white" />;`,
34
+ );
35
+ await fs.writeFile(
36
+ path.join(root, "app/c.tsx"),
37
+ `const x = "rgb(99,91,255)"; const y = "rgb(99,91,255)"; const z = "rgb(99,91,255)";
38
+ const cssIsh = \`font-family: "Inter", sans-serif;\`;
39
+ const elem = <div className="rounded-lg bg-orange-500 px-4 py-2 text-white" />;`,
40
+ );
41
+ return { root, cleanup: () => fs.rm(root, { recursive: true, force: true }) };
42
+ }
43
+
44
+ describe("extractDesignTokens", () => {
45
+ let fix: { root: string; cleanup: () => Promise<void> };
46
+ beforeEach(async () => {
47
+ fix = await setupFixture();
48
+ });
49
+ afterEach(async () => {
50
+ await fix.cleanup();
51
+ });
52
+
53
+ it("flags colors that occur ≥ minOccurrences", async () => {
54
+ const result = await extractDesignTokens(fix.root);
55
+ const orange = result.proposals.find((p) => p.key === "#ff8c42");
56
+ expect(orange).toBeDefined();
57
+ expect(orange?.section).toBe("color-palette");
58
+ expect(orange?.occurrences).toBeGreaterThanOrEqual(3);
59
+ expect(orange?.confidence).toBeGreaterThan(0);
60
+ });
61
+
62
+ it("ignores colors below the threshold", async () => {
63
+ const result = await extractDesignTokens(fix.root, { minOccurrences: 4 });
64
+ expect(result.proposals.find((p) => p.key === "#fff8f0")).toBeUndefined();
65
+ });
66
+
67
+ it("flags font-family declarations", async () => {
68
+ const result = await extractDesignTokens(fix.root, { minOccurrences: 1 });
69
+ const inter = result.proposals.find(
70
+ (p) => p.section === "typography" && p.value.includes("Inter"),
71
+ );
72
+ expect(inter).toBeDefined();
73
+ });
74
+
75
+ it("flags repeating className combos", async () => {
76
+ const result = await extractDesignTokens(fix.root, { kinds: ["component"] });
77
+ const combo = result.proposals.find((p) => p.section === "components");
78
+ expect(combo).toBeDefined();
79
+ expect(combo?.occurrences).toBeGreaterThanOrEqual(3);
80
+ });
81
+
82
+ it("drops proposals already represented in the existing DesignSpec", async () => {
83
+ const existing = parseDesignMd(`# Test
84
+ ## Color Palette
85
+ - Primary — #FF8C42 — brand
86
+ `);
87
+ const result = await extractDesignTokens(fix.root, { existing });
88
+ expect(result.proposals.find((p) => p.key === "#ff8c42")).toBeUndefined();
89
+ });
90
+ });
91
+
92
+ // ─── patch ────────────────────────────────────────────────────────────
93
+
94
+ const SAMPLE = `# Test
95
+ ## Color Palette
96
+ - Primary — #FF8C42 — brand
97
+ - Surface — #FFF8F0 — background
98
+
99
+ ## Typography
100
+ - body: font-family: "Inter", sans-serif
101
+
102
+ ## Layout
103
+ - sm: 0.5rem
104
+ `;
105
+
106
+ describe("patchDesignMd — color-palette", () => {
107
+ it("adds a new token in the right section", () => {
108
+ const r = patchDesignMd(SAMPLE, {
109
+ section: "color-palette",
110
+ operation: "add",
111
+ key: "Accent",
112
+ value: "#1A1F36",
113
+ role: "text",
114
+ });
115
+ expect(r.applied).toBe(true);
116
+ expect(r.next).toContain("- Accent — #1A1F36 — text");
117
+ // Other sections untouched.
118
+ expect(r.next).toContain("## Typography");
119
+ expect(r.next).toContain("## Layout");
120
+ });
121
+
122
+ it("refuses to add a duplicate key (slug-insensitive)", () => {
123
+ const r = patchDesignMd(SAMPLE, {
124
+ section: "color-palette",
125
+ operation: "add",
126
+ key: "primary",
127
+ value: "#000",
128
+ });
129
+ expect(r.applied).toBe(false);
130
+ expect(r.reason).toContain("already exists");
131
+ });
132
+
133
+ it("updates a matching key", () => {
134
+ const r = patchDesignMd(SAMPLE, {
135
+ section: "color-palette",
136
+ operation: "update",
137
+ key: "Primary",
138
+ value: "#FF7733",
139
+ });
140
+ expect(r.applied).toBe(true);
141
+ expect(r.before).toContain("#FF8C42");
142
+ expect(r.after).toContain("#FF7733");
143
+ expect(r.next).toContain("#FF7733");
144
+ expect(r.next).not.toContain("#FF8C42");
145
+ });
146
+
147
+ it("removes a matching key", () => {
148
+ const r = patchDesignMd(SAMPLE, {
149
+ section: "color-palette",
150
+ operation: "remove",
151
+ key: "Surface",
152
+ });
153
+ expect(r.applied).toBe(true);
154
+ expect(r.before).toContain("Surface");
155
+ expect(r.next).not.toContain("Surface");
156
+ // Primary still there.
157
+ expect(r.next).toContain("Primary");
158
+ });
159
+
160
+ it("returns no-op + reason when section is absent", () => {
161
+ const r = patchDesignMd("# Empty\n", {
162
+ section: "color-palette",
163
+ operation: "add",
164
+ key: "X",
165
+ value: "#000",
166
+ });
167
+ expect(r.applied).toBe(false);
168
+ expect(r.reason).toContain("not found");
169
+ expect(r.next).toBe("# Empty\n");
170
+ });
171
+
172
+ it("requires a value on add/update", () => {
173
+ const r = patchDesignMd(SAMPLE, {
174
+ section: "color-palette",
175
+ operation: "add",
176
+ key: "Foo",
177
+ });
178
+ expect(r.applied).toBe(false);
179
+ expect(r.reason).toContain("required");
180
+ });
181
+ });
182
+
183
+ describe("patchDesignMd — components (H3)", () => {
184
+ it("adds a new component as an H3 heading", () => {
185
+ const r = patchDesignMd(
186
+ `# Test
187
+ ## Components
188
+
189
+ ### Button
190
+ A primary action.
191
+ `,
192
+ { section: "components", operation: "add", key: "Card" },
193
+ );
194
+ expect(r.applied).toBe(true);
195
+ expect(r.next).toContain("### Card");
196
+ });
197
+ });
198
+
199
+ describe("patchDesignMdBatch", () => {
200
+ it("applies operations in order against the cumulative source", () => {
201
+ const r = patchDesignMdBatch(SAMPLE, [
202
+ { section: "color-palette", operation: "add", key: "Accent", value: "#000" },
203
+ { section: "color-palette", operation: "remove", key: "Surface" },
204
+ { section: "color-palette", operation: "update", key: "Primary", value: "#111" },
205
+ ]);
206
+ expect(r.appliedCount).toBe(3);
207
+ expect(r.next).toContain("#111");
208
+ expect(r.next).toContain("#000");
209
+ expect(r.next).not.toContain("Surface");
210
+ });
211
+
212
+ it("partial success — failed ops surface in results, others still apply", () => {
213
+ const r = patchDesignMdBatch(SAMPLE, [
214
+ { section: "color-palette", operation: "remove", key: "doesnotexist" },
215
+ { section: "color-palette", operation: "add", key: "Accent", value: "#000" },
216
+ ]);
217
+ expect(r.appliedCount).toBe(1);
218
+ expect(r.results[0]?.applied).toBe(false);
219
+ expect(r.results[1]?.applied).toBe(true);
220
+ });
221
+ });
222
+
223
+ // ─── diff ─────────────────────────────────────────────────────────────
224
+
225
+ describe("diffDesignSpecs", () => {
226
+ it("flags added / removed / changed color tokens", () => {
227
+ const local = parseDesignMd(`# A
228
+ ## Color Palette
229
+ - Primary — #FF8C42 — brand
230
+ - Surface — #FFF8F0 — background
231
+ `);
232
+ const upstream = parseDesignMd(`# B
233
+ ## Color Palette
234
+ - Primary — #FF7733 — brand
235
+ - Accent — #1A1F36 — text
236
+ `);
237
+ const d = diffDesignSpecs(local, upstream);
238
+ expect(d.colorPalette.find((e) => e.kind === "changed" && e.name === "Primary")).toBeDefined();
239
+ expect(d.colorPalette.find((e) => e.kind === "added" && e.name === "Accent")).toBeDefined();
240
+ expect(d.colorPalette.find((e) => e.kind === "removed" && e.name === "Surface")).toBeDefined();
241
+ expect(d.totalChanges).toBe(3);
242
+ });
243
+
244
+ it("returns empty diff when specs are identical at the structured level", () => {
245
+ const a = parseDesignMd(`# T
246
+ ## Color Palette
247
+ - Primary — #FF8C42 — brand
248
+ `);
249
+ const b = parseDesignMd(`# Different prose, same tokens
250
+ ## Color Palette
251
+ - Primary — #FF8C42 — brand
252
+ `);
253
+ const d = diffDesignSpecs(a, b);
254
+ expect(d.totalChanges).toBe(0);
255
+ });
256
+
257
+ it("surfaces section presence changes", () => {
258
+ const local = parseDesignMd(`# A\n## Color Palette\n- Primary — #FF8C42 — brand\n`);
259
+ const upstream = parseDesignMd(
260
+ `# B\n## Color Palette\n- Primary — #FF8C42 — brand\n## Agent Prompts\n### Brand voice\nTone.\n`,
261
+ );
262
+ const d = diffDesignSpecs(local, upstream);
263
+ expect(d.sectionPresenceChanged).toContain("agent-prompts");
264
+ });
265
+ });
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Issue #245 M5 — DESIGN.md lint tests.
3
+ */
4
+
5
+ import { describe, it, expect } from "bun:test";
6
+ import { lintDesignSpec } from "../lint";
7
+ import { parseDesignMd } from "../parser";
8
+
9
+ describe("lintDesignSpec — color-palette", () => {
10
+ it("warns when parser couldn't extract a value (rule: color-missing-value)", () => {
11
+ // Parser drops non-hex/rgb values during extraction, so the
12
+ // resulting token has no `.value` — the linter flags that.
13
+ const r = lintDesignSpec(parseDesignMd(`# T
14
+ ## Color Palette
15
+ - Primary — #FF8C42 — brand
16
+ - Bad — notahex — wrong
17
+ `));
18
+ expect(
19
+ r.issues.find((i) => i.rule === "color-missing-value" && i.name === "Bad"),
20
+ ).toBeDefined();
21
+ });
22
+
23
+ it("warns on missing color value", () => {
24
+ const r = lintDesignSpec(parseDesignMd(`# T
25
+ ## Color Palette
26
+ - Primary — see docs
27
+ `));
28
+ expect(r.issues.find((i) => i.rule === "color-missing-value")?.severity).toBe("warning");
29
+ });
30
+
31
+ it("warns on slug collision", () => {
32
+ const r = lintDesignSpec(parseDesignMd(`# T
33
+ ## Color Palette
34
+ - Primary — #FF0000
35
+ - primary — #00FF00
36
+ `));
37
+ expect(r.issues.find((i) => i.rule === "color-slug-collision")).toBeDefined();
38
+ });
39
+
40
+ it("notes duplicate values across distinct names (info)", () => {
41
+ const r = lintDesignSpec(parseDesignMd(`# T
42
+ ## Color Palette
43
+ - Brand — #FF8C42 — main
44
+ - Accent — #FF8C42 — secondary
45
+ `));
46
+ const dup = r.issues.find((i) => i.rule === "color-duplicate-value");
47
+ expect(dup?.severity).toBe("info");
48
+ });
49
+ });
50
+
51
+ describe("lintDesignSpec — typography / layout / shadows / components", () => {
52
+ it("warns on a typography token with neither family nor size", () => {
53
+ const r = lintDesignSpec(parseDesignMd(`# T
54
+ ## Typography
55
+ - body: weight: 400
56
+ `));
57
+ expect(r.issues.find((i) => i.rule === "typography-empty-token")).toBeDefined();
58
+ });
59
+
60
+ it("warns on layout slug collision", () => {
61
+ const r = lintDesignSpec(parseDesignMd(`# T
62
+ ## Layout
63
+ - sm: 0.5rem
64
+ - SM: 0.5rem
65
+ `));
66
+ expect(r.issues.find((i) => i.rule === "spacing-slug-collision")).toBeDefined();
67
+ });
68
+
69
+ it("warns on shadow slug collision", () => {
70
+ const r = lintDesignSpec(parseDesignMd(`# T
71
+ ## Depth & Elevation
72
+ - card — 0 1px 2px rgba(0,0,0,0.1)
73
+ - Card — 0 2px 4px rgba(0,0,0,0.2)
74
+ `));
75
+ expect(r.issues.find((i) => i.rule === "shadow-slug-collision")).toBeDefined();
76
+ });
77
+
78
+ it("warns on duplicate component H3 names", () => {
79
+ const r = lintDesignSpec(parseDesignMd(`# T
80
+ ## Components
81
+
82
+ ### Button
83
+
84
+ ### button
85
+ `));
86
+ expect(r.issues.find((i) => i.rule === "component-duplicate")).toBeDefined();
87
+ });
88
+ });
89
+
90
+ describe("lintDesignSpec — clean DESIGN.md", () => {
91
+ it("returns ok with empty issues for a well-formed spec", () => {
92
+ const r = lintDesignSpec(parseDesignMd(`# T
93
+ ## Color Palette
94
+ - Primary — #FF8C42 — brand
95
+ - Accent — #1A1F36 — text
96
+
97
+ ## Typography
98
+ - body: font-family: Inter, sans-serif; size: 16px
99
+
100
+ ## Layout
101
+ - sm: 0.5rem
102
+ - md: 1rem
103
+
104
+ ## Depth & Elevation
105
+ - card — 0 1px 3px rgba(0,0,0,0.1)
106
+ `));
107
+ expect(r.issues).toEqual([]);
108
+ expect(r.ok).toBe(true);
109
+ });
110
+ });
@@ -0,0 +1,165 @@
1
+ /**
2
+ * AGENTS.md / CLAUDE.md ↔ DESIGN.md linker (Issue #245 M5).
3
+ *
4
+ * Drops a markered `## Design System` section into the project's
5
+ * agent guide files so coding agents read DESIGN.md and use the
6
+ * Mandu MCP tools (M4) before touching UI. Idempotent — running it
7
+ * twice never duplicates the section.
8
+ *
9
+ * The linker writes the same payload to whichever of `AGENTS.md` /
10
+ * `CLAUDE.md` exists. When neither exists in `force: true` mode it
11
+ * creates `AGENTS.md` (the open standard) seeded with just the
12
+ * design block — agents that consume `CLAUDE.md` follow the
13
+ * cross-reference to `AGENTS.md` per Anthropic's convention.
14
+ *
15
+ * The injected section:
16
+ *
17
+ * - Names DESIGN.md as the canonical design source.
18
+ * - Lists the 8 MCP tools with one-line descriptions.
19
+ * - Spells out the §3.5 incremental loop as a 5-step prompt agents
20
+ * can follow verbatim.
21
+ */
22
+
23
+ import { promises as fs } from "node:fs";
24
+ import path from "node:path";
25
+
26
+ export const DESIGN_LINK_MARKER_START =
27
+ "<!-- @mandu-design-link:start — managed by `mandu design link` / `init --design`, do not edit -->";
28
+ export const DESIGN_LINK_MARKER_END = "<!-- @mandu-design-link:end -->";
29
+
30
+ export interface LinkAgentsOptions {
31
+ /** Project root. */
32
+ rootDir: string;
33
+ /** Filenames to update. Defaults to `["AGENTS.md", "CLAUDE.md"]`. */
34
+ filenames?: readonly string[];
35
+ /**
36
+ * When true, create a fresh `AGENTS.md` containing just the design
37
+ * link block when none of `filenames` exists. Default false — the
38
+ * linker only updates existing files unless explicitly asked.
39
+ */
40
+ createIfMissing?: boolean;
41
+ /** Override DESIGN.md filename. Defaults to `DESIGN.md`. */
42
+ designFilename?: string;
43
+ }
44
+
45
+ export interface LinkAgentsResult {
46
+ files: Array<{
47
+ path: string;
48
+ /** "created" | "inserted" (markered block added) | "updated" (markered block replaced) | "unchanged" */
49
+ action: "created" | "inserted" | "updated" | "unchanged";
50
+ }>;
51
+ /** True when at least one file was written. */
52
+ changed: boolean;
53
+ }
54
+
55
+ /** Generate the markered block payload. Pure — used by tests too. */
56
+ export function buildAgentsDesignBlock(designFilename: string = "DESIGN.md"): string {
57
+ const lines = [
58
+ DESIGN_LINK_MARKER_START,
59
+ "",
60
+ "## Design System",
61
+ "",
62
+ `This project uses **${designFilename}** as the single source of truth for visual design (colors, typography, spacing, shadows, components, agent prompts).`,
63
+ "Mandu's MCP tools expose every part of it without grepping the codebase. Agents MUST read DESIGN.md *before* writing or editing UI.",
64
+ "",
65
+ "### Tools (call before / during UI work)",
66
+ "",
67
+ "| Phase | Tool | Use it for |",
68
+ "|---|---|---|",
69
+ "| Read | `mandu.design.get` | Section-by-section DESIGN.md (or `'all'` for full spec). |",
70
+ "| Read | `mandu.design.prompt` | §9 Agent Prompts — pre-warm context every session. |",
71
+ "| Read | `mandu.component.list` | Existing components in `src/client/shared/ui/` + `widgets/`. Don't re-implement. |",
72
+ "| Check | `mandu.design.check` | Lint a file BEFORE editing — surfaces forbidden inline classes. |",
73
+ "| Discover | `mandu.design.extract` | Find token candidates the project uses but DESIGN.md doesn't list. |",
74
+ "| Patch | `mandu.design.propose` | One-call: extract → dry-run patch → user reviews diff. |",
75
+ "| Patch | `mandu.design.patch` | Section-safe add/update/remove. Defaults to `dry_run: true`. |",
76
+ "| Sync | `mandu.design.diff_upstream` | Compare against awesome-design-md slugs (e.g. `'stripe'`). |",
77
+ "",
78
+ "### 5-step UI workflow",
79
+ "",
80
+ "1. **Pre-warm.** Call `mandu.design.prompt` (and `mandu.design.get` for the section you'll touch) so your edit honours existing tokens.",
81
+ "2. **Inventory.** Call `mandu.component.list` for the matching category. Re-use first; only add when nothing fits.",
82
+ "3. **Check.** Before editing a file, call `mandu.design.check { file }` to see if the file already violates DESIGN.md §7.",
83
+ "4. **Edit.** Write the change. Use existing tokens from §2 / components from §1.",
84
+ "5. **Propose tokens.** If you introduced a new color/font/spacing pattern, call `mandu.design.propose` and ask the user to apply the patch (default `dry_run: true`).",
85
+ "",
86
+ "### Hard rules",
87
+ "",
88
+ "- Never invent colors / fonts / spacing values without a `mandu.design.propose` round.",
89
+ "- Never bypass `mandu.design.check` on a file you're about to edit.",
90
+ "- DESIGN.md is the spec — when in doubt, update DESIGN.md *first*, then write code.",
91
+ "",
92
+ DESIGN_LINK_MARKER_END,
93
+ "",
94
+ ];
95
+ return lines.join("\n");
96
+ }
97
+
98
+ /**
99
+ * Update agent guide files to reference DESIGN.md and the MCP tools.
100
+ *
101
+ * Behaviour per file:
102
+ * - Marker present → replace the markered region (idempotent).
103
+ * - File exists, no marker → append the block at the end.
104
+ * - File missing → skip unless `createIfMissing: true`.
105
+ */
106
+ export async function linkAgentsToDesignMd(
107
+ options: LinkAgentsOptions,
108
+ ): Promise<LinkAgentsResult> {
109
+ const filenames = options.filenames ?? ["AGENTS.md", "CLAUDE.md"];
110
+ const block = buildAgentsDesignBlock(options.designFilename);
111
+ const files: LinkAgentsResult["files"] = [];
112
+
113
+ let anyExists = false;
114
+ for (const name of filenames) {
115
+ const full = path.join(options.rootDir, name);
116
+ let existing: string | null;
117
+ try {
118
+ existing = await fs.readFile(full, "utf8");
119
+ anyExists = true;
120
+ } catch {
121
+ existing = null;
122
+ }
123
+
124
+ if (existing === null) {
125
+ files.push({ path: full, action: "unchanged" });
126
+ continue;
127
+ }
128
+
129
+ const startIdx = existing.indexOf(DESIGN_LINK_MARKER_START);
130
+ const endIdx = existing.indexOf(DESIGN_LINK_MARKER_END);
131
+ let next: string;
132
+ let action: LinkAgentsResult["files"][number]["action"];
133
+ if (startIdx >= 0 && endIdx > startIdx) {
134
+ const before = existing.slice(0, startIdx);
135
+ const after = existing.slice(endIdx + DESIGN_LINK_MARKER_END.length);
136
+ const replacement = block.replace(/\n+$/, "");
137
+ next = `${before}${replacement}${after}`;
138
+ action = next === existing ? "unchanged" : "updated";
139
+ } else {
140
+ const sep = existing.endsWith("\n") ? "" : "\n";
141
+ next = `${existing}${sep}\n${block}`;
142
+ action = "inserted";
143
+ }
144
+
145
+ if (action !== "unchanged") {
146
+ await fs.writeFile(full, next, "utf8");
147
+ }
148
+ files.push({ path: full, action });
149
+ }
150
+
151
+ if (!anyExists && options.createIfMissing) {
152
+ const target = path.join(options.rootDir, filenames[0] ?? "AGENTS.md");
153
+ const seed = `# Project Agent Guide\n\n${block}`;
154
+ await fs.writeFile(target, seed, "utf8");
155
+ // Replace the placeholder "unchanged" entry with the real outcome.
156
+ const idx = files.findIndex((f) => f.path === target);
157
+ if (idx >= 0) files[idx] = { path: target, action: "created" };
158
+ else files.push({ path: target, action: "created" });
159
+ }
160
+
161
+ return {
162
+ files,
163
+ changed: files.some((f) => f.action !== "unchanged"),
164
+ };
165
+ }