DEV Community

Hiroshi Toyama
Hiroshi Toyama

Posted on

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

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.

The two rule systems

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.
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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.

Why one shared directory can't work

The tempting shortcut:

ln -s ../.claude/rules .cursor/rules
Enter fullscreen mode Exit fullscreen mode

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:

  • Cursor: globs: as a comma-separated string, plus alwaysApply:.
  • Claude Code: paths: as a YAML list, no 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.

The fix: one source, generated outputs

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));
}
Enter fullscreen mode Exit fullscreen mode

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.

Gotchas

  • A hand-written YAML parser is fine because the shape is fixed. These frontmatter blocks are tiny and you control them. Don't pull in a YAML dependency for five files with two keys each — but do keep the parser honest about what it supports (scalar description, list paths) and nothing more.
  • alwaysApply: false + no globs has no Claude Code equivalent. Cursor's "Agent Requested" type (the agent decides relevance from the description) doesn't map. Going Claude → Cursor, a rule with no paths becomes alwaysApply: true. If you want scoping on the Cursor side, add explicit paths to the source.
  • Commit the generated .mdc or gitignore them — pick one and be explicit. Committing means Cursor users don't need Node to bootstrap; gitignoring keeps the diff clean. Either is fine; leaving it ambiguous means someone edits the generated file by hand and loses the change on the next run. The AUTO-GENERATED banner exists to catch exactly that.

Summary

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.

Top comments (0)