Turn global skills, rules, agents and tools on or off per repo with named profiles

The Claude Code mod itself: the folder Claude Code installs. It turns your global skills, agents, instruction files (CLAUDE.md and rules) and tools on or off per repo, using named profiles you keep in ~/.claude/harness-scope/profiles/. A repo picks one profile with a one-line .claude/harness-scope.json; the repo's own skills, agents and CLAUDE.md always stay.
You decide what is hidden. Nothing changes in a repo until you put that file there, and the file can only name one of your profiles.
⚠ harness-scope: profile "writing" on (Claude Code draws the ⚠ in front of a mod's status line; it is not a warning), and one line on screen says which file selected it. If that line is missing in such a repo, the mod did not load./harness-scope. It lists what is off and kept, and any profile pattern that matched nothing (a typo)./harness-scope names. It lists the skill, agent and tool names this conversation offered, in any repo..claude/harness-scope.json and run /clear (or start a new conversation). To remove the mod itself: claude plugin uninstall harness-scope@harness-scope. The mod writes no files, so there is nothing else to clean up.{ "profile": "writing" } in the repo's .claude/harness-scope.json. The bundled writing profile keeps the repo's own skills, the Explore and general-purpose agents, and turns off LSP, NotebookEdit, EnterWorktree and ExitWorktree.~/.claude/harness-scope/profiles/docs.json as { "skills": { "allow": ["prose-translation", "anthropic-skills:docx"] } } and select docs in the repo. Every other global, plugin and built-in skill drops out of the listing there.{ "instructions": { "deny": ["~/.claude/rules/common/testing.md"] }, "tools": { "deny": ["mcp__github__*"] } } as a profile and select it. That rule file is not loaded, and the matching tools are refused when called.| Hook | What it changes |
|---|---|
classic.SessionStart | Nothing in the session. On /clear and resume it forgets the loaded profile so the next request reads it again. |
session.start | Registers the /harness-scope command. |
command.run | Answers /harness-scope with what the profile turned off, and /harness-scope names with the names offered. Other commands pass through untouched. |
prompt.context | Removes your own instruction files (kind user) that the profile turns off. Project, local, managed and memory files are never removed. |
prompt.attachment | Removes turned-off skills from the skill listing and turned-off tools from the deferred tool list. Reminders from hooks and other plugins pass through untouched. If it cannot tell for sure where each skill's entry starts, it leaves the listing as it is and says so on screen. |
agent.offer | Stops offering agent types the profile turns off. The repo's own agents are always offered. |
tool.describe | Moves turned-off tools behind ToolSearch. |
tool.call | Refuses calls to turned-off tools, and Skill calls to skills it removed from the listing, with the reason. |
With no .claude/harness-scope.json, or with a profile it cannot read, every hook passes everything through unchanged. A profile it cannot read is reported once on screen and in the status line.
The mod reads the profile files in ~/.claude/harness-scope/profiles/, the repo's .claude/harness-scope.json, and Claude Code's session usage (to tell the repo's own skills apart, to find where each skill entry in the listing starts, and for /harness-scope names). It reads no environment variables: it finds ~/.claude from where the plugin is installed, or from the optional configDir setting. It writes nothing, sends nothing over the network, starts no processes and calls no model.
Usage, the profile format, measurements and limitations are in the README of the shimo4228/harness-scope repository on GitHub.
hooks/register.ts 411 lines1// harness-scope: turn global skills, agents, instruction files and tools on or off per repo with a named profile.
2// Plan: docs/plans/rfc-0001-r2-profile-allowlist.md. Invariants (tests/): no selector, a broken profile or an
3// unknown format means pass-through; output is a stable function of input and profile; the repo's own parts,
4// managed files and hook/plugin output are never touched; no network, processes or model calls.
5import type { EngineInterface, On, PluginOptions } from 'claude-code'
6import { BUNDLED } from './bundled'
7import { filterInstructionFiles } from './instructions'
8import { deferredToolNames, filterDeferredTools, filterSkillListing, type SkillItem } from './listing'
9import {
10 compileRule,
11 configDirFromPluginRoot,
12 expandTilde,
13 type Profile,
14 parseProfile,
15 parseSelector,
16 type Rule,
17 unmatchedPatterns,
18} from './profile'
19
20const SELECTOR = '.claude/harness-scope.json'
21
22type Active = {
23 readonly status: 'on'
24 readonly name: string
25 readonly from: string // a profile file's path, or '' for a bundled profile
26 readonly selector: string // as shown: relative to the session root when it sits there
27 readonly profile: Profile
28 readonly keepSkill: (n: string) => boolean
29 readonly keepAgent: (n: string) => boolean
30 readonly keepFile: (p: string) => boolean
31 readonly keepTool: (n: string) => boolean
32}
33type Loaded =
34 | { readonly status: 'off' }
35 | { readonly status: 'error'; readonly reason: string; readonly selector: string }
36 | Active
37
38type Receipt = {
39 skills: Set<string>
40 // Names (and aliases) the Skill tool refuses: only what this conversation removed from the listing.
41 refused: Set<string>
42 agents: Set<string>
43 files: Set<string>
44 tools: Set<string>
45 seenSkills: Set<string>
46 seenAgents: Set<string>
47 seenFiles: Set<string>
48 seenTools: Set<string>
49 keptSkills: Set<string>
50 notes: Set<string>
51 // Everything the conversation offered, profile or not, for `/harness-scope names`.
52 listings: string[]
53 offeredAgents: Set<string>
54 offeredTools: Set<string>
55}
56
57function newReceipt(): Receipt {
58 return {
59 skills: new Set(),
60 refused: new Set(),
61 agents: new Set(),
62 files: new Set(),
63 tools: new Set(),
64 seenSkills: new Set(),
65 seenAgents: new Set(),
66 seenFiles: new Set(),
67 seenTools: new Set(),
68 keptSkills: new Set(),
69 notes: new Set(),
70 listings: [],
71 offeredAgents: new Set(),
72 offeredTools: new Set(),
73 }
74}
75
76// Skill names from `session.usage`, in the listing's order, and which of them are the repo's own.
77type SkillNames = { readonly order: readonly string[]; readonly own: ReadonlySet<string> }
78
79// Per conversation: reset on /clear and resume (session.start does not fire there, and prompt.context fires before it).
80let loading: Promise<Loaded> | undefined
81let receipt = newReceipt()
82let statusShown = false
83// Claude Code's configuration directory: the userConfig field, else read off the install path. '' = unknown.
84let configuredDir = ''
85let configDir = ''
86
87function expandHome(rule: Rule | undefined): Rule | undefined {
88 if (rule === undefined) return undefined
89 return { ...rule, patterns: rule.patterns.map((p) => (configDir === '' ? p : expandTilde(p, configDir))) }
90}
91
92function activate(name: string, from: string, selector: string, profile: Profile): Active {
93 return {
94 status: 'on',
95 name,
96 from,
97 selector,
98 profile,
99 keepSkill: compileRule(profile.skills),
100 keepAgent: compileRule(profile.agents),
101 keepFile: compileRule(expandHome(profile.instructions)),
102 keepTool: compileRule(profile.tools),
103 }
104}
105
106async function readSelector($: EngineInterface): Promise<{ path: string; shown: string; text: string } | null> {
107 const root = await $.session.root()
108 const repo = await $.session.repo()
109 const dirs = repo !== null && repo.root !== root ? [root, repo.root] : [root]
110 for (const dir of dirs) {
111 const path = `${dir}/${SELECTOR}`
112 if (await $.fs.exists(path)) {
113 const text = await $.fs.read(path)
114 return typeof text === 'string' ? { path, shown: dir === root ? SELECTOR : path, text } : null
115 }
116 }
117 return null
118}
119
120async function load($: EngineInterface): Promise<Loaded> {
121 configDir = configuredDir !== '' ? configuredDir : (configDirFromPluginRoot($.plugin.root) ?? '')
122 const selector = await readSelector($)
123 if (selector === null) return { status: 'off' }
124 const fail = (reason: string): Loaded => ({ status: 'error', reason, selector: selector.shown })
125 const sel = parseSelector(selector.text)
126 if (!sel.ok) return fail(`${selector.shown}: ${sel.reason}`)
127 const own = configDir === '' ? '' : `${configDir}/harness-scope/profiles/${sel.profile}.json`
128 if (own !== '' && (await $.fs.exists(own))) {
129 const text = await $.fs.read(own)
130 const parsed = typeof text === 'string' ? parseProfile(text) : { ok: false as const, reason: 'not text' }
131 if (!parsed.ok) return fail(`${shortPath(own)}: ${parsed.reason}`)
132 return activate(sel.profile, own, selector.shown, parsed.profile)
133 }
134 const bundled = Object.hasOwn(BUNDLED, sel.profile) ? BUNDLED[sel.profile] : undefined
135 if (bundled !== undefined) return activate(sel.profile, '', selector.shown, bundled)
136 const where =
137 own === ''
138 ? 'the bundled profiles (set configDir with `claude plugin configure harness-scope` to use your own)'
139 : `${shortPath(own)} and the bundled profiles`
140 return fail(`profile "${sel.profile}" not found (looked in ${where})`)
141}
142
143function profileLabel(loaded: Active): string {
144 return loaded.from === ''
145 ? `profile "${loaded.name}" (bundled)`
146 : `profile "${loaded.name}" from ${shortPath(loaded.from)}`
147}
148
149// The pinned line under the prompt: present in every conversation of a repo that selects a profile, so its absence
150// there is the sign that the Mod did not load. Repos without a selector get none.
151function showStatus($: EngineInterface, loaded: Loaded): void {
152 if (loaded.status === 'off') {
153 // After the selector is deleted and /clear, the line from before must not stay.
154 if (statusShown) $.ui.status(undefined)
155 statusShown = false
156 return
157 }
158 statusShown = true
159 if (loaded.status === 'error') {
160 $.ui.status('passing everything through (see /harness-scope)')
161 return
162 }
163 const passed = receipt.notes.size > 0 ? ' (some lists passed through, see /harness-scope)' : ''
164 $.ui.status(`profile "${loaded.name}" on${passed}`)
165}
166
167// A list that could not be filtered: once on screen, once in the receipt, and in the status line.
168function note($: EngineInterface, loaded: Loaded, text: string): void {
169 if (receipt.notes.has(text)) return
170 receipt.notes.add(text)
171 $.ui.log(text)
172 showStatus($, loaded)
173}
174
175async function current($: EngineInterface): Promise<Loaded> {
176 if (loading === undefined) {
177 loading = load($).catch((err: unknown) => ({ status: 'error' as const, reason: String(err), selector: SELECTOR }))
178 const loaded = await loading
179 if (loaded.status === 'error') $.ui.log(`passing everything through — ${loaded.reason}`)
180 // A repo chooses which of the user's profiles applies; say so on screen every time one turns on.
181 if (loaded.status === 'on') {
182 $.ui.log(`${profileLabel(loaded)} on, selected by ${loaded.selector} — /harness-scope for details`)
183 }
184 showStatus($, loaded)
185 }
186 return loading
187}
188
189// Read again for every listing (17–35 ms measured), so a skill added mid-conversation is known when its listing comes.
190function names($: EngineInterface): Promise<SkillNames | null> {
191 return $.session
192 .usage({ breakdown: 'summary' })
193 .then((u) => {
194 const list = u.context.breakdown?.skills?.skillFrontmatter
195 if (list === undefined) return null
196 const own = new Set(list.filter((s) => s.source === 'projectSettings').map((s) => s.name))
197 return { order: list.map((s) => s.name), own }
198 })
199 .catch(() => null)
200}
201
202function shortPath(path: string): string {
203 if (configDir.endsWith('/.claude') && path.startsWith(`${configDir}/`)) {
204 return `~/.claude${path.slice(configDir.length)}`
205 }
206 return path
207}
208
209function undoLine(selector: string): string {
210 return `To turn it off: delete ${selector}, then /clear. harness-scope writes no files.`
211}
212
213// Claude Code prefixes the command's output, screen lines and the status line with the plugin name, so the text
214// carries none of its own.
215function receiptText(loaded: Loaded): string {
216 if (loaded.status === 'off') {
217 return `no ${SELECTOR} in this repo, so nothing is turned off. /harness-scope names lists names for a profile.`
218 }
219 if (loaded.status === 'error') return `passing everything through — ${loaded.reason}\n${undoLine(loaded.selector)}`
220 const r = receipt
221 const p = loaded.profile
222 const line = (label: string, rule: Rule | undefined, removed: Set<string>, seen: Set<string>, kept?: Set<string>) => {
223 if (rule === undefined) return `${label}: not in the profile`
224 // Before the first request nothing has been composed yet; "matched nothing" would be wrong then.
225 if (seen.size === 0 && removed.size === 0) return `${label} (${rule.mode}): not composed yet in this conversation`
226 const miss = unmatchedPatterns(rule, seen)
227 const off = [...removed].map(shortPath)
228 const stayed = rule.mode === 'allow' ? [...(kept ?? seen)].filter((n) => !removed.has(n)).map(shortPath) : []
229 return [
230 `${label} (${rule.mode}): ${removed.size} off${off.length ? ` — ${off.join(', ')}` : ''}`,
231 ...(stayed.length ? [`${label} kept: ${stayed.join(', ')}`] : []),
232 ...(miss.length ? [`${label} patterns that matched nothing: ${miss.join(', ')}`] : []),
233 ].join('\n')
234 }
235 return [
236 `${profileLabel(loaded)}, selected by ${loaded.selector}`,
237 line('skills', p.skills, r.skills, r.seenSkills, r.keptSkills),
238 line('agents', p.agents, r.agents, r.seenAgents),
239 line('instructions', expandHome(p.instructions), r.files, r.seenFiles),
240 line('tools', p.tools, r.tools, r.seenTools),
241 ...r.notes,
242 undoLine(loaded.selector),
243 ].join('\n')
244}
245
246async function namesText($: EngineInterface): Promise<string> {
247 const r = receipt
248 if (r.listings.length === 0 && r.offeredAgents.size === 0 && r.offeredTools.size === 0) {
249 return 'nothing composed yet in this conversation: send a prompt first, then run /harness-scope names'
250 }
251 const known = await names($)
252 const parsed = r.listings.map((l) => (known === null ? null : filterSkillListing(l, known.order, () => true)))
253 const skills = parsed.some((p) => p === null)
254 ? '(could not read the skill listing)'
255 : [...new Set(parsed.flatMap((p) => p?.items.map((i) => i.name) ?? []))].join(', ')
256 return [
257 'names offered in this conversation, as a profile matches them (globs * and ? work):',
258 `skills: ${skills}`,
259 `agents: ${[...r.offeredAgents].join(', ')}`,
260 `tools: ${[...r.offeredTools].join(', ')}`,
261 'instructions match file paths; in a pattern, ~/.claude/ stands for your configuration directory.',
262 ].join('\n')
263}
264
265function skillDeny(name: string, profile: string): string {
266 return `The skill "${name}" is turned off in this repo by the harness-scope profile "${profile}". If it is needed, ask the user to run /${name} themselves.`
267}
268
269// The skill listing with off skills removed; the input unchanged when it cannot be filtered safely.
270async function skillListing($: EngineInterface, loaded: Active, text: string): Promise<string> {
271 if (loaded.profile.skills === undefined) return text
272 const known = await names($)
273 if (known === null) {
274 note($, loaded, 'could not tell the skills apart (no session usage), so the skill listing passed through')
275 return text
276 }
277 const keep = (item: SkillItem) => {
278 const ns = [...new Set([item.name, item.alias ?? item.name, item.key])]
279 for (const n of ns) receipt.seenSkills.add(n)
280 // allow keeps an item either name matches; deny drops an item either name matches.
281 const kept =
282 known.own.has(item.key) ||
283 (loaded.profile.skills?.mode === 'deny' ? ns.every(loaded.keepSkill) : ns.some(loaded.keepSkill))
284 if (kept) receipt.keptSkills.add(item.name)
285 return kept
286 }
287 const out = filterSkillListing(text, known.order, keep)
288 if (out === null) {
289 note($, loaded, 'the skill listing had an unexpected format, so no skill was turned off')
290 // The model now sees every skill, so refusing one would contradict the listing.
291 receipt.refused.clear()
292 return text
293 }
294 for (const item of out.removedItems) {
295 receipt.skills.add(item.name)
296 for (const n of [item.name, item.alias ?? item.name, item.key]) receipt.refused.add(n)
297 }
298 return out.text
299}
300
301function deferredTools(loaded: Active, text: string): string {
302 if (loaded.profile.tools === undefined) return text
303 const out = filterDeferredTools(text, loaded.keepTool)
304 for (const n of out.removed) receipt.tools.add(n)
305 return out.text
306}
307
308// What `/harness-scope names` lists, kept whether or not a profile is on.
309function remember(type: string, agentId: string | undefined, text: string): void {
310 if (type === 'skill_listing' && agentId === undefined) receipt.listings.push(text)
311 if (type === 'deferred_tools_delta') for (const n of deferredToolNames(text)) receipt.offeredTools.add(n)
312}
313
314export function register(on: On, options: PluginOptions): void {
315 const configured = options.configDir
316 configuredDir = typeof configured === 'string' ? configured.replace(/\/+$/, '') : ''
317 on('classic.SessionStart', async (_$, e, next) => {
318 if (e.source === 'clear' || e.source === 'resume') {
319 loading = undefined
320 receipt = newReceipt()
321 }
322 return next(e)
323 })
324
325 on('session.start', async ($, e, next) => {
326 await $.command.register({
327 name: 'harness-scope',
328 description: 'Show what the harness-scope profile turned off in this repo',
329 argumentHint: '[names]',
330 })
331 return next(e)
332 })
333
334 on('command.run', async ($, e, next) => {
335 if (e.command !== 'harness-scope') return next(e)
336 const arg = e.args.trim().toLowerCase()
337 const text =
338 arg === ''
339 ? receiptText(await current($))
340 : arg === 'names'
341 ? await namesText($)
342 : `unknown argument "${e.args.trim()}": use /harness-scope, or /harness-scope names`
343 // The command's text output reaches the model; on a screen, show the names there instead.
344 if ((await $.session.surfaces()).length === 0) return { text }
345 // A screen line is one row (a newline draws as a stray glyph), so the receipt goes out line by line.
346 for (const line of text.split('\n')) $.ui.log(line)
347 return {}
348 })
349
350 on('prompt.context', async ($, e, next) => {
351 const r = await next(e)
352 const loaded = await current($)
353 if (loaded.status !== 'on' || loaded.profile.instructions === undefined) return r
354 if (r.instructionFiles === undefined) {
355 note($, loaded, 'instruction files were rewritten upstream, so none were turned off')
356 return r
357 }
358 for (const f of r.instructionFiles) if (f.kind === 'user') receipt.seenFiles.add(f.path)
359 const out = filterInstructionFiles(r.instructionFiles, loaded.keepFile)
360 if (out.removed.length === 0) return r
361 for (const p of out.removed) receipt.files.add(p)
362 return { ...r, instructionFiles: out.files }
363 })
364
365 on('prompt.attachment', async ($, e, next) => {
366 const r = await next(e)
367 if (e.origin.kind !== 'engine' || r.text === null) return r
368 remember(e.type, e.agentId, r.text)
369 const loaded = await current($)
370 if (loaded.status !== 'on') return r
371 if (e.type === 'skill_listing') return { ...r, text: await skillListing($, loaded, r.text) }
372 if (e.type === 'deferred_tools_delta') return { ...r, text: deferredTools(loaded, r.text) }
373 return r
374 })
375
376 on('agent.offer', async ($, e, next) => {
377 receipt.offeredAgents.add(e.agent)
378 const loaded = await current($)
379 if (loaded.status !== 'on' || loaded.profile.agents === undefined || e.source === 'projectSettings') return next(e)
380 receipt.seenAgents.add(e.agent)
381 if (loaded.keepAgent(e.agent)) return next(e)
382 receipt.agents.add(e.agent)
383 return { isOffered: false }
384 })
385
386 on('tool.describe', async ($, e, next) => {
387 receipt.offeredTools.add(e.tool)
388 const r = await next(e)
389 const loaded = await current($)
390 if (loaded.status !== 'on' || loaded.profile.tools === undefined) return r
391 receipt.seenTools.add(e.tool)
392 if (loaded.keepTool(e.tool)) return r
393 receipt.tools.add(e.tool)
394 return { ...r, isDeferred: true }
395 })
396
397 on('tool.call', async ($, e, next) => {
398 const loaded = await current($)
399 if (loaded.status !== 'on') return next(e)
400 if (loaded.profile.tools !== undefined && !loaded.keepTool(e.tool)) {
401 return { deny: `The tool ${e.tool} is turned off in this repo by the harness-scope profile "${loaded.name}".` }
402 }
403 if (e.tool === 'Skill') {
404 const name = String(e.skill ?? '').replace(/^\//, '')
405 // Only names this conversation actually removed from the listing, so the listing and the refusals agree.
406 if (receipt.refused.has(name)) return { deny: skillDeny(name, loaded.name) }
407 }
408 return next(e)
409 })
410}
411hooks/bundled.ts 12 lines1// Profiles shipped with the mod. A profile of the same name in ~/.claude/harness-scope/profiles/ takes precedence.
2import type { Profile } from './profile'
3
4export const BUNDLED: Readonly<Record<string, Profile>> = {
5 // Writing repos: only the repo's own skills, two general agents, no code-only tools.
6 writing: {
7 skills: { mode: 'allow', patterns: [] },
8 agents: { mode: 'allow', patterns: ['Explore', 'general-purpose'] },
9 tools: { mode: 'deny', patterns: ['LSP', 'NotebookEdit', 'EnterWorktree', 'ExitWorktree'] },
10 },
11}
12hooks/instructions.ts 19 lines1// Instruction-file filter for prompt.context. Pure.
2// Only the user's own files (kind "user") can be turned off; project, local, managed and memory files always stay.
3
4export type InstructionFileLike = { readonly path: string; readonly kind: string; readonly parent?: string }
5
6export function filterInstructionFiles<F extends InstructionFileLike>(
7 files: readonly F[],
8 keepPath: (path: string) => boolean,
9): { readonly files: readonly F[]; readonly removed: readonly string[] } {
10 const dropped = new Set<string>()
11 // Files arrive with each import after the file that imported it, so one pass carries a drop down the chain.
12 for (const f of files) {
13 if (f.kind !== 'user') continue
14 if (!keepPath(f.path) || (f.parent !== undefined && dropped.has(f.parent))) dropped.add(f.path)
15 }
16 if (dropped.size === 0) return { files, removed: [] }
17 return { files: files.filter((f) => !dropped.has(f.path)), removed: [...dropped] }
18}
19hooks/listing.ts 109 lines1// Filters for the text listings Claude Code injects (formats measured on 2.1.287 and 2.1.294; see docs/measurements/).
2// Pure. Kept items stay byte for byte, so the output is a stable function of the input (prompt cache).
3
4const SKILL_HEADER = 'The following skills are available for use with the Skill tool:'
5// "- name", "- name: description", "- name (alias): description". Names and aliases hold no whitespace.
6const ITEM_LINE = /^- (\S+)(?: \((\S+)\))?(?:: |$)/
7
8export type Filtered = { readonly text: string; readonly removed: readonly string[] }
9
10/** One skill as the listing writes it: `key` is the name `session.usage` gives it. */
11export type SkillItem = { readonly name: string; readonly alias: string | undefined; readonly key: string }
12
13export type SkillFiltered = Filtered & {
14 readonly items: readonly SkillItem[]
15 readonly removedItems: readonly SkillItem[]
16}
17
18// The usage name a listing line stands for: its name, its alias, or the name past a plugin prefix (synced skills
19// are `docx` in usage and `anthropic-skills:docx` in the listing).
20function resolve(known: ReadonlySet<string>, name: string, alias: string | undefined): string | undefined {
21 if (known.has(name)) return name
22 if (alias !== undefined && known.has(alias)) return alias
23 const colon = name.indexOf(':')
24 if (colon !== -1 && known.has(name.slice(colon + 1))) return name.slice(colon + 1)
25 return undefined
26}
27
28type Candidate = { readonly line: number; readonly item: SkillItem }
29
30// Line numbers that start an item, aligned with `order` (the listing keeps usage's order). A description line that
31// looks like an item ("- other-skill: when …") is a second candidate for that key; the alignment decides which line
32// is the item. null when the order is broken or two alignments fit (the caller passes the listing through).
33function align(order: readonly string[], candidates: readonly Candidate[]): Candidate[] | null {
34 const byKey = new Map<string, Candidate[]>()
35 for (const c of candidates) byKey.set(c.item.key, [...(byKey.get(c.item.key) ?? []), c])
36 const keys = order.filter((k) => byKey.has(k)) // a skill the listing left out (budget) has no line to align
37 const first: Candidate[] = []
38 for (const k of keys) {
39 const after = first.at(-1)?.line ?? -1
40 const c = byKey.get(k)?.find((x) => x.line > after)
41 if (c === undefined) return null
42 first.push(c)
43 }
44 const last: Candidate[] = []
45 for (const k of [...keys].reverse()) {
46 const before = last.at(-1)?.line ?? Number.POSITIVE_INFINITY
47 const c = byKey.get(k)?.findLast((x) => x.line < before)
48 if (c === undefined) return null
49 last.push(c)
50 }
51 last.reverse()
52 return first.every((c, i) => c.line === last[i]?.line) ? first : null
53}
54
55/**
56 * Removes skill items that `keep` rejects. `order` is every skill name `session.usage` reports, in its order.
57 * Returns null when the text is not the listing format this build writes, or when which lines start items is
58 * ambiguous, so the caller passes it through instead of guessing.
59 */
60export function filterSkillListing(
61 text: string,
62 order: readonly string[],
63 keep: (item: SkillItem) => boolean,
64): SkillFiltered | null {
65 if (!text.startsWith(SKILL_HEADER)) return null
66 const known = new Set(order)
67 const lines = text.split('\n')
68 const candidates: Candidate[] = []
69 lines.forEach((line, i) => {
70 const m = i === 0 ? null : ITEM_LINE.exec(line)
71 const name = m?.[1]
72 if (name === undefined) return
73 const key = resolve(known, name, m?.[2])
74 if (key !== undefined) candidates.push({ line: i, item: { name, alias: m?.[2], key } })
75 })
76 const starts = align(order, candidates)
77 if (starts === null || starts.length === 0) return null
78 const out = lines.slice(0, starts[0]?.line)
79 const removedItems: SkillItem[] = []
80 starts.forEach((s, i) => {
81 const body = lines.slice(s.line, starts[i + 1]?.line ?? lines.length)
82 if (keep(s.item)) out.push(...body)
83 else removedItems.push(s.item)
84 })
85 return {
86 text: out.join('\n'),
87 removed: removedItems.map((r) => r.name),
88 removedItems,
89 items: starts.map((s) => s.item),
90 }
91}
92
93/** Names of the tools in a deferred-tools list (one per line, no spaces). */
94export function deferredToolNames(text: string): string[] {
95 return text.split('\n').filter((line) => line.length > 0 && !/\s/.test(line))
96}
97
98/** Removes tool names (one per line, no spaces) that `keep` rejects; headers and blank lines stay. */
99export function filterDeferredTools(text: string, keep: (name: string) => boolean): Filtered {
100 const removed: string[] = []
101 const lines = text.split('\n').filter((line) => {
102 const isName = line.length > 0 && !/\s/.test(line)
103 if (!isName || keep(line)) return true
104 removed.push(line)
105 return false
106 })
107 return { text: lines.join('\n'), removed }
108}
109hooks/profile.ts 116 lines1// Profile and selector parsing. Pure: no `$` (the engine interface cannot be passed into imported functions).
2// A repo's selector names a profile and nothing else, so a cloned repo cannot turn off the user's own rules.
3
4export type Rule = { readonly mode: 'allow' | 'deny'; readonly patterns: readonly string[] }
5
6export type Profile = {
7 readonly skills?: Rule
8 readonly agents?: Rule
9 readonly instructions?: Rule
10 readonly tools?: Rule
11}
12
13export type Parsed<T> = ({ readonly ok: true } & T) | { readonly ok: false; readonly reason: string }
14
15const CATEGORIES = ['skills', 'agents', 'instructions', 'tools'] as const
16const PROFILE_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/
17
18function parseJson(text: string): unknown {
19 try {
20 return JSON.parse(text)
21 } catch {
22 return undefined
23 }
24}
25
26function isRecord(v: unknown): v is Record<string, unknown> {
27 return typeof v === 'object' && v !== null && !Array.isArray(v)
28}
29
30export function parseSelector(text: string): Parsed<{ readonly profile: string }> {
31 const v = parseJson(text)
32 if (!isRecord(v)) return { ok: false, reason: 'the selector is not a JSON object' }
33 const keys = Object.keys(v)
34 if (keys.length !== 1 || keys[0] !== 'profile') return { ok: false, reason: 'the selector may only hold "profile"' }
35 const name = v.profile
36 if (typeof name !== 'string' || !PROFILE_NAME.test(name)) {
37 return { ok: false, reason: '"profile" must be a name of letters, digits, "_" and "-"' }
38 }
39 return { ok: true, profile: name }
40}
41
42function parseRule(category: string, v: unknown): Parsed<{ readonly rule: Rule }> {
43 if (!isRecord(v)) return { ok: false, reason: `"${category}" must be an object` }
44 const keys = Object.keys(v)
45 if (keys.length !== 1 || (keys[0] !== 'allow' && keys[0] !== 'deny')) {
46 return { ok: false, reason: `"${category}" takes exactly one of "allow" or "deny"` }
47 }
48 const mode = keys[0]
49 const patterns = v[mode]
50 if (!Array.isArray(patterns) || !patterns.every((p): p is string => typeof p === 'string')) {
51 return { ok: false, reason: `"${category}.${mode}" must be a list of strings` }
52 }
53 return { ok: true, rule: { mode, patterns } }
54}
55
56export function parseProfile(text: string): Parsed<{ readonly profile: Profile }> {
57 const v = parseJson(text)
58 if (!isRecord(v)) return { ok: false, reason: 'the profile is not a JSON object' }
59 const profile: Record<string, Rule> = {}
60 for (const key of Object.keys(v)) {
61 if (!(CATEGORIES as readonly string[]).includes(key)) {
62 return { ok: false, reason: `unknown key "${key}" (use ${CATEGORIES.join(', ')})` }
63 }
64 const r = parseRule(key, v[key])
65 if (!r.ok) return r
66 profile[key] = r.rule
67 }
68 return { ok: true, profile }
69}
70
71function globToRegExp(glob: string): RegExp {
72 const body = glob
73 .replace(/[.+^${}()|[\]\\]/g, '\\$&')
74 .replace(/\*/g, '.*')
75 .replace(/\?/g, '.')
76 return new RegExp(`^${body}$`)
77}
78
79/** Returns whether a name is kept under the rule. No rule keeps everything. */
80export function compileRule(rule: Rule | undefined): (name: string) => boolean {
81 if (rule === undefined) return () => true
82 const regs = rule.patterns.map(globToRegExp)
83 const matches = (name: string) => regs.some((r) => r.test(name))
84 return rule.mode === 'allow' ? matches : (name) => !matches(name)
85}
86
87/** Patterns of a rule that matched none of the names seen (typos show up in the receipt). */
88export function unmatchedPatterns(rule: Rule | undefined, seen: Iterable<string>): string[] {
89 if (rule === undefined) return []
90 const names = [...seen]
91 return rule.patterns.filter((p) => !names.some((n) => globToRegExp(p).test(n)))
92}
93
94const INSTALL_ANCHORS = ['/plugins/cache/', '/plugins/marketplaces/'] as const
95
96/**
97 * Claude Code's configuration directory (`~/.claude` by default), read off where the plugin is installed:
98 * an installed copy sits under `<config dir>/plugins/`. null for a checkout loaded with --plugin-dir.
99 * The mod reads no environment variable, so this is how it finds the user's profiles.
100 */
101export function configDirFromPluginRoot(root: string): string | null {
102 for (const anchor of INSTALL_ANCHORS) {
103 const at = root.lastIndexOf(anchor)
104 if (at > 0) return root.slice(0, at)
105 }
106 return null
107}
108
109/** Expands a leading `~/`: `~/.claude/` is the config dir; other `~/` paths use the home above a `.claude` config dir. */
110export function expandTilde(pattern: string, configDir: string): string {
111 if (!pattern.startsWith('~/')) return pattern
112 if (pattern.startsWith('~/.claude/')) return `${configDir}/${pattern.slice('~/.claude/'.length)}`
113 if (configDir.endsWith('/.claude')) return `${configDir.slice(0, -'/.claude'.length)}${pattern.slice(1)}`
114 return pattern
115}
116