SuperAPI AI Directory

optimize-claude-config

Agents & models

View repository

Updated 10-16 01:28

About this skill

optimize-claude-config

Optimize the .claude/ config of a repo so rules load only when relevant and CLAUDE.md stays small. Default mode audits, reports, asks to confirm, then applies. --audit-only stops after the report.

Why this matters (canonical facts)

Verified empirically in Claude Code 2.1.145:

  • paths: as a YAML array lazy-loads a rule (it enters context only when a matching file is read) AND passes the IDE extension's schema validation. This is the target format.
  • CSV single-line paths: a, b lazy-loads at runtime but the IDE flags it red ("paths must be an array of glob patterns"). Migrate to the array form. YAML-array support landed in CC 2.1.84.
  • globs: "..." (Cursor's field) loads eager at session start, ignoring scope. This is the main thing to fix — convert to paths:.
  • alwaysApply: is a Cursor field, ignored by Claude Code — harmless noise. Leave it; do not churn files just to remove it.
  • A rule with no paths: and no globs: is effectively always-loaded. Flag it: either it genuinely belongs in CLAUDE.md, or it needs a paths: scope.
  • Read-not-create caveat (bug #23478): path-scoped rules trigger on read, not create. Creating a brand-new file that matches a rule, without first reading a neighbor, does NOT load that rule. The hook in area 3 backstops this.
  • CLAUDE.md should be a lightweight, always-loaded index: project overview + a rule map (pointers into .claude/rules/*) + a short "Critical Rules" digest — NOT full rule bodies inlined. Per-directory CLAUDE.md files that are pure @import shells are redundant once rules self-load via paths:.

Step 0 — Parse arguments

  • --audit-only present → report only, never edit or write.
  • A trailing path arg → treat it as the repo root to scan; otherwise use the current working directory. Resolve .claude/ under that root.
  • If <root>/.claude does not exist, stop and tell the user there is nothing to optimize here.

Step 1 — Discover

Collect, under <root>:

find .claude/rules -type f -name '*.md' 2>/dev/null
find . -name 'CLAUDE.md' -not -path '*/node_modules/*' 2>/dev/null
ls .claude/settings.json .claude/settings.local.json 2>/dev/null
ls .claude/hooks/inject-rules.py 2>/dev/null

Read each rule file's frontmatter, each CLAUDE.md, and the settings file(s).

Step 1b — Analyze project structure (derive paths from reality)

Do NOT guess path globs or trust existing ones blindly. Build a factual map of the repo first, then derive/validate every paths: against it.

Enumerate the real tree (tracked files only, so build output and deps are excluded):

git ls-files 2>/dev/null || find . -type f -not -path '*/.git/*' -not -path '*/node_modules/*' -not -path '*/build/*'

From that file list, compute:

  1. Top-level domains — the first path segment of each file (e.g. apps/, functions/, packages/). These are the coarse areas rules will scope to.
  2. Monorepo package roots — directories containing a manifest (pubspec.yaml, package.json, Cargo.toml, go.mod, pyproject.toml). Nested packages (e.g. apps/mobile/packages/*/) change the right glob depth: prefer apps/mobile/packages/*/lib/**/*.dart over a flat **/*.dart.
  3. Extensions per domain — which file types live where (e.g. .dart under apps/, .ts under functions/). This tells you the file-type tail of each glob (**/*.dart vs **/*.ts).
  4. Generated / vendored / frozen areas — directories a rule should NOT match (*.g.dart, *.freezed.dart, build/, node_modules/, lib/ build output, and anything the repo's CLAUDE.md marks frozen/deprecated). Exclude these from proposed globs.

Use a quick aggregation to see the shape, e.g.:

# extensions by top-level domain
git ls-files | awk -F/ '{d=$1; n=split($NF,a,"."); print d"  *."a[n]}' | sort | uniq -c | sort -rn | head -40
# package roots
git ls-files | grep -E '/(pubspec\.yaml|package\.json|Cargo\.toml|go\.mod|pyproject\.toml)$'

Hold this map; Step 2 uses it to validate and derive globs.

Step 2 — Audit rules frontmatter

For every .claude/rules/**/*.md, classify its frontmatter:

Finding Condition Proposed action
✅ OK paths: is a YAML array (one - pattern per line) none
🔴 eager globs: has globs: (any form) rename key to paths:, convert value to a YAML array (one item per line)
🟡 CSV paths: paths: value is inline / comma-separated on one line reformat to a YAML array
🟡 unscoped no paths: and no globs: derive a candidate scope from Step 1b and the rule's own domain, then propose it for the user to confirm. Do NOT auto-apply a scope.
🟠 stale glob a paths:/globs: pattern matches zero tracked files flag it — the directory was renamed/removed, or the depth/extension is wrong. Propose the corrected pattern from the real tree.
🟠 over-broad a pattern matches generated/vendored/frozen files (Step 1b list) tighten it (add the right package depth, or narrow the extension) so the rule only loads for hand-written source.

Validate every pattern against the real tree. For each paths:/globs: entry, check it actually matches files (reuse the glob→regex logic from the hook, or git ls-files | grep with the glob translated). A pattern matching zero files is the most common silent failure — the rule looks scoped but never loads.

Deriving a glob (for unscoped or stale rules). Use the Step 1b map:

  • Identify the rule's domain from its description:/body (e.g. "Flutter client", "Cloud Functions", "TypeScript") and map it to the real top-level domain(s) and extension(s) you found.
  • Respect monorepo package depth — emit apps/mobile/packages/*/lib/**/*.dart when packages exist, not a flat **/*.dart.
  • Exclude generated/frozen areas; never scope a rule to build/, node_modules/, *.g.dart, or a deprecated/frozen dir.
  • Present each derived glob with the count of files it matches so the user can sanity-check (e.g. apps/mobile/lib/**/*.dart → 142 files).

Conversion rules:

  • When a pattern is already correct, preserve it verbatim — only change the key name and the YAML shape. Strip surrounding quotes the CSV form may have.
  • Only rewrite a pattern's value when Step 1b proves it stale or over-broad, and show the before/after in the report.
  • Keep description: and any alwaysApply: line unchanged.
  • Idempotent: a file already in the ✅ state with patterns that match real files gets no edit.

Target shape:

---
description: <unchanged>
alwaysApply: false        # only if it was already there
paths:
  - apps/mobile/lib/**/*.dart
  - functions/**/*.ts
---

Step 3 — Audit CLAUDE.md slimming

For each CLAUDE.md found:

  1. Pure @import shell — file body (after stripping blank lines/comments) is nothing but @import/@path lines → redundant, because rules now self-load via paths:. Propose deletion. Exception: keep it if a parent CLAUDE.md relies on importing it AND it carries unique prose.
  2. Bloated index — a top-level CLAUDE.md that inlines full rule bodies that already exist under .claude/rules/. Propose replacing the duplicated section with a one-line pointer ((→ .claude/rules/<area>/<file>.md)), keeping only a short digest. Do NOT delete unique content — only de-duplicate what is verbatim-present in a rule file.
  3. Healthy index — overview + rule map + short Critical Rules digest → leave it.

Be conservative: when unsure whether content is unique, flag it for the user rather than deleting. Never delete a CLAUDE.md that has hand-written guidance not covered by a rule.

Step 4 — Audit the read-not-create hook

Check two things:

  1. .claude/settings.json registers a PreToolUse hook with matcher Write pointing at .claude/hooks/inject-rules.py.
  2. .claude/hooks/inject-rules.py exists.

If either is missing, propose installing it. The canonical files are embedded below — this skill is self-contained. Always reproduce them verbatim from the blocks in this document. NEVER copy inject-rules.py (or any config) from another repository, a sibling project, or a path on the host machine — there is no "reference repo". The skill must behave identically for anyone who runs it, including a colleague who only has this skill file.

.claude/settings.json (merge into existing hooks block; don't clobber other hooks):

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write",
        "hooks": [
          {
            "type": "command",
            "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/inject-rules.py\""
          }
        ]
      }
    ]
  }
}

