{"slug": "stop-symlinking-your-cursor-and-claude-code-rules-generate-them-instead", "title": "Stop Symlinking Your Cursor and Claude Code Rules — Generate Them Instead", "summary": "A developer building with both Cursor and Claude Code discovered that symlinking their rules directories fails silently due to incompatible file extensions and frontmatter schemas. They built a zero-dependency Node.js code generator that keeps both formats in sync from a single source of truth in `.claude/rules/*.md`.", "body_md": "If you use both Cursor and Claude Code on the same repo, you have probably noticed both tools support a `rules/`\n\ndirectory of persistent instructions. The obvious move is to make them share one directory with a symlink. Don't. The two formats overlap *just* enough to look compatible and *just* differently enough to fail silently. This post walks through the exact incompatibilities and a small code generator that keeps both formats in sync from a single source of truth.\n\nBoth tools let you drop markdown files with YAML frontmatter into a project directory. Instructions apply either always or scoped to file globs.\n\n**Cursor** — `.cursor/rules/*.mdc`\n\n:\n\n```\n---\ndescription: \"Run ESLint after changing TS/JS\"\nglobs: extensions/app/**/*.{ts,tsx,js}\nalwaysApply: false\n---\n# ESLint after edits\nRun `npm run lint` before considering the task done.\n```\n\nCursor derives the rule *type* from a combination of three fields:\n\n`alwaysApply` |\n`description` |\n`globs` |\nType |\n|---|---|---|---|\n`true` |\n— | — | Always |\n`false` |\n— | provided | Auto Attached |\n`false` |\nprovided | omitted | Agent Requested |\n`false` |\nomitted | omitted | Manual (@-only) |\n\n**Claude Code** — `.claude/rules/*.md`\n\n:\n\n```\n---\ndescription: Run ESLint after changing TS/JS\npaths:\n  - \"extensions/app/**/*.{ts,tsx,js}\"\n---\n# ESLint after edits\nRun `npm run lint` before considering the task done.\n```\n\nClaude Code's model is simpler: a rule with a `paths:`\n\nlist loads only when Claude reads a matching file; a rule with **no** `paths:`\n\nloads unconditionally every session (same priority as `.claude/CLAUDE.md`\n\n). There is no \"agent decides from description\" mode.\n\nThe tempting shortcut:\n\n```\nln -s ../.claude/rules .cursor/rules\n```\n\nOne physical directory, both tools point at it, done. Except it breaks on two independent axes:\n\n**1. File extension.** Cursor's rule loader *only* reads `.mdc`\n\nand ignores plain `.md`\n\n. Claude Code discovers `.md`\n\nand ignores `.mdc`\n\n. A single file on disk cannot have both extensions, so whichever you pick, exactly one tool silently sees zero rules. No error, no warning — the rules just don't apply.\n\n**2. Frontmatter keys.** Even if you dodged the extension problem, the scoping keys differ:\n\n`globs:`\n\nas a `alwaysApply:`\n\n.`paths:`\n\nas a `alwaysApply`\n\n.Point Claude Code at a Cursor-format file and it sees no `paths:`\n\nkey, so it treats *every* rule as always-on — your carefully scoped API rule now loads on every session. The failure is invisible because the file *is* being read; it's just being interpreted with the wrong schema.\n\nThis is the trap that motivated the whole exercise: I had `.cursor/rules -> ../.claude/rules`\n\nand \"fixed\" the files to Claude Code's format, which instantly broke Cursor without a single error message.\n\nSince the bodies are identical and only the frontmatter differs, treat one format as the source of truth and generate the other. I picked `.claude/rules/*.md`\n\nas the source (Claude Code reads it directly) and generate `.cursor/rules/*.mdc`\n\n.\n\nThe mapping is mechanical:\n\nSource (`.md` ) |\nGenerated (`.mdc` ) |\n|---|---|\n`paths:` list present |\n`globs: <comma-joined>` + `alwaysApply: false`\n|\n`paths:` absent |\n`alwaysApply: true` |\n`description:` |\ncarried over verbatim |\n\nHere is the core of the generator — plain Node, zero dependencies, so there is no `package.json`\n\nto maintain just for this:\n\n``` js\nimport { readdirSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';\nimport { join } from 'node:path';\n\nconst SRC_DIR = '.claude/rules';\nconst OUT_DIR = '.cursor/rules';\n\nfunction splitFrontmatter(text) {\n  const m = text.match(/^---\\n([\\s\\S]*?)\\n---\\n?([\\s\\S]*)$/);\n  return m ? { fm: m[1], body: m[2] } : { fm: '', body: text };\n}\n\n// Minimal parser for our known shape: `description:` scalar + `paths:` list.\nfunction parseFrontmatter(fm) {\n  const out = { description: undefined, paths: [] };\n  const lines = fm.split('\\n');\n  for (let i = 0; i < lines.length; i++) {\n    const desc = lines[i].match(/^description:\\s*(.*)$/);\n    if (desc) { out.description = desc[1].trim(); continue; }\n    if (/^paths:\\s*$/.test(lines[i])) {\n      for (let j = i + 1; j < lines.length; j++) {\n        const item = lines[j].match(/^\\s*-\\s*(.*)$/);\n        if (!item) break;\n        out.paths.push(item[1].trim().replace(/^[\"']|[\"']$/g, ''));\n        i = j;\n      }\n    }\n  }\n  return out;\n}\n\nfunction toCursorFrontmatter({ description, paths }) {\n  const lines = ['---'];\n  if (description) lines.push(`description: ${description}`);\n  if (paths.length) {\n    lines.push(`globs: ${paths.join(', ')}`);\n    lines.push('alwaysApply: false');\n  } else {\n    lines.push('alwaysApply: true');\n  }\n  lines.push('---');\n  return lines.join('\\n');\n}\n\nmkdirSync(OUT_DIR, { recursive: true });\nconst sources = readdirSync(SRC_DIR).filter((f) => f.endsWith('.md'));\nconst expected = new Set(sources.map((f) => f.replace(/\\.md$/, '.mdc')));\n\nfor (const file of sources) {\n  const { fm, body } = splitFrontmatter(readFileSync(join(SRC_DIR, file), 'utf8'));\n  const outName = file.replace(/\\.md$/, '.mdc');\n  const banner = '<!-- AUTO-GENERATED from .claude/rules. Edit the .md source. -->';\n  writeFileSync(\n    join(OUT_DIR, outName),\n    `${toCursorFrontmatter(parseFrontmatter(fm))}\\n\\n${banner}\\n${body.replace(/^\\n+/, '')}`,\n  );\n}\n\n// Drop generated files whose source .md was deleted.\nfor (const f of readdirSync(OUT_DIR)) {\n  if (f.endsWith('.mdc') && !expected.has(f)) rmSync(join(OUT_DIR, f));\n}\n```\n\nRun `node scripts/sync-cursor-rules.mjs`\n\nafter editing any rule. The stale-file sweep at the end means deleting a source `.md`\n\nalso removes its generated `.mdc`\n\n, so the two directories never drift.\n\n`description`\n\n, list `paths`\n\n) and nothing more.`alwaysApply: false`\n\n+ no `globs`\n\nhas no Claude Code equivalent.`paths`\n\nbecomes `alwaysApply: true`\n\n. If you want scoping on the Cursor side, add explicit `paths`\n\nto the source.`.mdc`\n\nor gitignore them — pick one and be explicit.`AUTO-GENERATED`\n\nbanner exists to catch exactly that.Cursor and Claude Code rules look shareable but aren't: different extensions (`.mdc`\n\nvs `.md`\n\n) and different scoping schemas (`globs`\n\nstring vs `paths`\n\nlist) both fail *silently* when you force one directory to serve both. A ~40-line dependency-free generator turns one source of truth into both formats and includes a stale-file sweep so the outputs never drift. Edit one place, run one command, both tools stay correct.", "url": "https://wpnews.pro/news/stop-symlinking-your-cursor-and-claude-code-rules-generate-them-instead", "canonical_source": "https://dev.to/toyama0919/stop-symlinking-your-cursor-and-claude-code-rules-generate-them-instead-3c9n", "published_at": "2026-07-12 11:58:52+00:00", "updated_at": "2026-07-12 12:15:07.469156+00:00", "lang": "en", "topics": ["developer-tools", "artificial-intelligence", "large-language-models"], "entities": ["Cursor", "Claude Code", "Node.js"], "alternates": {"html": "https://wpnews.pro/news/stop-symlinking-your-cursor-and-claude-code-rules-generate-them-instead", "markdown": "https://wpnews.pro/news/stop-symlinking-your-cursor-and-claude-code-rules-generate-them-instead.md", "text": "https://wpnews.pro/news/stop-symlinking-your-cursor-and-claude-code-rules-generate-them-instead.txt", "jsonld": "https://wpnews.pro/news/stop-symlinking-your-cursor-and-claude-code-rules-generate-them-instead.jsonld"}}