Load .cursor/rules and .cursor/commands as native Claude Code rules and slash commands

Personal Claude Code mods, shipped as a plugin marketplace.
A mod is code that runs inside Claude Code with your permissions. Read a mod's source before you install it.
Needs Claude Code v2.1.287 or later (claude --version).
claude plugin marketplace add il-tmfv/ccmods
claude plugin install cursor-rules@ccmods --scope user
/reload-plugins in an open session.claude plugin list shows cursor-rules@ccmods as enabled.Inside a session the same thing is /plugin marketplace add il-tmfv/ccmods, then /plugin install cursor-rules@ccmods.
claude plugin marketplace update ccmods
claude plugin update cursor-rules@ccmods
git clone git@github.com:il-tmfv/ccmods.git
claude --plugin-dir ccmods/plugins/cursor-rules
Makes Claude Code read what a repo keeps for Cursor, with no converter and nothing to keep in sync. Files are read from disk, so an edited rule applies at once.
| Cursor | What Claude Code gets |
|---|---|
.cursor/rules/*.mdc with alwaysApply: true | The rule text in the first message of each conversation |
.cursor/rules/*.mdc with globs | The rule text, attached to the result of the first Read, Edit or Write of a matching file. It arrives after the tool has run, so a Write of a new file with no earlier Read sees the rule only afterward |
.cursor/rules/*.mdc with only description | A one-line index in the first message. Claude opens the file when the task matches |
.cursor/commands/*.md in the project | A /name slash command that sends the file as your prompt. Text after the name is appended |
~/.cursor/commands/*.md | The same, in every project. A project command wins over a user one with the same name |
Details:
*.rb matches the file name at any depth, app/services/*.rb is anchored to the project root, ** crosses directories, {a,b} works./clear or a compaction, glob rules are handed over again on the next matching file..cursor/rules/ at the session root, up to three directories deep. Rules in nested .cursor/rules/ folders of a monorepo are not read./reload-plugins or a restart. Edits to an existing command apply immediately.cd plugins/cursor-rules
claude plugin validate .
claude plugin test
Run against a real project without installing: claude --plugin-dir plugins/cursor-rules.
plugins/<name>/ with .claude-plugin/plugin.json and hooks/hooks.json..claude-plugin/marketplace.json.claude plugin validate . in the repo root.hooks/register.ts 147 lines1import type { EngineInterface, Register } from 'claude-code'
2import { commandDescription, commandName, matchesGlobs, parseRule, splitFrontmatter, type Rule } from './lib'
3
4const RULE_FILE = /\.mdc?$/
5const MAX_DEPTH = 3
6
7// Parsed rules by path, reparsed when the file's mtime changes
8const cache = new Map<string, { mtimeMs: number; rule: Rule }>()
9// Rules already handed to Claude in this conversation
10const applied = new Set<string>()
11// Slash command name to the Cursor command file behind it
12const commands = new Map<string, string>()
13
14async function loadRules($: EngineInterface, dir: string, depth = 0): Promise<Rule[]> {
15 if (depth === 0 && !(await $.fs.exists(dir))) return []
16 const rules: Rule[] = []
17 let entries: Awaited<ReturnType<typeof $.fs.list>>
18 try {
19 entries = await $.fs.list(dir)
20 } catch {
21 return rules
22 }
23 for (const entry of entries) {
24 const path = `${dir}/${entry.name}`
25 if (entry.kind === 'dir' && depth < MAX_DEPTH) {
26 rules.push(...(await loadRules($, path, depth + 1)))
27 } else if (entry.kind === 'file' && RULE_FILE.test(entry.name)) {
28 const cached = cache.get(path)
29 if (cached !== undefined && cached.mtimeMs === entry.mtimeMs) {
30 rules.push(cached.rule)
31 continue
32 }
33 try {
34 const rule = parseRule(path, entry.name.replace(RULE_FILE, ''), await $.fs.read(path))
35 cache.set(path, { mtimeMs: entry.mtimeMs, rule })
36 rules.push(rule)
37 } catch {
38 // An unreadable rule file is skipped; the others still load
39 }
40 }
41 }
42 return rules
43}
44
45function render(rule: Rule, root: string): string {
46 const where = rule.globs.length > 0 ? ` (applies to ${rule.globs.join(', ')})` : ''
47 return `Cursor rule "${rule.name}"${where}, from ${rule.path.replace(root + '/', '')}:\n\n${rule.body}`
48}
49
50export const register: Register = on => {
51 // Rules that are always on, plus a one-line index of rules Claude may open on its own.
52 // The host fires this once per conversation, so no dedupe is needed here; `applied` only
53 // keeps a rule that has both alwaysApply and globs from being handed over a second time
54 on('prompt.context', async ($, e, next) => {
55 const base = await next(e)
56 try {
57 const root = await $.session.root()
58 const rules = await loadRules($, `${root}/.cursor/rules`)
59 const always = rules.filter(rule => rule.isAlways && rule.body !== '')
60 const optional = rules.filter(rule => !rule.isAlways && rule.globs.length === 0 && rule.description !== '')
61 const parts = always.map(rule => render(rule, root))
62 if (optional.length > 0) {
63 const index = optional.map(rule => `- ${rule.path.replace(root + '/', '')}: ${rule.description}`)
64 parts.push('Cursor rules to read when the task matches their description:\n' + index.join('\n'))
65 }
66 if (parts.length === 0) return base
67 for (const rule of always) applied.add(rule.path)
68 return { ...base, blocks: [...base.blocks, { name: 'cursorRules', text: parts.join('\n\n---\n\n') }] }
69 } catch {
70 return base
71 }
72 }).catch(($, e, next) => next(e))
73
74 // Glob rules load the first time Claude touches a matching file, as native path rules do.
75 // The host attaches `context` to the tool's result, so it arrives after the call has run:
76 // a Write to a new file with no earlier Read sees the rule only once the file exists
77 on('tool.call', { tool: ['Read', 'Edit', 'Write'] }, async ($, e, next) => {
78 const ran = await next(e)
79 if (ran.deny !== undefined || ran.isError === true) return ran
80 try {
81 const root = await $.session.root()
82 if (!e.file_path.startsWith(root + '/')) return ran
83 const relative = e.file_path.slice(root.length + 1)
84 const rules = await loadRules($, `${root}/.cursor/rules`)
85 const hits = rules.filter(
86 rule => rule.globs.length > 0 && !applied.has(rule.path) && rule.body !== '' && matchesGlobs(rule.globs, relative),
87 )
88 if (hits.length === 0) return ran
89 for (const rule of hits) applied.add(rule.path)
90 return { ...ran, context: [...(ran.context ?? []), ...hits.map(rule => render(rule, root))] }
91 } catch {
92 return ran
93 }
94 }).catch(($, e, next) => next(e))
95
96 // After /clear or a compaction Claude no longer holds the rules, so hand them over again
97 on('session.end', ($, e, next) => {
98 applied.clear()
99 return next(e)
100 })
101 on('session.compact', async ($, e, next) => {
102 const result = await next(e)
103 applied.clear()
104 return result
105 }).catch(($, e, next) => next(e))
106
107 // Every .cursor/commands/*.md becomes a slash command; the project's win over the user's
108 on('session.start', async ($, e, next) => {
109 const home = await $.env.get('HOME')
110 const dirs = [`${await $.session.root()}/.cursor/commands`, ...(home === undefined ? [] : [`${home}/.cursor/commands`])]
111 commands.clear()
112 for (const dir of dirs) {
113 let entries: Awaited<ReturnType<typeof $.fs.list>>
114 try {
115 if (!(await $.fs.exists(dir))) continue
116 entries = await $.fs.list(dir)
117 } catch {
118 // An unreadable commands directory is skipped; the other one still loads
119 continue
120 }
121 for (const entry of entries) {
122 const name = commandName(entry.name)
123 if (entry.kind !== 'file' || !entry.name.endsWith('.md') || name === undefined || commands.has(name)) continue
124 try {
125 const { body } = splitFrontmatter(await $.fs.read(`${dir}/${entry.name}`))
126 await $.command.register({ name, description: commandDescription(body) })
127 commands.set(name, `${dir}/${entry.name}`)
128 } catch {
129 // The name is taken by a built-in or native command, or the file can't be read
130 }
131 }
132 }
133 return next(e)
134 }).catch(($, e, next) => next(e))
135
136 on('command.run', async ($, e, next) => {
137 const file = commands.get(e.command)
138 if (file === undefined) return next(e)
139 const { body } = splitFrontmatter(await $.fs.read(file))
140 const args = e.args.trim()
141 const text = args === '' ? body.trim() : `${body.trim()}\n\n${args}`
142 // The host refuses a submit from inside command.run, which holds the turn; a timer's dispatch is a later one
143 $.clock.after(0, () => $.prompt.submit({ text, asUser: true }))
144 return {}
145 }).catch(($, e, next) => next(e))
146}
147hooks/lib.ts 132 lines1export type Rule = {
2 name: string
3 path: string
4 description: string
5 globs: string[]
6 isAlways: boolean
7 body: string
8}
9
10// Splits on commas outside braces, so `*.{js,ts},*.rb` is two globs
11export function splitGlobs(value: string): string[] {
12 const parts: string[] = []
13 let depth = 0
14 let current = ''
15 for (const ch of value) {
16 if (ch === '{') depth++
17 if (ch === '}') depth--
18 if (ch === ',' && depth === 0) {
19 parts.push(current)
20 current = ''
21 } else {
22 current += ch
23 }
24 }
25 parts.push(current)
26 return parts.map(unquote).filter(part => part !== '')
27}
28
29function unquote(value: string): string {
30 const trimmed = value.trim()
31 return /^(['"]).*\1$/.test(trimmed) ? trimmed.slice(1, -1).trim() : trimmed
32}
33
34export function splitFrontmatter(text: string): { meta: Record<string, string | string[]>; body: string } {
35 const source = text.replace(/\r\n/g, '\n')
36 if (!source.startsWith('---\n')) return { meta: {}, body: source }
37 const end = source.indexOf('\n---', 4)
38 if (end === -1) return { meta: {}, body: source }
39 const header = source.slice(4, end)
40 const body = source.slice(end + 4).replace(/^[^\n]*\n?/, '')
41 const meta: Record<string, string | string[]> = {}
42 let listKey: string | undefined
43 for (const line of header.split('\n')) {
44 const item = /^\s+-\s+(.*)$/.exec(line)
45 if (item && listKey) {
46 const list = meta[listKey]
47 if (Array.isArray(list)) list.push(unquote(item[1]))
48 continue
49 }
50 const pair = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(line)
51 if (!pair) continue
52 if (pair[2] === '') {
53 meta[pair[1]] = []
54 listKey = pair[1]
55 } else {
56 meta[pair[1]] = unquote(pair[2])
57 listKey = undefined
58 }
59 }
60 return { meta, body }
61}
62
63export function parseRule(path: string, name: string, text: string): Rule {
64 const { meta, body } = splitFrontmatter(text)
65 const rawGlobs = meta.globs
66 let globs: string[] = []
67 if (Array.isArray(rawGlobs)) globs = rawGlobs
68 else if (typeof rawGlobs === 'string') globs = splitGlobs(rawGlobs.replace(/^\[(.*)\]$/, '$1'))
69 const description = typeof meta.description === 'string' ? meta.description : ''
70 return {
71 name,
72 path,
73 description,
74 globs,
75 isAlways: meta.alwaysApply === 'true',
76 body: body.trim(),
77 }
78}
79
80const SPECIAL = /[.+^$()|[\]\\]/
81
82export function globToRegExp(glob: string): RegExp {
83 let source = ''
84 let depth = 0
85 for (let i = 0; i < glob.length; i++) {
86 const ch = glob[i]
87 if (ch === '*' && glob[i + 1] === '*') {
88 if (glob[i + 2] === '/') {
89 source += '(?:.*/)?'
90 i += 2
91 } else {
92 source += '.*'
93 i += 1
94 }
95 } else if (ch === '*') source += '[^/]*'
96 else if (ch === '?') source += '[^/]'
97 else if (ch === '{') {
98 depth++
99 source += '(?:'
100 } else if (ch === '}' && depth > 0) {
101 depth--
102 source += ')'
103 } else if (ch === ',' && depth > 0) source += '|'
104 else if (SPECIAL.test(ch)) source += '\\' + ch
105 else source += ch
106 }
107 // Like Cursor: a glob without a slash matches the file name at any depth
108 const prefix = glob.includes('/') ? '^' : '(?:^|/)'
109 return new RegExp(prefix + source + '$')
110}
111
112export function matchesGlobs(globs: readonly string[], relativePath: string): boolean {
113 return globs.some(glob => {
114 try {
115 return globToRegExp(glob.replace(/^\.?\//, '')).test(relativePath)
116 } catch {
117 // A malformed glob (say an unclosed `{`) matches nothing instead of failing the whole lookup
118 return false
119 }
120 })
121}
122
123export function commandDescription(body: string): string {
124 const first = body.split('\n').map(line => line.replace(/^#+\s*/, '').trim()).find(line => line !== '')
125 return first === undefined ? 'Cursor command' : first.slice(0, 100)
126}
127
128export function commandName(file: string): string | undefined {
129 const name = file.replace(/\.md$/, '')
130 return /^[A-Za-z0-9_-]{1,64}$/.test(name) ? name : undefined
131}
132