Cursor と Claude Code のルールを symlink で共有してはいけない — 生成して同期する
Cursor と Claude Code を同じ repo で併用していると、両方とも「プロジェクトルール」ファイルの仕組みを持っていることに気づく。永続的な指示を markdown で書いておくと、常時 or ファイルパスに応じて AI に読み込ませられるやつだ。素朴に考えると「同じディレクトリを symlink で共有すればいい」となる。これがダメ。2つのフォーマットは一見互換に見えて、静かに壊れる程度に微妙に違う。この記事では何がどう非互換なのかと、1つの source of truth から両フォーマットを生成する小さなスクリプトを紹介する。
2つのルールシステム
どちらも「YAML frontmatter 付き markdown をプロジェクト内のディレクトリに置く」という形式で、指示を常時適用 or glob でスコープする。
Cursor — .cursor/rules/*.mdc:
---
description: Run ESLint after changing TS/JS
globs: extensions/app/**/*.{ts,tsx,js}
alwaysApply: false
---
# ESLint after edits
タスク完了前に `npm run lint` を走らせる。
Cursor はルールの種別を3つのフィールドの組み合わせで決める。
alwaysApply |
description |
globs |
種別 |
|---|---|---|---|
true |
— | — | Always |
false |
— | あり | Auto Attached |
false |
あり | なし | Agent Requested |
false |
なし | なし | Manual(@指定のみ) |
Claude Code — .claude/rules/*.md:
---
description: Run ESLint after changing TS/JS
paths:
- "extensions/app/**/*.{ts,tsx,js}"
---
# ESLint after edits
タスク完了前に `npm run lint` を走らせる。
Claude Code のモデルはもっとシンプルだ。paths: リストを持つルールはマッチするファイルを読んだときだけロードされ、paths: を持たないルールは毎セッション常時ロードされる(.claude/CLAUDE.md と同じ優先度)。Cursor の「description から agent が判断する」モードに相当するものは無い。
なぜ1ディレクトリ共有が成立しないのか
やりたくなるショートカット:
ln -s ../.claude/rules .cursor/rules
物理ディレクトリは1つ、両ツールがそこを見る、完了。……とはいかない。独立した2つの軸で壊れる。
1. 拡張子。 Cursor のルールローダーは .mdc しか読まず、素の .md は無視する。Claude Code は .md を探して .mdc を無視する。ディスク上の1ファイルが .mdc と .md を同時に名乗ることはできないので、どちらを選んでも片方のツールがルール0件になる。エラーも警告も出ない。ただ静かに適用されない。
2. frontmatter のキー。 仮に拡張子問題を回避できても、スコープ指定のキーが違う。
- Cursor:
globs:はカンマ区切りの文字列、加えてalwaysApply:。 - Claude Code:
paths:は YAML リスト、alwaysApplyは無い。
Claude Code に Cursor 形式のファイルを食わせると paths: キーが見つからないので、すべてのルールを常時ロード扱いにする。丁寧にスコープしたはずの API 用ルールが全セッションで読み込まれる。ファイル自体は読めているので、この失敗は目に見えない。ただスキーマを取り違えて解釈しているだけだ。
これが今回の発端になった罠だった。.cursor/rules -> ../.claude/rules の symlink があった状態で、ファイルを Claude Code 形式に「直した」ら、エラーメッセージ1つ出さずに Cursor 側が即死した。
解決策: source を1つにして生成する
本文は同一で frontmatter だけ違うのだから、片方を source of truth として扱い、もう片方を生成すればいい。ここでは .claude/rules/*.md を source(Claude Code はこれを直接読む)とし、.cursor/rules/*.mdc を生成する。
変換は機械的だ。
Source (.md) |
生成物 (.mdc) |
|---|---|
paths: リストあり |
globs: <カンマ結合> + alwaysApply: false
|
paths: なし |
alwaysApply: true |
description: |
そのまま引き継ぎ |
生成スクリプトの中核はこれ。Node 標準ライブラリのみ、依存ゼロ。これだけのために package.json を維持したくないからだ。
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 };
}
// 既知の形だけ扱う最小パーサ: `description:` スカラ + `paths:` リスト
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+/, '')}`,
);
}
// source の .md が消えた生成物を掃除
for (const f of readdirSync(OUT_DIR)) {
if (f.endsWith('.mdc') && !expected.has(f)) rmSync(join(OUT_DIR, f));
}
ルールを編集したら node scripts/sync-cursor-rules.mjs を実行するだけ。末尾の stale ファイル掃除のおかげで、source の .md を消せば対応する .mdc も消える。2つのディレクトリがドリフトしない。
注意点
-
手書きの YAML パーサでいい。ただし「形が固定だから」だ。 この frontmatter は極小で、自分で管理している。5ファイル・各2キーのために YAML ライブラリを入れるな。ただしパーサが対応する範囲(スカラ
description、リストpaths)を正直に保ち、それ以上を勝手に扱わないこと。 -
alwaysApply: false+globsなし には Claude Code の等価物が無い。 Cursor の「Agent Requested」種別(description から agent が関連性を判断する)はマップできない。Claude → Cursor の向きでは、pathsの無いルールはalwaysApply: trueになる。Cursor 側でスコープしたければ source に明示的なpathsを足す。 -
生成した
.mdcは commit するか gitignore するか、どちらかに明示的に倒す。 commit すれば Cursor 利用者が Node なしで使える。gitignore すれば diff がきれいになる。どちらでもいいが、曖昧にすると誰かが生成物を手で編集して次回実行で変更を失う。AUTO-GENERATEDバナーはまさにそれを防ぐためにある。
まとめ
Cursor と Claude Code のルールは共有できそうに見えて共有できない。拡張子(.mdc vs .md)とスコープのスキーマ(globs 文字列 vs paths リスト)、この2つがどちらも、1ディレクトリで両対応を強制すると静かに壊れる。40行弱の依存ゼロ生成スクリプトなら、1つの source of truth を両フォーマットに変換し、stale 掃除で出力がドリフトしない。1箇所を編集し、1コマンド走らせれば、両ツールが正しいままになる。