SLOPSHOPPER

cursor-rules

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

newguardcommandprompttimer
v0.1.0no licenseupdated 2026-10-08il-tmfv/ccmods/plugins/cursor-rules
A shopper browsing a rack in a slop shop
README

ccmods

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.

Install

Needs Claude Code v2.1.287 or later (claude --version).

  1. Add the marketplace (once per machine):
   claude plugin marketplace add il-tmfv/ccmods
  1. Install a mod for every project on this machine:
   claude plugin install cursor-rules@ccmods --scope user
  1. Restart Claude Code, or run /reload-plugins in an open session.
  1. Check: 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.

Update

claude plugin marketplace update ccmods
claude plugin update cursor-rules@ccmods

Try without installing

git clone git@github.com:il-tmfv/ccmods.git
claude --plugin-dir ccmods/plugins/cursor-rules

Mods

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.

CursorWhat Claude Code gets
.cursor/rules/*.mdc with alwaysApply: trueThe rule text in the first message of each conversation
.cursor/rules/*.mdc with globsThe 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 descriptionA one-line index in the first message. Claude opens the file when the task matches
.cursor/commands/*.md in the projectA /name slash command that sends the file as your prompt. Text after the name is appended
~/.cursor/commands/*.mdThe same, in every project. A project command wins over a user one with the same name

Details:

  • Globs follow Cursor: *.rb matches the file name at any depth, app/services/*.rb is anchored to the project root, ** crosses directories, {a,b} works.
  • Frontmatter is stripped. Only the rule body reaches Claude.
  • After /clear or a compaction, glob rules are handed over again on the next matching file.
  • Rules are read from .cursor/rules/ at the session root, up to three directories deep. Rules in nested .cursor/rules/ folders of a monorepo are not read.
  • New command files appear after /reload-plugins or a restart. Edits to an existing command apply immediately.
  • A command whose name is taken by a built-in or native command is skipped.

Develop

cd plugins/cursor-rules
claude plugin validate .
claude plugin test

Run against a real project without installing: claude --plugin-dir plugins/cursor-rules.

Add a mod

  1. Create plugins/<name>/ with .claude-plugin/plugin.json and hooks/hooks.json.
  2. Add an entry to .claude-plugin/marketplace.json.
  3. Run claude plugin validate . in the repo root.
Source 2 files
hooks/register.ts 147 lines
1import 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}
147
hooks/lib.ts 132 lines
1export 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