On a rainy afternoon I opened my skills folder and started colour-coding the directories by the work they belong to. SEO research here, frontend implementation there, site operations over on the side. Partway through the sorting I noticed that the shortest column was the one I open most days.
I wrote up the counting itself in I counted 42 skills on my shelf, so I won't repeat it here. What I want to share this time is what comes after the counting.
For a while I assumed that bundling would make things lighter. It didn't. The per-turn weight didn't drop by a single character. I'm still glad I did it — what dropped was the number of moves, not the weight.
Counting by job reveals the ratio
The counting method is the same as before: add up the characters in each description field. The difference is that this time I group before I add.
# group_skills.py — count a skills folder, grouped by the work each skill serves
import os
import re
import sys
BASE = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser("~/.claude/skills")
GROUPS = {
"seo": ["ai-seo", "seo-audit", "programmatic-seo", "content-research-writer"],
"web-front": ["vercel-optimize", "vercel-react-best-practices", "webapp-testing"],
"design": ["frontend-design", "implement-design", "web-design-guidelines"],
"site-ops": ["claudelab-site", "gemilab-site"],
}
def description_length(folder: str) -> int:
path = os.path.join(BASE, folder, "SKILL.md")
if not os.path.isfile(path):
return 0
text = open(path, encoding="utf-8", errors="replace").read()
front = re.search(r"^---\n(.*?)\n---", text, re.S | re.M)
if not front:
return 0 # no frontmatter means no description to pay for
field = re.search(r"^description:\s*(.*?)(?=^\w+:|\Z)", front.group(1), re.S | re.M)
if not field:
return 0
return len(" ".join(field.group(1).split()))
lengths = {d: description_length(d) for d in sorted(os.listdir(BASE))
if os.path.isdir(os.path.join(BASE, d))}
total = sum(lengths.values()) or 1
assigned = set()
print(f"{'group':<12}{'n':>4}{'chars':>8}{'share':>8}")
for name, members in GROUPS.items():
present = [m for m in members if m in lengths]
assigned.update(present)
chars = sum(lengths[m] for m in present)
print(f"{name:<12}{len(present):>4}{chars:>8}{chars / total * 100:>7.1f}%")
rest = [k for k in lengths if k not in assigned]
rest_chars = sum(lengths[k] for k in rest)
print(f"{'(rest)':<12}{len(rest):>4}{rest_chars:>8}{rest_chars / total * 100:>7.1f}%")
print(f"total {len(lengths)} folders / {total} chars")Both the os.path.isfile guard and the 0 return for missing frontmatter earned their place. My shelf still holds a few folders whose contents are nothing but a forwarding note, and the first version of this script raised on them.
Here is what it printed for me. The grouping follows how my own week is divided, so treat it as one example rather than a template.
| Group | Skills | Description chars | Share of total |
|---|---|---|---|
| SEO research and audits | 5 | 2,442 | 20.7% |
| Frontend implementation | 6 | 2,370 | 20.1% |
| Design and imagery | 9 | 2,416 | 20.5% |
| Site operations | 4 | 623 | 5.3% |
| Development workflow | 7 | 1,145 | 9.7% |
| Everything else | 11 | 2,774 | 23.6% |
| Total | 42 | 11,770 | 100% |
That table stopped me for a minute. The site-operations set, which I open nearly every day, accounts for 5.3% of the description text. The other 94.7% describes work I am not doing today. That may well be the whole of what "the shelf keeps growing" actually feels like.
Character counts are only a yardstick for comparing your own shelf against itself. When you need real numbers, claude plugin details <name> estimates tokens through the count_tokens API for your active model.
A single .claude-plugin/plugin.json changes what a folder means
A skills directory can hold three different kinds of thing under the same tree.
| What you have | What it loads as |
|---|---|
<skills-dir>/foo/SKILL.md with no manifest | A plain skill named foo |
<skills-dir>/foo/.claude-plugin/plugin.json | A plugin foo@skills-dir, which can bundle skills, agents, hooks and more |
<plugin>/skills/bar/SKILL.md | A skill bar packaged inside a plugin |
There are two places it can load from. Put it in ~/.claude/skills/ for personal scope, where it loads in every project. Put it in <cwd>/.claude/skills/ for project scope, where it loads once you have accepted the workspace trust dialog for that folder.
claude plugin init writes the scaffold for you.
# scaffold at ~/.claude/skills/site-ops/ with skills and hooks folders
claude plugin init site-ops --with skills hooks
# rewrite an existing scaffold
claude plugin init site-ops --forceThe manifest can stay small. It is optional altogether, and name is the only required field.
{
"name": "site-ops",
"displayName": "Site Ops",
"version": "0.1.0",
"description": "Article updates and deploy chores for four sites",
"author": { "name": "Masaki Hirokawa" },
"skills": "./skills/",
"hooks": "./hooks/hooks.json"
}Moving flat folders in gives you a tree like this.
~/.claude/skills/
├── site-ops/
│ ├── .claude-plugin/
│ │ └── plugin.json
│ ├── skills/
│ │ ├── claudelab-site/SKILL.md
│ │ ├── gemilab-site/SKILL.md
│ │ ├── antigravitylab-site/SKILL.md
│ │ └── rorklab-site/SKILL.md
│ └── hooks/
│ └── hooks.json
└── seo-kit/
├── .claude-plugin/
│ └── plugin.json
└── skills/
├── seo-audit/SKILL.md
└── programmatic-seo/SKILL.md
I keep the migration itself to a fixed four steps.
- Note the folder names you're moving, then run
claude plugin init <name> --with skillsto write the scaffold - Move the original folders under
<name>/skills/as they are, leaving eachSKILL.mduntouched - Run
claude plugin validate ~/.claude/skills/<name>to catch any file whose frontmatter no longer parses - Restart Claude Code and record the Always-on figure from
claude plugin details <name>
validate in step 3 exits 0 when it passes, 1 when it fails, and 2 when the validation run itself fails, such as when the path can't be read. I keep the figure from step 4 next to the pre-migration total in a note of my own.
Don't put commands/, agents/, or skills/ inside .claude-plugin/. Only plugin.json belongs there; everything else sits one level up. I got this backwards once and built a plugin that loaded with no components at all.