Your ecosystem has fourteen repositories and every one carries a copy of the same release skill. Three of them are out of date and two disagree about the changelog format. That matters because nobody can say which copy an agent used when it got a release wrong. The skill was right when it was written, once, in a repository nobody remembers.
What you get
You will end up with one shared layer, per-repository overrides, and a report of which skills an agent in a given repository actually sees. You also get a lint that fails a skill an agent cannot choose. This is for you if you maintain several repositories that share conventions.
Short answer
Put the skills that apply everywhere in one shared place, and keep per-repository skills in that repository. Resolve them as layers, with the local one winning, and report every name that was shadowed. Lint both layers: a skill needs a name matching its directory and a description long enough for an agent to choose on.
You will need
Node 22 or later, and more than one repository whose agents need the same conventions. A skill is a
directory with a SKILL.md carrying frontmatter, as the Agent Skills
format describes, which is what makes this resolvable with
a directory walk. The frontmatter fields
are the part the lint below reads.1
Voxgig maintains tabnas, whose repositories share skills this way. This page compares that with a plugin marketplace and with leaving every repository on its own.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A Claude Code plugin marketplace repo | You want versioned distribution and installation across many machines | A marketplace to publish and versions to manage, which is more than a copy | The repositories are yours and always checked out together |
| A shared skills repository | Several repositories with one owner and one set of conventions | A second checkout, or a submodule, in every repository that needs it | Only one repository exists |
| Layered resolution with a lint | Any multi-repository setup, as the thing that makes the layers visible | A resolver to run, and findings somebody has to act on | Nothing: this is how you see what the layers produced |
| A skills folder in each repository | One repository, or conventions that genuinely differ everywhere | Copies that drift, with no way to tell which one an agent used | The same skill exists in three repositories |
The question is not whether to share. It is what happens when the shared version and the local one disagree, and the answer has to be visible rather than accidental. A local skill winning is the right default, because the repository knows its own source layout, and a resolver that reports the shadowing is what stops a silent override becoming a mystery.2
A marketplace and a shared repository differ on distribution rather than on structure. If your repositories are always checked out side by side, a shared directory costs nothing. If they are installed independently, on machines you do not control, versioned distribution starts earning its keep.
Resolve the layers, and report the shadowing
The resolver merges two layers, lets the last one win, and records every override.
for (const layer of layers) {
for (const skill of layer.skills) {
// A skill with no name in its frontmatter is keyed by its directory, so
// it still appears and still fails the lint rather than vanishing.
const key = skill.name ?? skill.dir
if (merged.has(key)) shadowed.push({ name: key, by: layer.source, over: merged.get(key).source })
merged.set(key, { ...skill, source: layer.source })
}
}
Keying a nameless skill by its directory matters more than it looks. The alternative is a skill that disappears from the merge because its key was undefined, which reads as a skill that was never written.
Record where each surviving skill came from. The question after a bad agent run is which version it read, and a resolver that answers it turns that into a lookup.
Lint what an agent has to choose from
Each of the three rules is about the moment an agent picks.
if (!skill.name) findings.push(`${skill.dir}: no name in the frontmatter, so nothing can address it`)
else if (skill.name !== skill.dir) findings.push(`${skill.dir}: name is ${skill.name}, which differs from its directory`)
if (!skill.description) findings.push(`${skill.dir}: no description, so an agent cannot tell when to use it`)
A name that differs from its directory is the rule people argue with, and it earns its place the first time somebody greps for a skill by name and finds nothing.3
Run the lint over the shared layer on its own as well as over each merge. A finding in the shared layer affects every repository, and it should not be reported fourteen times.
Check it worked
Resolve for two repositories: one with an override, and one without.
node demo.mjs
repo-parser: 3 skills
debug-grammar (from repo-parser)
parse-json (from repo-parser)
release (from shared)
shadowed: parse-json from shared replaced by repo-parser
repo-abnf: 3 skills
compile-abnf (from repo-abnf)
parse-json (from shared)
release (from shared)
finding: compile-abnf: no name in the frontmatter, so nothing can address it
lint of the shared layer alone
no findings
The parser repository overrides parse-json because inside that repository the engine is the local
source rather than the published package, which is exactly the kind of thing a shared skill cannot
know. The shadowing is reported, so nobody has to guess which version applied.
The second repository shows the other half. Its own skill has no name field, it still appears in
the listing, and the lint says why it cannot be addressed. A resolver that dropped it would leave
somebody wondering where their skill went, and the answer would be nowhere visible.
node --test resolve.test.mjs
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 125.614828
When it goes wrong
An agent used the wrong version of a skill. Two layers used the same name without a warning. Run the resolver in the repository and read the shadowed list.
The shared skills are stale. Nobody owns them, because they belong to every repository and therefore to none. Give the shared layer a home repository with reviewers.
A shared skill references a path that only exists in one repository. It was written in that repository and moved without being generalized. Keep the shared layer free of paths, or make the path a parameter the repository supplies.
Every repository overrides everything. The shared layer is describing conventions that are not actually shared. Move those skills back down and keep the shared layer small. Three skills everybody uses beats twenty that everybody overrides.
When not to do this
Do not share a skill that is right in one repository and merely plausible in the others. A skill that is slightly wrong everywhere costs more than four copies that are each correct.
Do not put secrets or host names in a shared skill. It is copied into every checkout, and the blast radius of a shared file is the whole ecosystem.
Do not build a marketplace for repositories that are always checked out together. The distribution machinery is real work, and a shared directory already solves the problem you have.
Do not copy the tabnas layout because it works for tabnas. Those repositories are checked out together, by the same people, and share one release process. A shared layer is right there and wrong for an ecosystem whose repositories are installed independently by people you have never met.
Related how-tos
Last verified
Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.
Footnotes
-
The specification puts numbers on both fields. A name is 1 to 64 characters of lowercase letters, digits and hyphens, with no hyphen at either end and no two together, and a description is 1 to 1,024 characters. Claude Code adds two rules of its own. A field it does not recognize is ignored without a report, and the listing an agent chooses from caps the combined description and
when_to_usetext at 1,536 characters. A skill can pass the standard in full and reach the agent in part. ↩︎ Back to text -
Claude Code made the same call for its own layers. Its documentation gives the example of a skill named Commit synced from claude.ai and a local skill named commit, which count as the same name, so the local one keeps running. The comparison ignores case, spacing and invisible characters, and treats full-width letters and dash variants as their plain forms. A name that differs by a look-alike letter from another alphabet counts as a different name, and a label is how the two are told apart. ↩︎ Back to text
-
The argument was settled upstream. The specification lists, among the constraints on
name, that it must match the parent directory name, in the same breath as the ban on consecutive hyphens. A lint that enforces it is enforcing the standard, which is a more comfortable position than enforcing a preference. ↩︎ Back to text