Load the rules/ folders that installed Claude Code plugins ship (Claude, Cursor .mdc and Antigravity formats), with path scoping.

A Claude Code mod that loads the rules/ folder of every installed plugin. Claude Code has no rules plugin component today (claude-code#14200, 120+ reactions), so rules that plugin authors ship for Cursor or Antigravity, or that a team ships as a shared coding standard, never reach Claude. This mod closes that gap.
/plugin marketplace add rafal-one/plugin-rules
/plugin install plugin-rules@plugin-rules
Needs a Claude Code build with mods (function hooks). Run /plugin-rules to see what loaded and why.
For each enabled plugin it reads <plugin>/rules/**/*.{md,mdc} and activates every rule by its frontmatter:
| Frontmatter | Meaning | What this mod does |
|---|---|---|
none, or alwaysApply: true, or trigger: always_on | always on | added to Claude's instruction files |
paths: (Claude), globs: (Cursor), trigger: glob + globs: (Antigravity) | path scoped | appended to the result of the first Read/Edit/Write of a matching file, once per session |
description: only, trigger: model_decision | model decides | listed with its path and description, so Claude can Read it |
trigger: manual | manual | listed like the above |
foo.md and foo.mdc twins (as in flutter/agent-plugins) are collapsed into one rule. Globs support *, **, ? and {a,b}; a glob without / matches the file name anywhere.
Set in the plugin config menu or pluginConfigs in settings:
extraDirs: comma-separated directories with rule files, for example a team standards checkout.disabledPlugins: comma-separated plugin names whose rules must not load.A plugin disabled in enabledPlugins is skipped automatically.
~/.claude/plugins/installed_plugins.json. Rule files over 20,000 characters are truncated.prompt.context hooks do not run in plugins a person installs where an organization manages Claude Code; the path-scoped rules still do.claude plugin validate .
claude plugin test . # 10 unit tests (frontmatter, dialects, globs)
claude -p --plugin-dir . --settings s.json "..." # live run, see below
Live check (2026-10-19): with extraDirs pointing at a folder holding an alwaysApply rule and a globs: src/**/*.py rule, Claude answered the codeword from the always rule and received the path rule when it read src/m.py.
If it saves you time, you can support development: PayPal.
MIT licensed.
hooks/register.ts 140 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import {
4 RULE_FILE,
5 availableIndex,
6 classify,
7 dedupe,
8 formatRule,
9 matchesGlob,
10 relativeTo,
11 splitList,
12 type Rule,
13} from './rules'
14
15type Installed = { plugins?: Record<string, Array<{ installPath?: string }> | { installPath?: string }> }
16
17const SCOPED_TOOLS = ['Read', 'Edit', 'Write'] as const
18const MAX_RULE_CHARS = 20000
19
20type Opts = { extraDirs: string[]; skip: Set<string> }
21
22async function readRules(
23 $: EngineInterface,
24 plugin: string,
25 dir: string,
26 out: Rule[],
27): Promise<void> {
28 const entries = await $.fs.list(dir).catch(() => [])
29 for (const ent of entries) {
30 const full = `${dir.replace(/\/+$/, '')}/${ent.name}`
31 if (ent.kind === 'dir') await readRules($, plugin, full, out)
32 else if (ent.kind === 'file' && RULE_FILE.test(ent.name)) {
33 const text = await $.fs.read(full).catch(() => undefined)
34 if (text !== undefined) out.push(classify(plugin, full, text.slice(0, MAX_RULE_CHARS)))
35 }
36 }
37}
38
39async function scan($: EngineInterface, opts: Opts): Promise<Rule[]> {
40 const home = await $.env.get('HOME')
41 const out: Rule[] = []
42 const settings = await $.settings.read().catch(() => ({}) as Record<string, unknown>)
43 const enabled = (settings as { enabledPlugins?: Record<string, boolean> }).enabledPlugins ?? {}
44 const raw = await $.fs
45 .read(`${home ?? ''}/.claude/plugins/installed_plugins.json`)
46 .catch(() => '{}')
47 let installed: Installed = {}
48 try {
49 installed = JSON.parse(raw) as Installed
50 } catch {
51 installed = {}
52 }
53 for (const [key, value] of Object.entries(installed.plugins ?? {})) {
54 const name = key.split('@')[0]
55 if (enabled[key] === false || opts.skip.has(name)) continue
56 const list = Array.isArray(value) ? value : [value]
57 for (const item of list) {
58 if (item?.installPath) await readRules($, name, `${item.installPath}/rules`, out)
59 }
60 }
61 const cwd = await $.session.cwd()
62 for (const dir of opts.extraDirs) {
63 const abs = dir.startsWith('/') ? dir : `${cwd}/${dir}`
64 await readRules($, 'extra', abs, out)
65 }
66 return dedupe(out).filter(r => r.body !== '' || r.mode === 'model')
67}
68
69export const register: Register = (on, options) => {
70 const extraDirs = splitList(options?.extraDirs)
71 const skip = new Set(splitList(options?.disabledPlugins))
72 const opts: Opts = { extraDirs, skip }
73 const delivered = new Set<string>()
74 let cache: Rule[] | undefined
75
76 on('session.start', async ($, e, next) => {
77 delivered.clear()
78 cache = undefined
79 await $.command.register({
80 name: 'plugin-rules',
81 description: 'List the plugin rules that are loaded and why',
82 })
83 return next(e)
84 })
85
86 on('prompt.context', async ($, e, next) => {
87 cache = await scan($, opts)
88 const rules = cache
89 const always = rules.filter(r => r.mode === 'always')
90 const listed = rules.filter(r => r.mode === 'model' || r.mode === 'manual')
91 const files = always.map(r => ({
92 path: r.path,
93 kind: 'user' as const,
94 content: r.body,
95 }))
96 const index = availableIndex(listed)
97 if (index !== '') files.push({ path: 'plugin-rules:index', kind: 'user', content: index })
98 if (files.length === 0) return next(e)
99 if (e.instructionFiles !== undefined) {
100 return next({ ...e, instructionFiles: [...e.instructionFiles, ...files] })
101 }
102 const text = files.map(f => f.content).join('\n\n')
103 return next({ ...e, blocks: [...e.blocks, { name: 'pluginRules', text }] })
104 })
105
106 for (const tool of SCOPED_TOOLS) {
107 on('tool.call', { tool }, async ($, e, next) => {
108 const ran = await next(e)
109 if (ran.deny !== undefined) return ran
110 const file = (e as { file_path?: string }).file_path
111 if (!file) return ran
112 cache = cache ?? (await scan($, opts))
113 const rules = cache
114 const cwd = await $.session.cwd()
115 const rel = relativeTo(cwd, file)
116 const hits = rules.filter(
117 r => r.mode === 'glob' && !delivered.has(r.path) && matchesGlob(r.globs, rel),
118 )
119 if (hits.length === 0) return ran
120 for (const r of hits) delivered.add(r.path)
121 return { ...ran, context: [...(ran.context ?? []), ...hits.map(formatRule)] }
122 }).catch(($, e, next) => next(e))
123 }
124
125 on('command.run', { command: 'plugin-rules' }, async $ => {
126 const rules = await scan($, opts)
127 if (rules.length === 0) return { text: 'No plugin ships a rules/ folder (and no extra directories are set).' }
128 const lines = rules.map(r => {
129 const why =
130 r.mode === 'glob'
131 ? `when a file matching ${r.globs.join(', ')} is read or edited${delivered.has(r.path) ? ' (delivered)' : ''}`
132 : r.mode === 'always'
133 ? 'always, in the instructions'
134 : 'listed; Claude may Read it'
135 return `${r.plugin}/${r.name}: ${why}`
136 })
137 return { text: lines.join('\n') }
138 })
139}
140hooks/rules.ts 178 lines1// Pure helpers: frontmatter, rule classification, glob matching. No engine access.
2
3export type Mode = 'always' | 'glob' | 'model' | 'manual'
4
5export type Rule = {
6 plugin: string
7 path: string
8 name: string
9 mode: Mode
10 globs: string[]
11 description: string
12 body: string
13}
14
15type Value = string | boolean | string[]
16
17const unquote = (s: string): string => {
18 const t = s.trim()
19 return t.length >= 2 && (t[0] === '"' || t[0] === "'") && t[t.length - 1] === t[0]
20 ? t.slice(1, -1)
21 : t
22}
23
24const scalar = (raw: string): Value => {
25 const t = raw.trim()
26 if (t === 'true') return true
27 if (t === 'false') return false
28 if (t.startsWith('[') && t.endsWith(']')) {
29 return t
30 .slice(1, -1)
31 .split(',')
32 .map(unquote)
33 .filter(s => s !== '')
34 }
35 return unquote(t)
36}
37
38/** Splits `---` frontmatter off a file. Unknown YAML shapes are ignored, not fatal. */
39export function parseFrontmatter(text: string): { data: Record<string, Value>; body: string } {
40 const src = text.replace(/^/, '').replace(/\r\n/g, '\n')
41 const m = /^---[ \t]*\n([\s\S]*?)\n---[ \t]*(?:\n|$)/.exec(src)
42 if (!m) return { data: {}, body: src }
43 const data: Record<string, Value> = {}
44 let listKey: string | undefined
45 for (const line of m[1].split('\n')) {
46 if (/^\s*#/.test(line) || line.trim() === '') continue
47 const item = /^\s+-\s+(.*)$/.exec(line)
48 if (item && listKey !== undefined) {
49 const cur = data[listKey]
50 data[listKey] = [...(Array.isArray(cur) ? cur : []), unquote(item[1])]
51 continue
52 }
53 const kv = /^([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(line)
54 if (!kv) continue
55 if (kv[2].trim() === '') {
56 listKey = kv[1]
57 data[kv[1]] = []
58 } else {
59 listKey = undefined
60 data[kv[1]] = scalar(kv[2])
61 }
62 }
63 return { data, body: src.slice(m[0].length) }
64}
65
66const asList = (v: Value | undefined): string[] => {
67 if (v === undefined || typeof v === 'boolean') return []
68 const items = Array.isArray(v) ? v : v.split(',')
69 return items.map(s => unquote(s)).filter(s => s !== '')
70}
71
72const asText = (v: Value | undefined): string => (typeof v === 'string' ? v : '')
73
74/**
75 * Maps the three frontmatter dialects to one activation mode.
76 * Claude: `paths:`. Cursor: `alwaysApply`, `globs`, `description`.
77 * Antigravity: `trigger: always_on | glob | model_decision | manual`.
78 */
79export function classify(
80 plugin: string,
81 path: string,
82 text: string,
83): Rule {
84 const { data, body } = parseFrontmatter(text)
85 const file = path.split('/').pop() ?? path
86 const name = file.replace(/\.(mdc|md|markdown)$/i, '')
87 const globs = [...asList(data.paths), ...asList(data.globs)]
88 const description = asText(data.description).trim()
89 const trigger = asText(data.trigger).toLowerCase()
90 let mode: Mode
91 if (trigger === 'always_on' || trigger === 'always') mode = 'always'
92 else if (trigger === 'glob') mode = globs.length > 0 ? 'glob' : 'manual'
93 else if (trigger === 'model_decision') mode = 'model'
94 else if (trigger === 'manual') mode = 'manual'
95 else if (data.alwaysApply === true) mode = 'always'
96 else if (globs.length > 0) mode = 'glob'
97 else if (description !== '' && 'alwaysApply' in data) mode = 'model'
98 else if (Object.keys(data).length === 0 || 'paths' in data) mode = 'always'
99 else mode = description !== '' ? 'model' : 'always'
100 return { plugin, path, name, mode, globs, description, body: body.trim() }
101}
102
103/** .md and .mdc twins (same basename) collapse to the one that carries activation metadata. */
104export function dedupe(rules: Rule[]): Rule[] {
105 const seen = new Map<string, Rule>()
106 const score = (r: Rule): number =>
107 (r.globs.length > 0 || r.description !== '' ? 2 : 0) + (r.path.endsWith('.md') ? 1 : 0)
108 for (const r of rules) {
109 const key = `${r.plugin}\u0000${r.name}`
110 const cur = seen.get(key)
111 if (!cur || score(r) > score(cur)) seen.set(key, r)
112 }
113 return [...seen.values()]
114}
115
116const escapeRe = (c: string): string => c.replace(/[.+^$()|[\]\\]/g, '\\$&')
117
118export function globToRegExp(glob: string): RegExp {
119 let re = ''
120 let depth = 0
121 for (let i = 0; i < glob.length; i++) {
122 const c = glob[i]
123 if (c === '*') {
124 if (glob[i + 1] === '*') {
125 i++
126 if (glob[i + 1] === '/') {
127 i++
128 re += '(?:.*/)?'
129 } else re += '.*'
130 } else re += '[^/]*'
131 } else if (c === '?') re += '[^/]'
132 else if (c === '{') {
133 depth++
134 re += '(?:'
135 } else if (c === '}' && depth > 0) {
136 depth--
137 re += ')'
138 } else if (c === ',' && depth > 0) re += '|'
139 else re += escapeRe(c)
140 }
141 return new RegExp(`^${re}$`)
142}
143
144/** Matches a project-relative path. A pattern without `/` matches the basename anywhere. */
145export function matchesGlob(globs: string[], relPath: string): boolean {
146 const path = relPath.replace(/\\/g, '/').replace(/^\.\//, '')
147 const base = path.split('/').pop() ?? path
148 return globs.some(g => {
149 const pat = g.trim().replace(/^\.\//, '')
150 if (pat === '') return false
151 const re = globToRegExp(pat)
152 return re.test(path) || (!pat.includes('/') && re.test(base))
153 })
154}
155
156export function relativeTo(cwd: string, file: string): string {
157 const root = cwd.replace(/\/+$/, '')
158 return file.startsWith(root + '/') ? file.slice(root.length + 1) : file
159}
160
161export function splitList(s: unknown): string[] {
162 return typeof s === 'string' ? s.split(',').map(x => x.trim()).filter(x => x !== '') : []
163}
164
165export const RULE_FILE = /\.(md|mdc|markdown)$/i
166
167export function formatRule(r: Rule): string {
168 return `Rule "${r.name}" from plugin ${r.plugin} (${r.path}):\n\n${r.body}`
169}
170
171export function availableIndex(rules: Rule[]): string {
172 if (rules.length === 0) return ''
173 const lines = rules.map(
174 r => `- ${r.path}: ${r.description !== '' ? r.description : r.name}`,
175 )
176 return `Plugin rules you may Read when relevant:\n${lines.join('\n')}`
177}
178