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

> Source: <https://dev.to/toyama0919/stop-symlinking-your-cursor-and-claude-code-rules-generate-them-instead-3c9n>
> Published: 2026-07-12 11:58:52+00:00

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
---
# ESLint after edits
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}"
---
# ESLint after edits
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 loader *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:

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