The lock layer for agent skills. Skills are declared in package.json,
pinned to exact commits and content hashes in skills-lock.json, and
installed into agent directories (.claude/skills/, .cursor/skills/, …)
with one command. Like a package manager's lockfile workflow, but the
packages are SKILL.md directories and the registry is any git repo.
$ skills-lock install
+ frontend-design anthropics/skills @ 575462609294 → claude-code, cursor
+ code-review acme/skills#v2.1.0 @ 8f3c2a19bd04 → claude-code, cursor
+ deploy acme/skills#v2.1.0 @ 8f3c2a19bd04 → claude-code, cursor
✓ 3 skill(s) installed, skills-lock.json writtenCommit skills-lock.json. Teammates and CI run skills-lock install --frozen
and get byte-identical skills, verified by content hash — or a hard failure.
Skills today are distributed imperatively: npx skills add owner/repo
fetches whatever is on main right now and vendors it into your repo. That
leaves four gaps that every package ecosystem solved years ago:
- No manifest. There is no record of which skills a project depends on, so there is no "clone and restore" workflow.
- No version pinning. Fetching a branch head means upstream edits change your agents' behavior silently. A skill is a prompt injected into a privileged agent — silent drift is a supply-chain problem, not just a reproducibility annoyance.
- No lockfile / integrity. Even a pinned tag can be force-moved. Content hashes make tampering loud.
- No CI story.
--frozenis what lets a pipeline assert "the skills in this build are exactly the ones the team reviewed."
package.json already sits in most repos and tolerates extra fields. It
would suffice as the standard manifest — this tool is the existence proof.
$ npm install -g skills-lock # or: npx skills-lock ...Requires Node ≥ 18 and git. Zero runtime dependencies.
| Command | What it does |
|---|---|
skills-lock install |
Resolve + install everything in "skills", write skills-lock.json. Already-locked skills are not re-resolved — the pin holds until you ask. |
skills-lock install --frozen |
CI mode: no resolution, no lockfile writes. Fails if the lock is missing, out of sync with package.json, or any content hash mismatches. |
skills-lock add <source> [--skill name]… [--path dir] [--list] |
Discover skills in a source, add them to package.json, install. --list previews without installing. |
skills-lock update [name…] |
Re-resolve floating refs (e.g. #main) to their current commit and re-pin. The only command that moves a pin. |
skills-lock remove <name…> |
Remove from manifest, lockfile, and all agent directories. |
skills-lock list |
Declared skills with their pinned commits. |
owner/repo GitHub shorthand, repo default branch
owner/repo#v1.2.0 pinned to a tag (also: branch or full commit SHA)
github:owner/repo#ref explicit prefix
https://host/repo.git#ref any git remote (GitLab, internal mirrors, …)
file:../team-skills local directory — no pinning, integrity only
A repo can host many skills (a catalog): skills-lock finds every SKILL.md
and matches by frontmatter name, falling back to directory name. When a
name is ambiguous, use the long form { "source": …, "path": … }.
{
"lockfileVersion": 1,
"skills": {
"frontend-design": {
"spec": "anthropics/skills",
"resolved": "https://github.com/anthropics/skills.git",
"ref": null,
"commit": "57546260929473d4e0d1c1bb75297be2fdfa1949",
"path": "skills/frontend-design",
"integrity": "sha256-ZlxYVFXey8sCEWvJKK31FIuc0PZ7frSQjIwNZHwA9oo="
}
}
}commit pins the source; integrity is a sha256 over the skill directory's
file tree, so even a rewritten tag or a poisoned cache cannot slip modified
instructions past --frozen.
Default target is claude-code (.claude/skills/). Configure more in
package.json:
{
"skillsConfig": {
"agents": ["claude-code", "cursor", "my-agent"],
"agentDirs": { "my-agent": ".my-agent/skills" }
}
}Built-in: claude-code, cursor, opencode, windsurf, github-copilot,
amp, universal (.agents/skills).
- run: npx skills-lock install --frozenSet SKILLS_LOCK_CACHE to relocate the git checkout cache (default:
.skills/cache/ in the project, auto-gitignored) — useful for sharing a
cache directory across CI jobs.
A skill is a prompt injected into a privileged agent, and the manifest that
selects skills is often written by someone other than the person running the
install — you clone a repo and run skills-lock install to restore its
skills. So the threat model treats package.json and skills-lock.json as
untrusted input, and running install against a hostile manifest must
not execute code or touch anything outside the project.
What that means concretely:
- Source transports are allowlisted. Only
http(s)://,git://,ssh://, and explicit local paths are accepted. Git'sext::transport (which runs an arbitrary command) and other schemes are refused — including via thegit+prefix, which does not bypass the check. - No git argument injection. Refs and URLs may not begin with
-, and--terminates option parsing before any user value reachesgit. Commits read from the lockfile must be full 40-hex SHAs before they touch git. - No path escapes. Skill names are restricted to a safe directory
charset; the install destination,
entry.path, andskillsConfig.agentDirsoverrides are all asserted to stay inside their expected directory before any recursive remove or copy. - No symlinks. A skill tree containing a symlink is refused, closing both
the integrity-hash bypass (symlinks evade content hashing) and the
arbitrary-file-read risk (a symlink copied into an agent dir pointing at,
say,
~/.ssh/id_rsa). - Integrity is verified.
install --frozenrecomputes a sha256 tree hash of each skill and fails on any mismatch, so a rewritten tag or a poisoned cache cannot slip modified instructions past CI.
These guarantees are covered by adversarial regression tests in
test/e2e.mjs (each asserts the absence of the bad effect — no executed
payload, victim directories left intact — not merely that an error is shown).
Two residual notes: file: sources are inherently local-trust (they read a
path you put in your own manifest), and ssh:// hostname hardening relies on
your installed git (use git ≥ 2.30). Found a gap? Please open an issue.
| manifest | lockfile | pinning | integrity | new infra needed | |
|---|---|---|---|---|---|
npx skills add (skills.sh) |
— | — | ref in URL, not recorded | — | none |
paks |
per-skill frontmatter | — | --version at install |
— | own registry |
| pixi/conda (pavel.pink) | pixi.toml |
pixi.lock |
semver | conda hashes | conda packaging + pixi in every project |
| skills-lock | package.json |
skills-lock.json |
tag/branch/SHA → commit | sha256 tree hash | none — any git repo is a registry |
The pixi approach is the most complete (conda's run_constraints can even
couple a skill's version to the library it documents) but demands conda
tooling in every consuming project. skills-lock deliberately targets the
other end: zero new infrastructure over the way skills are already
distributed — git repos of SKILL.md directories.
This is a working prototype making a standards argument: the manifest
format for agent skills should be package.json, and installs should be
lockfile-driven. The ideal endgame is not another tool — it's this
workflow (manifest + lock + --frozen) absorbed into the ecosystem leader
(vercel-labs/skills), whose agent-directory map already covers 70+ agents.
Not yet handled, by design or by youth:
- Semver ranges (
^1.2) — would resolve against git tags; pinned refs cover the core need first. - Global scope (
~/.claude/skills) — project scope is where reproducibility matters most. - Skill dependencies —
SKILL.mdfrontmatter has no standard field yet. - Symlink installs — copies are maximally compatible; symlinks into the cache would give a single source of truth.
MIT