I opened my skills list first thing one morning, meaning to fix the one that sorts wallpaper assets. I got a few lines down and stopped. There were more rows than I remembered.
Forty-two, once I counted. For more than half of them I could not tell you when I last called one.
I remember adding each of them. Every one made sense on the day it went in. What never happens on its own is the day you take something out. A shelf only grows, and that morning I finally admitted it about mine.
Claude Code v2.1.261, released on September 4, added /skill-doctor. It lists the skills you have loaded, which ones have never been used, and how much context each one is eating. Since it was there, I decided to count my own shelf from the files first, and then look at what the command had to say.
What rides along every turn is one line of description
Let me start with the structure, because getting this backwards makes every number afterwards mean something else.
When a skill is loaded, what sits in context on every turn is not the body of SKILL.md. It is the name and description from the frontmatter — the part that says what this skill is for. The body is only read when the skill is actually invoked.
Put another way: a skill you have never called still costs you its description on every single turn. "I'm not using it, so it's free" is not how it works.
The table /skill-doctor prints has six columns.
| Column | What it means |
|---|---|
| skill | The skill name |
| source | Where it comes from (user, plugin, marketplace) |
| context | How much rides along each turn as the description line |
| 7d tokens | What it actually consumed over the last seven days |
| uses | How many times it was invoked |
| last used | When it was last invoked |
Rows are ordered by last used, which puts everything marked never at the top. The first thing you see is the thing you least want to look at.
Counting my own shelf, from the files
Before waiting on the command, I counted what the files alone could tell me. A small script that walks the folders and adds up how many characters each description takes.
import os, re, sys
root = sys.argv[1] if len(sys.argv) > 1 else "."
rows = []
for name in sorted(os.listdir(root)):
path = os.path.join(root, name, "SKILL.md")
if not os.path.isfile(path):
continue
text = open(path, encoding="utf-8", errors="replace").read()
m = re.match(r"^---\n(.*?)\n---\n", text, re.S)
desc = ""
if m:
d = re.search(r"^description:\s*(.*(?:\n[ \t]+.*)*)$", m.group(1), re.M)
if d:
desc = re.sub(r"\s+", " ", d.group(1).strip().strip("\"'"))
rows.append((name, m is not None, len(desc), len(text), len(text.splitlines())))
with_fm = [r for r in rows if r[1]]
without = [r for r in rows if not r[1]]
desc_total = sum(r[2] for r in with_fm)
body_total = sum(r[3] for r in rows)
print(f"folders with SKILL.md : {len(rows)}")
print(f"with frontmatter : {len(with_fm)}")
print(f"without frontmatter : {len(without)}")
print(f"description total : {desc_total:,} chars (avg {desc_total // max(len(with_fm),1)})")
print(f"body total : {body_total:,} chars / {sum(r[4] for r in rows):,} lines")
print(f"description share : {desc_total / body_total * 100:.2f} %")
print("longest descriptions :")
for r in sorted(with_fm, key=lambda x: -x[2])[:3]:
print(f" {r[0]:<32} {r[2]:>4} chars")Pointed at my skills folder, it printed this.
folders with SKILL.md : 42
with frontmatter : 38
without frontmatter : 4
description total : 11,752 chars (avg 309)
body total : 332,292 chars / 8,931 lines
description share : 3.54 %
longest descriptions :
claude-api 744 chars
ai-seo 698 chars
seo-audit 686 charsThere are 332,000 characters of skill bodies on that shelf, and only 3.54 percent of that — a little under twelve thousand characters — rides along every turn. Seeing the ratio was a relief. The shelf is heavy, but almost all of the weight only lands when I actually reach for something.
The average of 309 characters is not nothing, though. One skill spends 744 characters explaining itself and another gets by on 79, a spread of more than nine times on the same shelf. Some of that gap is worth closing by rewriting, not by deleting.
Four of them were only a note saying they had moved
The line that mattered most was the one counting folders with no frontmatter at all: four of them.
I opened each. They were short documents that said the file had moved, and gave the new location. Leftovers from a time I reorganised where skills live. The move itself was finished long ago; only the forwarding notes were still sitting on the shelf. On the day I wrote them, I thought they were needed.
You cannot spot these by reading names. The names look perfectly alive. You only find out by opening the thing and seeing that its job ended a while back.
I count the number of times I reached for something, not the number of things I put on the shelf. The row count of a list is not the count of what you use. I intend to keep that sentence somewhere near my desk for a while.
I try not to read never as "unnecessary"
This is the point where it gets tempting to hurry, and where it pays to stop for a moment.
last used: never means "not invoked, as far as this machine's records go." A skill you installed yesterday shows never. So does a skill you only reach for a couple of times a year. On the day it appears, it is a candidate for removal, not a confirmed one.
Among my forty-two there were a few I use once a quarter. If I had cleared them out on momentum, the day I needed one would have started with remembering what it was I deleted.
There is one more thing I keep separate. Whether trimming unused skills improves response quality or speed is not something the official notes claim. I would rather write about context cost as context cost, and leave quality as its own question. What went down was the bill, not my skill.
Is the same name arriving from two different shelves?
The other thing worth checking is whether you have the same plugin enabled from two marketplaces. Same name, different origin, and it loads twice — every one of its skills doubled.
The quick way is to dump the /skill-doctor output to a file and look for repeated names. The obvious one-liner takes the first whitespace-separated field, and that is where it trips over names containing a space. Here is exactly what I ran.
# A table with a couple of space-containing names mixed in, for testing
cat > /tmp/sd.txt <<'TABLE'
skill source context 7d tokens uses last used
brand voice marketplace-a 300 0 0 never
brand guidelines user 280 0 0 never
seo-audit user 686 3120 4 2 days ago
seo-audit claude-plugins-official 686 0 0 never
TABLE
awk 'NF>3 {print $1}' /tmp/sd.txt | sort | uniq -dThe result:
brand
seo-auditseo-audit is a real hit. brand is not. brand voice and brand guidelines are two different skills, but taking only the first field makes their leading word line up, and the pair gets reported as a duplicate. Names with spaces in them are common enough that this false positive is not a corner case.
Cutting the name column at the first run of two or more spaces makes the confusion go away.
awk 'NR>1 {
if (match($0, / +/)) {
n = substr($0, 1, RSTART - 1)
c[n]++
}
} END {
for (k in c) if (c[k] > 1) printf "%s\tx%d\n", k, c[k]
}' /tmp/sd.txt | sortseo-audit x2The NR>1 is there to drop the header row, so the literal word skill is not counted as a name. In the naive version it was being counted as one.
If you would rather check the origin itself, read it from the settings file. I have seen enabledPlugins written both as an array and as an object, so this handles either shape.
import json, sys, collections
path = sys.argv[1] if len(sys.argv) > 1 else "settings.json"
try:
data = json.load(open(path, encoding="utf-8"))
except FileNotFoundError:
print(f"no settings at: {path}")
sys.exit(0)
except json.JSONDecodeError as e:
print(f"cannot parse JSON: {path} ({e})")
sys.exit(2)
raw = data.get("enabledPlugins")
pairs = []
if isinstance(raw, list):
for entry in raw:
name, _, market = str(entry).partition("@")
pairs.append((name, market or "(marketplace unknown)"))
elif isinstance(raw, dict):
for market, names in raw.items():
for name in names or []:
pairs.append((str(name), market))
else:
print("enabledPlugins is neither an array nor an object. Check the shape first.")
sys.exit(2)
by_name = collections.defaultdict(list)
for name, market in pairs:
by_name[name].append(market)
dups = {n: m for n, m in by_name.items() if len(m) > 1}
print(f"{len(pairs)} enabled entries / {len(by_name)} distinct names")
for name in sorted(dups):
print(" duplicate: " + name + " <- " + " / ".join(sorted(dups[name])))
sys.exit(1 if dups else 0)I ran it against both shapes of settings file, and both gave the same answer.
5 enabled entries / 3 distinct names
duplicate: aws-core <- agent-toolkit-for-aws / claude-plugins-official
duplicate: frontend-design <- claude-plugins-official / my-marketplaceIt exits 1 on a hit so I can line it up with the other morning checks later. A missing file exits 0 and stays quiet; only malformed JSON returns 2. Absent and broken deserve different treatment. On the wider theme of settings that fail without saying anything, I wrote up a related case in a mistyped key in settings.json is ignored without a word.
One thing to do tomorrow morning
Just one. Run /skill-doctor once and count how many rows say never. Only count. Do not delete anything that day.
Run it again a week later, and anything that got called in the meantime will have dropped off the never list. What is still marked never on the second pass is your first real candidate for removal. Since I started doing it in two passes, I have not once regretted a deletion.
If you want to go further into what a single description line actually costs, I measured a skill section by section in the skill that cost ten thousand tokens just to start, split by reach rather than size. And for the earlier question of which skills earn a place in your daily work at all, I put my reasoning in five things I look at before a Claude Code skill stays in my daily workflow.
Counting the shelf was not a cheerful task. Even so, since seeing the number forty-two, I have felt a little closer to my own tools. Thank you for reading this far.