1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Cursor と Claude Code のルールを symlink で共有してはいけない — 生成して同期する

1
Last updated at Posted at 2026-07-12

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コマンド走らせれば、両ツールが正しいままになる。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?