.claude/hooks/inject-rules.py — when absent, Write this exact content (copy the block below character-for-character; do not fetch it from elsewhere):

#!/usr/bin/env python3
"""PreToolUse(Write) hook: inject path-scoped rules when creating a new file.

Works around the read-not-create caveat (#23478): `.claude/rules/*.md` files
with `paths:` frontmatter load on read, not on create. When Claude creates a
NEW file matching a rule's paths, this injects that rule's body as
additionalContext so it is in context before the file is written.

Fires only for: Write to a file that does NOT yet exist AND matches at least
one rule's `paths:` patterns. Otherwise it exits silently.
"""
import json
import sys
import os
import re
import glob


def glob_to_regex(pat):
    out, i = ['^'], 0
    while i < len(pat):
        if pat[i:i + 3] == '**/':
            out.append('(?:.*/)?')
            i += 3
        elif pat[i:i + 2] == '**':
            out.append('.*')
            i += 2
        elif pat[i] == '*':
            out.append('[^/]*')
            i += 1
        elif pat[i] == '?':
            out.append('[^/]')
            i += 1
        else:
            out.append(re.escape(pat[i]))
            i += 1
    out.append('$')
    return re.compile(''.join(out))


def parse_paths(frontmatter):
    paths, in_paths = [], False
    for line in frontmatter.splitlines():
        if re.match(r'^paths:\s*$', line):
            in_paths = True
            continue
        if in_paths:
            m = re.match(r'^\s*-\s*(.+?)\s*$', line)
            if m:
                paths.append(m.group(1))
            elif line.strip() and not line[0].isspace():
                in_paths = False
    return paths


