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.