SLOPSHOPPER

harness-scope

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

newguardcommandstatuspromptagents
★ 2v0.1.3MITupdated 2026-10-08shimo4228/harness-scope/plugin
A shopper browsing a rack in a slop shop
README

harness-scope

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.

  • Is it on? In a repo that selects a profile, a status line under the prompt reads ⚠ 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.
  • What did it turn off? Run /harness-scope. It lists what is off and kept, and any profile pattern that matched nothing (a typo).
  • What names can a profile use? After one prompt, run /harness-scope names. It lists the skill, agent and tool names this conversation offered, in any repo.
  • How do I undo it? Delete .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.

Examples

  1. A writing repo that sees only its own skills. Put { "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.
  2. Keep a few global skills in a docs repo. Save ~/.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.
  3. Hide coding rules and an MCP server's tools in a research repo. Save { "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.

What each hook does

HookWhat it changes
classic.SessionStartNothing in the session. On /clear and resume it forgets the loaded profile so the next request reads it again.
session.startRegisters the /harness-scope command.
command.runAnswers /harness-scope with what the profile turned off, and /harness-scope names with the names offered. Other commands pass through untouched.
prompt.contextRemoves your own instruction files (kind user) that the profile turns off. Project, local, managed and memory files are never removed.
prompt.attachmentRemoves 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.offerStops offering agent types the profile turns off. The repo's own agents are always offered.
tool.describeMoves turned-off tools behind ToolSearch.
tool.callRefuses 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.

Data

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.

Source 5 files
hooks/register.ts 411 lines
1// 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}
411
hooks/bundled.ts 12 lines
1// 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}
12
hooks/instructions.ts 19 lines
1// 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}
19
hooks/listing.ts 109 lines
1// 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}
109
hooks/profile.ts 116 lines
1// 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