def main():
    try:
        data = json.load(sys.stdin)
    except Exception:
        return

    proj = os.environ.get('CLAUDE_PROJECT_DIR') or data.get('cwd') or os.getcwd()
    file_path = (data.get('tool_input') or {}).get('file_path', '')
    if not file_path:
        return

    abs_fp = file_path if os.path.isabs(file_path) else os.path.join(proj, file_path)
    # Only the caveat case: a brand-new file.
    if os.path.exists(abs_fp):
        return

    rel = os.path.relpath(abs_fp, proj)
    chunks = []
    pattern = os.path.join(proj, '.claude', 'rules', '**', '*.md')
    for rule_file in glob.glob(pattern, recursive=True):
        try:
            txt = open(rule_file, encoding='utf-8').read()
        except Exception:
            continue
        if not txt.startswith('---'):
            continue
        end = txt.find('\n---', 3)
        if end == -1:
            continue
        frontmatter, body = txt[3:end], txt[end + 4:]
        if any(glob_to_regex(p).match(rel) for p in parse_paths(frontmatter)):
            chunks.append(body.strip())

    if not chunks:
        return

    context = (
        'The file you are about to create (`' + rel + '`) matches path-scoped '
        'rules that were NOT yet loaded into context (they load on read, not on '
        'create). Apply the following rule(s) to the content you write now:\n\n'
        + '\n\n---\n\n'.join(chunks)
    )
    print(json.dumps({
        'hookSpecificOutput': {
            'hookEventName': 'PreToolUse',
            'additionalContext': context,
        }
    }))


main()

If inject-rules.py exists but differs from the embedded canonical, report the diff and ask before overwriting — the user may have customized it. When the user confirms a sync, Write the embedded block above; do not pull the file from any other location.

Step 5 — Report

Print one consolidated table grouped by area (Rules / CLAUDE.md / Hook), each row being path · finding · proposed action. For any glob that changes or is derived, show before → after with the file-match count so the scope is verifiable at a glance (e.g. globs:"lib/**" → paths: apps/mobile/lib/**/*.dart (142 files)). End with a count summary, e.g. 3 rules to migrate · 1 stale glob fixed · 1 CLAUDE.md to delete · hook already installed.

If nothing needs changing, say so plainly and stop — the config is already optimal.

Step 6 — Confirm & apply

  • If --audit-only: stop after the report.
  • Otherwise ask the user to confirm (y to apply all, or let them deselect specific rows). Then:
    • Edit rule frontmatter with Edit (key rename + YAML-array reshape).
    • Delete redundant shells with rm; de-duplicate bloated CLAUDE.md with Edit.
    • Install the hook (Write the script, Edit/Write settings.json) only if that row was confirmed.
  • Never touch unscoped rules or ambiguous CLAUDE.md content automatically — those are advisory flags for the user to resolve by hand.

Step 7 — Verify

After applying, tell the user how to confirm lazy-loading works:

  • Run /memory (or read a file matching a migrated rule's paths:) and look for the ⎿ Loaded … InstructionsLoaded message — the rule should appear only when a matching file is in context, not at session start.
  • The hook fires only on creating a new matching file; the additionalContext arrives after the first write, so the model self-corrects with a second write (create-then-fix). Net result: the new file ends up compliant.

Do not run git commit unless the user asks.