cd /news/developer-tools/stop-symlinking-your-cursor-and-clau… · home topics developer-tools article
[ARTICLE · art-56153] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

Stop Symlinking Your Cursor and Claude Code Rules — Generate Them Instead

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`.

read5 min views1 publishedJul 12, 2026

If you use both Cursor and Claude Code on the same repo, you have probably noticed both tools support a rules/

directory 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.

Both tools let you drop markdown files with YAML frontmatter into a project directory. Instructions apply either always or scoped to file globs.

Cursor.cursor/rules/*.mdc

:

---
description: "Run ESLint after changing TS/JS"
globs: extensions/app/**/*.{ts,tsx,js}
alwaysApply: false
---
Run `npm run lint` before considering the task done.

Cursor derives the rule type from a combination of three fields:

alwaysApply | description | globs | Type | |---|---|---|---| true | — | — | Always | false | — | provided | Auto Attached | false | provided | omitted | Agent Requested | false | omitted | omitted | Manual (@-only) |

Claude Code.claude/rules/*.md

:

---
description: Run ESLint after changing TS/JS
paths:
  - "extensions/app/**/*.{ts,tsx,js}"
---
Run `npm run lint` before considering the task done.

Claude Code's model is simpler: a rule with a paths:

list loads only when Claude reads a matching file; a rule with no paths:

loads unconditionally every session (same priority as .claude/CLAUDE.md

). There is no "agent decides from description" mode.

The tempting shortcut:

ln -s ../.claude/rules .cursor/rules

One physical directory, both tools point at it, done. Except it breaks on two independent axes:

1. File extension. Cursor's rule only reads .mdc

and ignores plain .md

. Claude Code discovers .md

and ignores .mdc

. 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.

2. Frontmatter keys. Even if you dodged the extension problem, the scoping keys differ:

globs:

as a alwaysApply:

.paths:

as a alwaysApply

.Point Claude Code at a Cursor-format file and it sees no paths:

key, 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.

This is the trap that motivated the whole exercise: I had .cursor/rules -> ../.claude/rules

and "fixed" the files to Claude Code's format, which instantly broke Cursor without a single error message.

Since 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

as the source (Claude Code reads it directly) and generate .cursor/rules/*.mdc

.

The mapping is mechanical:

Source (.md ) | Generated (.mdc ) | |---|---| paths: list present | globs: <comma-joined> + alwaysApply: false | paths: absent | alwaysApply: true | description: | carried over verbatim |

Here is the core of the generator — plain Node, zero dependencies, so there is no package.json

to maintain just for this:

import { readdirSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
import { join } from 'node:path';

const SRC_DIR = '.claude/rules';
const OUT_DIR = '.cursor/rules';

function splitFrontmatter(text) {
  const m = text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
  return m ? { fm: m[1], body: m[2] } : { fm: '', body: text };
}

// Minimal parser for our known shape: `description:` scalar + `paths:` list.
function parseFrontmatter(fm) {
  const out = { description: undefined, paths: [] };
  const lines = fm.split('\n');
  for (let i = 0; i < lines.length; i++) {
    const desc = lines[i].match(/^description:\s*(.*)$/);
    if (desc) { out.description = desc[1].trim(); continue; }
    if (/^paths:\s*$/.test(lines[i])) {
      for (let j = i + 1; j < lines.length; j++) {
        const item = lines[j].match(/^\s*-\s*(.*)$/);
        if (!item) break;
        out.paths.push(item[1].trim().replace(/^["']|["']$/g, ''));
        i = j;
      }
    }
  }
  return out;
}

function toCursorFrontmatter({ description, paths }) {
  const lines = ['---'];
  if (description) lines.push(`description: ${description}`);
  if (paths.length) {
    lines.push(`globs: ${paths.join(', ')}`);
    lines.push('alwaysApply: false');
  } else {
    lines.push('alwaysApply: true');
  }
  lines.push('---');
  return lines.join('\n');
}

mkdirSync(OUT_DIR, { recursive: true });
const sources = readdirSync(SRC_DIR).filter((f) => f.endsWith('.md'));
const expected = new Set(sources.map((f) => f.replace(/\.md$/, '.mdc')));

for (const file of sources) {
  const { fm, body } = splitFrontmatter(readFileSync(join(SRC_DIR, file), 'utf8'));
  const outName = file.replace(/\.md$/, '.mdc');
  const banner = '<!-- AUTO-GENERATED from .claude/rules. Edit the .md source. -->';
  writeFileSync(
    join(OUT_DIR, outName),
    `${toCursorFrontmatter(parseFrontmatter(fm))}\n\n${banner}\n${body.replace(/^\n+/, '')}`,
  );
}

// Drop generated files whose source .md was deleted.
for (const f of readdirSync(OUT_DIR)) {
  if (f.endsWith('.mdc') && !expected.has(f)) rmSync(join(OUT_DIR, f));
}

Run node scripts/sync-cursor-rules.mjs

after editing any rule. The stale-file sweep at the end means deleting a source .md

also removes its generated .mdc

, so the two directories never drift.

description

, list paths

) and nothing more.alwaysApply: false

  • no globs

has no Claude Code equivalent.paths

becomes alwaysApply: true

. If you want scoping on the Cursor side, add explicit paths

to the source..mdc

or gitignore them — pick one and be explicit.AUTO-GENERATED

banner exists to catch exactly that.Cursor and Claude Code rules look shareable but aren't: different extensions (.mdc

vs .md

) and different scoping schemas (globs

string vs paths

list) 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.

── more in #developer-tools 4 stories · sorted by recency
── more on @cursor 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/stop-symlinking-your…] indexed:0 read:5min 2026-07-12 ·