Homebrew for agent skills: install, update and remove agent skills from git repositories, Claude plugins, OCI images with a receipt for every install so update and removal are deterministic. One store, symlinked into every agent you use.
- Why
- Features
- Install
- Use
- How it works
- Commands
- Shell completion
- Configuration
- skills.toml
- Status
- Development
- Contributing
- License
- Acknowledgements
Skills spread by copy-paste. You clone a repo into ~/.claude/skills, copy the
same directory into ~/.codex/skills, and a month later nothing records where
any of it came from, which commit you took, or what to delete to undo it.
satchel makes that a package-manager problem instead: one fetch, one copy on
disk, symlinks into every agent, and a receipt that makes the reverse operation
exact.
See how satchel compares to npx skills
in the wiki.
- One store, every agent. A skill is fetched once and symlinked into Claude Code, Codex, Gemini and more — Cursor, Windsurf, Cline, Continue, Zed, Amp, OpenCode, GitHub Copilot, Antigravity and Kiro are all built in too. One copy to update, not several to keep in sync.
- Find a skill without knowing owner/repo.
satchel search <query>matches against a curated registry by name, description and tags, printing a source for each match that can be passed straight tosatchel install. The registry is newly introduced and starts empty — it fills up over time through curation PRs, so an earlysearchmay turn up nothing yet. - A receipt for every install.
satchel listshows what is installed, at which commit, and in which agents.removeunlinks exactly what was created — it never guesses. - The whole receipt, when you need it.
satchel info <name>prints what a skill is for, where it came from, which revision is installed and where its files are — and checks each symlink against the disk, so one that has been deleted, broken or re-pointed is named rather than assumed to work. --dry-runthat is exact. Commands build a plan of the mutations and print it. What you see is what runs; the dry run is not a separate code path.- Updates that keep your choices.
satchel updatemoves a skill to the head of the ref it tracks, keeping the name you installed it under, the agents you linked it into, and its pin. A skill you edited through its symlink is reported rather than overwritten. - See the change before you take it.
satchel diff <name>prints the unified diff between the revision you have installed and the oneupdatewould move to — or, with--against previous, the onerollbackwould move back to. It is scoped to the skill you installed, not the whole repository, and it installs nothing. - Undo an update.
satchel rollback <name>swaps a skill back onto the revision it was on before its last update, keeping its name, its agents and its pin. It is a toggle, so running it again returns to where you were, and a skill you edited through its symlink is reported rather than reverted. - Pin to an immutable commit.
--ref v1.2.0 --pinfreezes the resolved sha so a later update skips it.satchel pinandsatchel unpinadd and remove a pin after the fact, without a remove and reinstall. - Safe by construction. Path-escaping skill names, subpaths and tar entries are rejected; an existing file is never clobbered; nothing but its own symlinks is ever deleted; and links created by a failed apply are rolled back.
- Fast on repeats. A git mirror cache plus a content-addressed revision store means reinstalling a commit you already have does no network work.
- Develop a skill in place.
satchel link ./my-skillregisters a directory you are working in, linked rather than copied, so every edit is live in every agent immediately.removetakes away the symlinks and never the directory. - Move your skills to another machine.
satchel bundle > skills.tomlwrites a small, human-editable manifest of what you have installed;satchel sync skills.tomlinstalls it somewhere else, pins and all.synconly ever adds — it reports a difference or a skill the manifest does not name, and never removes anything. - Sync against a team's shared manifest.
satchel sync owner/team-skillsreadsskills.tomlstraight out of a git profile repository — the same shapesinstallaccepts for a skill — instead of a local file, so a team can keep one canonical list and every machine stays in sync with it.--refchooses the branch, tag or sha to read. - Tag skills to manage them in groups. A
tagslist on a manifest entry is carried onto the receipt;list --tagandbundle --tagfilter to skills carrying any of the given tags, for working with one slice of a large set. - Reach an agent you installed something before you had.
satchel link avoid-ai-writing -a geminiadds a link to the revision that skill is already on, without fetching anything or disturbing a pin. It is the exact inverse ofremove -a. - Takes over what is already there.
satchel adoptrecords the skills already sitting in each agent's skills directory, so hand-made symlinks stop being invisible. One that leads into a clean git checkout is recorded with the sha it is at, pinned; one into a second agent for a skill already managed is added to its receipt. Nothing is moved, copied or deleted. - Tells you when something has rotted.
satchel doctorreports links a receipt records that are gone, links pointing at nothing, one name resolving differently in two agents, skills edited in place, and revisions no receipt references. It changes nothing and names the command that repairs each finding, and it exits non-zero, so it works as a check in CI. - Claude Code plugins too.
satchel install superpowers@claude-plugins-officialinstalls throughclaude plugin, records a receipt, and links every skill the plugin ships into the agents that cannot install plugins themselves — so a plugin reaches Codex, Gemini and every other configured agent like anything else. A plugin Claude already has is adopted rather than reinstalled, andupdatere-points those links when claude moves the plugin to a new version. - Repositories of many skills.
--skilltakes the ones you name,--alltakes every one it finds, and they share a single copy of the repository. A bareinstallon such a repository never guesses: at a terminal it lists what it found and lets you tick the ones you want, and anywhere else — a pipe, a CI job — it prints the same list and stops, so a script still has to say. - Package skills into a container image.
satchel package <source-dir> <oci-ref>bundles a directory of skills into an OCI artifact and pushes it to any registrydockercan reach;satchel install oci://registry/repo:taginstalls from one, andoutdated/updatefollow a moved tag the same way they follow a moved git ref.package --sign-key <path>signs the pushed image with cosign, andinstall --verify-key <path>verifies it before installing — or sign and verify keylessly with--sign-keylessand--verify-identity/--verify-issuer, using Sigstore's Fulcio/Rekor flow instead of a keypair. - Disk you can get back.
satchel gcdeletes the revisions and mirrors no installed skill references, and reports what it freed. Nothing shared is collected while any skill still points at it. - Scriptable.
satchel list --jsonemits the raw receipts,info --jsonemits one of them with everything derived from it, and a partial install exits2so a script can tell it from having installed nothing. - Scaffold a skill and start editing immediately.
satchel new my-skillwrites./my-skill/SKILL.mdwith valid frontmatter and links it into every agent found in the same step — the create-and-link equivalent ofsatchel link ./my-skillfor a skill you have not written yet. - Declare which agents a skill was written for.
agents:in aSKILL.md's frontmatter is an optional YAML list (agents: [claude, codex]). Installing into an agent not in the list still links it — this is advisory, not a refusal — but prints a warning naming the skill and the undeclared agent. - Pick skills interactively instead of naming them one at a time.
satchel browselists what is installed, with its outdated status, and lets you tick several to update or remove in one batch. - Catch a broken
SKILL.mdbefore you publish it.satchel lint <path>checks a skill's frontmatter the wayinstallreads it, but strictly: a missing or empty name or description fails the check, and a name that would not match its directory is a warning. Point it at a directory of several skills and it checks every one. - One static binary. No runtime dependency beyond
git, andclaudeonly if you install plugins.
brew install richardcase/tap/satchel(macOS only — the Homebrew formula publishes a cask. On Linux, use the
pacman repository, the .deb/.rpm packages, or the tarball below.)
Or grab a binary, .deb, .rpm or .pkg.tar.zst from the releases page,
or build from source with go install github.com/richardcase/satchel/cmd/satchel@latest.
satchel has its own signed pacman repository, for x86_64 and aarch64.
First, trust its signing key:
curl -fsSL https://richardcase.github.io/archlinux-repo/richardcase.asc | sudo pacman-key --add -
sudo pacman-key --lsign-key A4A33462EA5B83E51E46D2DC0F5F63C609D80700Then add the repository to /etc/pacman.conf:
[richardcase]
Server = https://richardcase.github.io/archlinux-repo/$archsudo pacman -Sy satchelNew releases then arrive with pacman -Syu. Stable releases only: release
candidates are not published to the repository.
This project was renamed from skillsctl to satchel. There is no
skillsctl compatibility binary and no dual support for the old
SKILLSCTL_* environment variables — install satchel and use it in their
place. If you were using the default store and config locations (no
SKILLSCTL_HOME/SKILLSCTL_CONFIG set), the first satchel command you run
automatically moves ~/.local/share/skillsctl to ~/.local/share/satchel
and ~/.config/skillsctl/config.toml to ~/.config/satchel/config.toml,
repointing every symlink it manages along the way — nothing else to do. If
you had SKILLSCTL_HOME or SKILLSCTL_CONFIG set, rename the variable to
SATCHEL_HOME/SATCHEL_CONFIG yourself; satchel prints a one-time
reminder if it notices the old name still set.
satchel search research # find skills by name, description or tag
satchel install conorbronsdon/avoid-ai-writing # choose agents from a checklist
satchel install owner/repo/path/to/skill # a skill inside a monorepo
satchel install owner/repo//path/to/skill # the same, boundary spelled out
satchel install owner/repo # pick from a list of its skills
satchel install owner/repo --skill web-research # name one (repeat for more)
satchel install owner/repo --all # every skill in the repo
satchel install owner/repo -a claude # just one agent
satchel install owner/repo --ref v1.2.0 --pin # pin a version
satchel install owner/repo --dry-run # show what would change
satchel install superpowers@claude-plugins-official # a Claude Code plugin
satchel install https://gitlab.com/group/subgroup/repo.git # any git host, incl. GitLab subgroups
satchel install oci://ghcr.io/owner/skills:v1 # from a packaged OCI artifact
satchel package ./my-skills ghcr.io/owner/skills:v1 # push a directory of skills as one
satchel package ./my-skills ghcr.io/owner/skills:v1 --sign-key cosign.key # ...and sign it
satchel install oci://ghcr.io/owner/skills:v1 --verify-key cosign.pub # verify before installing
satchel package ./my-skills ghcr.io/owner/skills:v1 --sign-keyless # sign via Sigstore's Fulcio/Rekor flow
satchel install oci://ghcr.io/owner/skills:v1 \
--verify-identity signer@example.com --verify-issuer https://accounts.google.com # verify a keyless signature
satchel link ./my-skill # a skill you are writing
satchel install ./my-skill # the same thing
satchel new my-skill # scaffold a skill and link it, in one step
satchel lint ./my-skill # check its SKILL.md before publishing
satchel lint ./my-skills-repo # check every skill under a directory
satchel link avoid-ai-writing -a gemini # into an agent that missed it
satchel browse # pick installed skills to update or remove
satchel list # what's installed
satchel list --json # the raw receipts
satchel list --include-channel git # only skills fetched via git
satchel list --exclude-channel local # everything except skills you are editing
satchel info brainstorming # one skill's receipt in full
satchel info brainstorming --json # the same, for a script
satchel outdated # what has moved upstream
satchel diff avoid-ai-writing # what `update` would change
satchel diff avoid-ai-writing --against previous # what `rollback` would undo
satchel update # move everything to its ref's head
satchel update avoid-ai-writing # just this one, pin or not
satchel update --dry-run # show what would change
satchel pin brainstorming # freeze it where it is
satchel unpin brainstorming # let it follow its ref again
satchel unpin brainstorming --ref develop # ...this ref, from now on
satchel remove avoid-ai-writing # unlink everywhere
satchel rollback avoid-ai-writing # swap back to before the last update
satchel rollback avoid-ai-writing # run it again to toggle back
satchel rollback avoid-ai-writing --force # ...even if you edited it in place
satchel adopt --dry-run # what is already in your agents
satchel adopt # take it over
satchel gc # reclaim disk nothing uses
satchel gc --dry-run # show what it would free
satchel bundle > skills.toml # write what's installed as a manifest
satchel bundle --tag frontend > frontend.toml # ...just the skills tagged frontend
satchel sync skills.toml # install what it names, and report the rest
satchel sync skills.toml --dry-run # show what would change
satchel sync team/skills-profile # sync from a git-hosted skills.toml
satchel sync team/skills-profile --ref develop # ...at a specific branch
satchel list --tag frontend # only skills tagged frontend
satchel version$ satchel list
NAME CHANNEL VERSION AGENTS TAGS
avoid-ai-writing git a1b2c3d claude,codex frontend
brainstorming git 9f8e7d6 (pinned) claude
superpowers plugin 6.3.0 claude,codex
my-skill local - claude
search matches a query against the registry's name, description and tags,
printing a source for each hit that can be passed straight to install:
$ satchel search research
NAME SOURCE DESCRIPTION
web-research vercel-labs/agent-skills/web-research Research a topic against primary sources
No match is not an error — it just says so:
$ satchel search nonexistent
No skills found matching "nonexistent".
info prints everything the receipt records, together with the description from
the skill's SKILL.md. Each link is checked against the disk, so a symlink that
has been deleted, broken or re-pointed is named as such — nothing is fetched and
nothing is repaired:
$ satchel info brainstorming
brainstorming
Explores user intent, requirements and design before implementation.
channel git
source https://github.com/obra/superpowers.git
subpath skills/brainstorming
ref the repository's default branch
revision b36e0829c6d0140e93cfef2ca599b1b07d4a7797
files ~/.local/share/satchel/rev/github.com/obra/superpowers/b36e082…/skills/brainstorming
(satchel's store)
installed 2026-08-15 08:50:14 UTC
updated 2026-08-15 08:50:14 UTC
links
claude ~/.claude/skills/brainstorming
codex ~/.codex/skills/brainstorming (missing)
A name that is not installed is an error naming the closest ones that are:
$ satchel info brainstorm
error: "brainstorm" is not installed; did you mean brainstorming?
A source can be owner/repo, owner/repo/path/to/skill, any git URL
(https, ssh or scp-style), a local path, or oci://registry/repository:tag for
a skill packaged with satchel package. // separates a repository or an
artifact from a subpath inside it — the only way to name one in a
.git-suffixed or git@host: URL, where the repository boundary is otherwise
the whole path, and in an oci:// reference, where the tag ends it:
oci://ghcr.io/owner/skills:v1//pdf-forms.
The owner/repo shorthand is GitHub-specific, but any other git host — GitLab,
Bitbucket, a self-hosted server — works with its full URL, .git suffix
included: satchel install https://gitlab.com/group/subgroup/repo.git. The
suffix matters more on GitLab than GitHub, since GitLab projects can nest
inside subgroups (group/subgroup/repo), and without an explicit .git
boundary that path is indistinguishable from owner/repo/path/to/skill. Add
//path/to/skill after the .git to name a skill inside such a repository.
A repository holding several skills can be narrowed with --skill <name>
(repeatable, matching a skill's name or its path) or --all. Without one of
them, install asks rather than guessing — at a terminal, that is a list to
pick from:
$ satchel install vercel-labs/agent-skills
skills in https://github.com/vercel-labs/agent-skills.git @ 7c41bf0:
❯ ◉ pdf-forms Extract and fill PDF forms
◯ web-research Research a topic against primary sources
↑/↓ move · space toggle · a all · enter install · q cancel
↑/↓ (or k/j) move, space ticks a skill, a ticks every one, enter
installs what is ticked and q backs out. Backing out, or confirming with
nothing ticked, exits 1 and changes nothing. With --as, which renames a
single skill, the list takes one choice instead of several.
A repository whose skills live under two or more top-level folders — several independent skill packs in one repo — gets a folder heading per group instead of one flat list. Space on a heading ticks or clears every skill under it in one keystroke; a skill can still be ticked on its own:
$ satchel install mattpocock/skills
skills in https://github.com/mattpocock/skills.git @ 9a2f5c1:
❯ ◉ content
◉ blog-post-writer Write a blog post in your voice
◉ tweet-thread Turn notes into a tweet thread
◯ engineering
◯ pr-description Summarize a diff into a PR description
↑/↓ move · space toggle · a all · enter install · q cancel
There has to be someone to ask: when stdin or stderr is not a terminal — a
pipe, a CI job, < /dev/null — the same list is printed and the command stops,
so an unattended run can never install something nobody chose. Grouping
carries over to this plain form too:
$ satchel install vercel-labs/agent-skills < /dev/null
skills in https://github.com/vercel-labs/agent-skills.git @ 7c41bf0:
pdf-forms Extract and fill PDF forms
web-research Research a topic against primary sources
error: this repository holds 2 skills: pass --skill <name> (repeatable) or --all
A Claude Code plugin-marketplace repository — a root
.claude-plugin/marketplace.json naming plugins that each nest their own
skills/ — is recognized the same way: skills are grouped and labelled by the
plugin they belong to, however deep they are nested, and a plugin's own
.claude-plugin/plugin.json description fills in for a skill whose own
SKILL.md has none:
$ satchel install humanlayer/skills < /dev/null
skills in https://github.com/humanlayer/skills.git @ 52a638e:
show-me:
show-me Explain visually
improve-claude-md:
improve-claude-md Improve CLAUDE.md files
error: this repository holds 2 skills: pass --skill <name> (repeatable) or --all
$ satchel install humanlayer/skills --skill show-me
installed show-me @ 52a638e into claude
link <path> on a directory of several skills asks the same question.
outdated compares each skill against its remote, reading refs only — nothing is
fetched. It exits 3 when an update is available, so it works as a CI check:
$ satchel outdated
NAME CHANNEL REF CURRENT LATEST STATUS
avoid-ai-writing git HEAD 3c0fd8a 3c0fd8a current
brainstorming git main 525e31b 9071811 outdated
pinned-one git HEAD 525e31b 9071811 outdated (pinned)
note: 1 update available
Pinned skills are listed and marked, so a pin never hides the fact that something
moved, but they do not set that exit code on their own — update skips them.
diff shows the change itself rather than the two shas, so you can read what an
update would bring in before you take it. It is scoped to the installed skill:
in a repository of many, only that skill's own subdirectory is compared.
$ satchel diff brainstorming
diff --git a/SKILL.md b/SKILL.md
index 525e31b..9071811 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -12,6 +12,8 @@ description: Explores user intent before implementation.
Ask what problem is being solved.
+Ask who else has to live with the answer.
$ satchel diff brainstorming --against previous
no changes
--against latest (the default) fetches the tracked ref into the local mirror
cache so it compares against the remote's true head — nothing is installed, no
symlink moves and no receipt is written, but it does touch the network.
--against previous compares against the revision rollback would swap back
to, which was already fetched when it was installed or updated onto, so it needs
no network at all. Either way, identical revisions print no changes.
update re-points each symlink at the new revision and rewrites the receipt,
keeping the name, the agents and the pin:
$ satchel update
updated avoid-ai-writing 3c0fd8a -> 9071811
skipped brainstorming: edited since it was installed; pass --force to update it anyway
skipped pinned-one: pinned at 525e31b; name it explicitly to update it
1 revision (4.1 MB) now unreferenced; run `satchel gc` to reclaim
Naming a skill updates it even when it is pinned, re-pinning it at the new
commit. Revision directories carry no .git, so a skill edited through its
symlink is spotted by re-hashing it against what was recorded at install time,
and skipped rather than overwritten — --force updates it anyway, discarding
the edit. The old revision stays on disk until satchel gc, so a failed
update leaves the previous one linked and the receipt untouched.
rollback undoes an update: the receipt remembers the revision it was on before
its last one, and rollback swaps back onto it, keeping the name, the agents and
the pin. It is a toggle — running it again returns to the revision the first
rollback moved away from:
$ satchel rollback avoid-ai-writing
rolled back avoid-ai-writing to 3c0fd8a
$ satchel rollback avoid-ai-writing
rolled back avoid-ai-writing to 9071811
A skill that has never been updated has nothing recorded to swap back to, and a
skill edited through its symlink is skipped rather than silently reverted — the
same check update makes, with the same escape hatch:
$ satchel rollback my-notes
skipped my-notes: nothing to roll back to: install or update this skill first
$ satchel rollback brainstorming
skipped brainstorming: edited since it was installed: pass --force to roll it back anyway
$ satchel rollback brainstorming --force
rolled back brainstorming to 525e31b
satchel diff <name> --against previous shows what a rollback would undo
before you run it, and --dry-run prints the relink and record ops it would
apply. Only skills fetched from git or from an OCI registry have a revision
history to swap back to; a local skill or a plugin is refused by name.
A pin can be added and removed after the fact, so changing your mind costs one command rather than a remove and a reinstall:
$ satchel pin brainstorming
pinned brainstorming at 9f8e7d6 (it no longer tracks main)
$ satchel unpin brainstorming
unpinned brainstorming; it now tracks the repository's default branch
Neither fetches anything or moves a symlink: both write one field on the
receipt, which is why --dry-run on them prints a record line and nothing
else. A pinned skill tracks no ref, so pin says which one it dropped and
unpin says what the skill follows now — --ref names another, and is checked
before it is recorded so a typo fails here rather than in the next update.
Only skills fetched from git can be pinned: a local skill is whatever is in its
directory right now, and a plugin is at whichever version Claude installed.
Skills are fetched once into ~/.local/share/satchel and symlinked into each
agent's skills directory, so one copy serves every configured agent.
~/.local/share/satchel/
cache/<slug>.git bare git mirror, reused across installs and refs
rev/<slug>/<sha>/ the extracted tree at one commit
state.json receipts
A receipt records the source, channel, requested ref, resolved sha, whether it
is pinned, the revision path, a content hash of the tree, what those last three
held before its most recent update (which is what rollback swaps back onto),
and every symlink the install created — which is what makes remove
deterministic.
A local skill is recorded but never copied: the receipt holds the directory you
gave and the symlinks point straight at it, so edits are live and there is
nothing in the store. It has no revision, no content hash and nothing to update
from — list shows a - for its version and update says so. Removing it
takes away satchel's own symlinks and leaves your directory exactly as it was.
A directory inside the store, or already inside an agent's skills directory, is
refused rather than linked.
adopt is how a skill that was installed by hand becomes one of these. A
symlink is recorded exactly as link would have recorded it — the same receipt,
so removing it later takes away the symlink and leaves its target alone. One
that leads into a git checkout with a remote is recorded on the git channel
instead, at the sha the checkout is at and pinned, so outdated still reports
when the ref moves while update re-points it only when you name it. A checkout
with uncommitted changes stays local, because the sha would not describe the
files on disk. A real directory sitting in a skills directory is reported rather
than adopted: there is no symlink to record as the removal contract, and adopt
moves nothing. Nor does it touch anything already managed, anything dangling, or
anything without a SKILL.md — it says what it found and why.
A hand-made link into a second agent, for a skill that is already managed, is
added to the receipt that manages it — the same amendment
satchel link <name> -a <agent> makes, found after the fact. It has to point
where that receipt already says its files are, since a receipt is what update
re-points and remove deletes; one that leads somewhere else is reported
instead.
A plugin is the second exception, because Claude Code owns it. satchel records
the plugin@marketplace id, the version and the install path claude reported;
there is no revision in the store and no content hash, since the files are the
agent's. What it adds is the fan-out: every skill under the plugin's skills/
directory is symlinked into the agents that cannot install plugins for
themselves, and those links are recorded on the receipt like any other. So
install, update and remove run claude plugin install|update|uninstall,
read back what claude decided, and then make the links agree with it — which
matters because claude installs each version beside the last, so a link left
alone would go on serving a version that has been replaced. gc still leaves a
plugin alone: nothing of it is in the store. claude must be on PATH; nothing
else needs it.
Locations can be overridden with environment variables:
| Variable | Overrides | Falls back to |
|---|---|---|
SATCHEL_HOME |
the store | $XDG_DATA_HOME/satchel, then ~/.local/share/satchel |
SATCHEL_CONFIG |
the config file | $XDG_CONFIG_HOME/satchel/config.toml, then ~/.config/satchel/config.toml |
SATCHEL_REGISTRY_URL |
where search fetches the registry from |
the config file's [registry] table, then the built-in default |
package/install support two independent, mutually exclusive ways to sign
and verify an OCI artifact, both by shelling out to cosign — install it
separately and have it on PATH, or satchel reports as much and names
the offline flags instead. Neither is the default; pick whichever fits how
the image was built.
Keypair, for images signed by hand or by a pipeline that already manages
its own keys. Generate one with cosign generate-key-pair:
cosign generate-key-pair
# writes cosign.key (encrypted private key) and cosign.pub (public key);
# prompts for a password to encrypt cosign.key unless COSIGN_PASSWORD is setthen:
satchel package ./my-skills ghcr.io/owner/skills:v1 --sign-key cosign.key
satchel install oci://ghcr.io/owner/skills:v1 --verify-key cosign.pubSigning runs cosign sign --key <path> --yes <ref>; cosign reads the key's
decryption password from COSIGN_PASSWORD in the environment, not from a
flag. Verification runs cosign verify --key <path> <ref> and never makes a
network call beyond the registry pull itself — it works with no access to
Sigstore's infrastructure. Losing the private key stops you signing new
images under that identity; it does not invalidate images already signed.
Keyless, for images signed in CI, where there is no key to hold or rotate — the signer's identity is the workflow itself, backed by Sigstore's Fulcio (short-lived certificate issuance) and Rekor (transparency log):
satchel package ./my-skills ghcr.io/owner/skills:v1 --sign-keyless
satchel install oci://ghcr.io/owner/skills:v1 \
--verify-identity signer@example.com --verify-issuer https://accounts.google.com--sign-keyless runs cosign sign --yes <ref> with no --key, so cosign
drives its own OIDC flow: an interactive browser login when run on a
workstation, or the CI platform's ambient OIDC token when run unattended —
GitHub Actions' own https://token.actions.githubusercontent.com issuer is
picked up automatically, with nothing extra to configure. The resulting
certificate's identity (an email address, or a CI workflow's OIDC subject
like https://github.com/owner/repo/.github/workflows/release.yml@refs/heads/main)
and issuer are what --verify-identity/--verify-issuer check against —
both are required together, and must match the signer's certificate exactly.
--verify-identity/--verify-issuer runs
cosign verify --certificate-identity <identity> --certificate-oidc-issuer <issuer> <ref>,
which — unlike --verify-key — is an online check: it queries Rekor's
transparency log for the signature.
Each CI platform has to be told to hand its job an OIDC token before cosign can use it:
GitHub Actions grants nothing by default — add id-token: write to the
job's permissions, then sign as usual:
permissions:
id-token: write # required so cosign can request the ambient OIDC token
contents: read
steps:
- uses: sigstore/cosign-installer@v3
- run: satchel package ./my-skills ghcr.io/owner/skills:v1 --sign-keylessVerify against the workflow's own identity:
satchel install oci://ghcr.io/owner/skills:v1 \
--verify-identity https://github.com/owner/repo/.github/workflows/release.yml@refs/heads/main \
--verify-issuer https://token.actions.githubusercontent.comGitLab CI has no ambient token — request one explicitly via id_tokens
with aud: sigstore, which cosign reads from the SIGSTORE_ID_TOKEN
environment variable it sets:
sign:
id_tokens:
SIGSTORE_ID_TOKEN:
aud: sigstore
script:
- satchel package ./my-skills registry.example.com/owner/skills:v1 --sign-keylessGitLab's certificate identity is the pipeline's URL rather than an email — note the required double slash before the CI config path, and that the issuer is the GitLab instance itself:
satchel install oci://registry.example.com/owner/skills:v1 \
--verify-identity "https://gitlab.com/owner/skills//.gitlab-ci.yml@refs/heads/main" \
--verify-issuer https://gitlab.cominstall refuses the install outright, before anything is extracted or
linked, if verification is requested and fails. If an image is signed but
install was given neither --verify-key nor
--verify-identity/--verify-issuer, it still installs — skipping
verification is opt-in, not silent, so it prints a warning naming both ways
to verify.
| Command | Flags | Does |
|---|---|---|
search <query> |
--json |
Find skills in the registry by name, description or tag |
install <source> |
--skill, --all, -a/--agent, --ref, --as, --pin, --dry-run |
Fetch one or more skills and link them into each agent |
install <p>@<m> |
-a/--agent, --as, --dry-run |
Install a Claude Code plugin through claude plugin |
install oci://<ref> |
--skill, --all, -a/--agent, --ref, --as, --pin, --verify-key, --verify-identity, --verify-issuer, --dry-run |
Install one or more skills from an OCI artifact |
package <source-dir> <oci-ref> |
--sign-key, --sign-keyless, --dry-run |
Package a directory of skills into an OCI artifact and push it |
link <name> |
-a/--agent, --dry-run |
Link an installed skill into another agent |
link <path> |
-a/--agent, --skill, --all, --as, --dry-run |
Link a skill you are working on, where it already is |
new <name> |
-a/--agent, --description, --dry-run |
Scaffold a new skill and link it in place |
lint <path> |
--json |
Validate a skill's SKILL.md before publishing it |
adopt |
-a/--agent, --dry-run, --json |
Record the skills already in an agent's skills directory |
list |
--json, --include-channel, --exclude-channel, --tag |
Show installed skills, versions and agents |
browse |
--dry-run |
Pick installed skills interactively to update or remove |
info <name> |
--json |
Show one skill's receipt in full, and whether its links are live |
outdated |
--json |
Report skills whose tracked ref has moved |
diff <name> |
--against latest|previous |
Print the unified diff between an installed skill and what update would move to, or what rollback would move back to |
update [name...] |
--force, --dry-run |
Move skills to the head of the ref they track |
pin <name>... |
--dry-run |
Freeze skills at the revision they are installed at |
unpin <name>... |
--ref, --dry-run |
Release the pin, so update moves them again |
remove <name> |
-a/--agent, --dry-run |
Unlink from every agent, or just the named ones |
rollback <name>... |
--force, --dry-run |
Swap a skill back onto the revision it was on before its last update (a toggle: running it twice undoes itself) |
doctor |
--json |
Report where the receipts and the filesystem disagree |
gc |
--dry-run, --json |
Delete revisions and mirrors no receipt references |
bundle |
--tag |
Write the installed skills as a portable skills.toml |
sync <file-or-source> |
--ref, --dry-run |
Install the skills a manifest names, from a local file or a git-hosted profile repo, and report the rest |
version |
Print version, commit and build date |
remove also answers to uninstall and rm. Removing from some agents keeps
the receipt; removing the last link forgets it.
Removing a plugin uninstalls it through claude and takes away every link its
skills had. Naming only an agent that holds links — remove superpowers -a codex
— takes those away and keeps the receipt, since the plugin is still installed.
Naming the agent that owns it is refused if that would strand a linked agent's
skills — one holding links that was not also named in the same command; naming
both together takes both away in one command rather than being refused. The
error names satchel remove <name>, which does mean everywhere.
link <name> -a <agent> is its inverse, for the agent that was not on the
machine when something was installed: it adds a link to the revision the receipt
already has, without fetching anything. Which of the two forms you meant is
decided by looking the argument up in the receipts, so an installed name takes
the first and everything else takes the path. Naming an agent that already has
the skill links the rest and says so, exiting 2; naming only agents that already
have it does nothing and exits 1.
A plugin is linked skill by skill: link superpowers -a codex puts every skill
the plugin ships into codex, repairing any of them a link was missing for —
codex holding some but not all of a plugin's skills is not "already has it".
The agent that installed the plugin is reported as already having it, because
it can see those skills without a symlink.
--skill, --all, --ref and --pin mean nothing for a plugin — it is
installed whole, at whichever version its marketplace publishes — and are
refused rather than ignored. outdated cannot ask the marketplace whether a
newer version exists, so instead it compares the receipt against what claude
has installed now: a plugin claude has moved since satchel last looked comes
back stale, which satchel update repairs.
Nothing in the store is deleted until you ask. remove unlinks a skill and
forgets its receipt, and update moves it off the revision it was on, but both
leave the copy on disk, because another skill may be installed from the same
commit — which is the normal case for a repository installed with --all. gc
reclaims what no receipt references: the revision, and the bare mirror once no
revision of that repository is left.
$ satchel gc --dry-run
rev/github.com/obra/superpowers/9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c 4.1 MB
cache/github.com/obra/superpowers.git 2.7 MB
would reclaim 1 revision and 1 mirror, 6.8 MB
doctor checks that on-disk reality still matches the receipts: links a receipt
records that are gone, links pointing at nothing or at something other than
what the receipt says, one name resolving differently in two agents, revisions
missing from the store, skills edited in place through their symlink, and
revisions no receipt references. It changes nothing — every finding names the
command that repairs it, and the decision stays yours. Every configured agent is
scanned, with no way to narrow it: a health check that skipped an agent would
report a clean bill of health for a broken one.
It also warns, without failing, when cosign is not on PATH — signing and
verifying packages both depend on it, and the warning names where to install
it.
$ satchel doctor
missing links
tdd codex ~/.codex/skills/tdd is recorded but not on disk
fix: satchel remove tdd -a codex, then satchel link tdd -a codex
dangling links
brainstorming claude points at ~/.local/share/satchel/rev/…/9f8e7d6c, which is gone
brainstorming codex points at ~/.local/share/satchel/rev/…/9f8e7d6c, which is gone
fix: satchel remove brainstorming, then satchel install obra/superpowers
orphan revisions
rev/github.com/obra/superpowers/9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c 4.1 MB
fix: satchel gc
note: 4 problems in 2 skills
The repairs are deliberately not satchel update: update moves a skill to the
head of the ref it tracks and stops at current when the ref has not moved,
which is the usual state of a skill whose link somebody deleted. Putting a link
back is remove -a followed by link -a, and replacing store content is a
reinstall — with a gc in between when the revision was edited in place, since
install reuses a revision directory that is already there.
Exit codes: 0 everything asked for was done, 1 nothing was, 2 part of it
was and the rest is reported — install --all where one name is already taken
installs the others and exits 2, outdated exits 2 when it could not reach
some of the remotes, update exits 2 when it updated some skills and skipped
others (and 1 when it updated none of them), gc exits 2 when it freed
some of what it found but could not remove the rest, adopt exits 2 when
it adopted some of what it found and skipped the rest (and 1 when it could
adopt none of it), and doctor exits 2 when an agent's skills directory could
not be read. The codes above 2 are findings rather
than a verdict on the work:
3 means outdated ran to completion and something has moved, and 4 that
doctor ran to completion and something is wrong. A stale plugin does not set
3: it is not an available update, and satchel update repairs it on its own.
satchel generates a completion script for bash, zsh, fish and PowerShell —
this comes from Cobra, the CLI framework satchel is built on, so it needs no
setup beyond sourcing it:
# zsh
echo 'source <(satchel completion zsh)' >> ~/.zshrc
# bash
echo 'source <(satchel completion bash)' >> ~/.bashrcsatchel completion --help lists every shell and the exact setup for each.
Agents are configured in ~/.config/satchel/config.toml. Without one,
satchel uses the built-in defaults below, and installs into whichever of
them exist.
[[target]]
name = "claude"
dir = "~/.claude/skills"
project_dir = ".claude/skills"
plugins = true
[[target]]
name = "codex"
dir = "~/.codex/skills"dir is the agent's user-level skills directory, project_dir the
repository-relative one, and plugins marks an agent that installs plugins from
a marketplace for itself. It gates installing a plugin, never seeing one: a
name@marketplace source needs an agent with plugins = true in the set, and
naming only agents without it through -a is an error rather than a silent
no-op — but it is precisely the agents without it that a plugin's skills are
linked into.
The built-in table, with no config file needed:
name |
dir (global) |
project_dir |
|---|---|---|
claude |
~/.claude/skills |
.claude/skills |
codex |
~/.codex/skills |
.codex/skills |
gemini |
~/.gemini/skills |
.gemini/skills |
cursor |
~/.cursor/skills |
.agents/skills |
windsurf |
~/.codeium/windsurf/skills |
.windsurf/skills |
cline |
~/.agents/skills |
.agents/skills |
continue |
~/.continue/skills |
.continue/skills |
zed |
~/.agents/skills |
.agents/skills |
amp |
$XDG_CONFIG_HOME/agents/skills |
.agents/skills |
opencode |
$XDG_CONFIG_HOME/opencode/skills |
.agents/skills |
copilot |
~/.copilot/skills |
.agents/skills |
antigravity |
~/.gemini/antigravity/skills |
.agents/skills |
kiro |
~/.kiro/skills |
.kiro/skills |
Only claude is plugin-capable today. An agent that isn't in this table yet
still works — add a [[target]] block for it by hand, as above.
Omitting -a/--agent on install, link or adopt in a terminal shows a
checklist of every configured agent, with claude and codex pre-ticked
when they're already present, rather than silently acting on every present
agent. -a still bypasses it outright. Piped or scripted use (no terminal
attached) is unchanged: it falls back to every present agent, exactly as
before.
satchel search fetches its registry from GitHub, configurable via a
[registry] table:
[registry]
url = "https://raw.githubusercontent.com/richardcase/satchel/main/registry/skills.json"SATCHEL_REGISTRY_URL overrides both the config file and the built-in
default, mainly for testing against a self-hosted mirror. A successful fetch
is cached at <store root>/registry-cache.json, used when the network or
GitHub is unavailable.
satchel bundle writes the skills you have installed as a manifest, and
satchel sync installs one. It is meant to be read and edited by hand, and
committed.
version = 1
[[skill]]
name = 'alpha'
source = 'https://github.com/owner/repo.git'
subpath = 'skills/alpha'
ref = '9f8e7d6c5b4a39281706f5e4d3c2b1a098765432'
pinned = true
[[skill]]
name = 'beta'
source = 'https://github.com/owner/repo.git'
ref = 'develop'
agents = ['claude']
tags = ['frontend']refis the branch or tag a skill tracks, or the frozen sha whenpinnedis set — aninstall --pinrecords no ref, so the sha is the only thing that can carry the pin to another machine.agentsis omitted when the skill is in every agent present on the machine, which is what an omitted-ameans toinstall. Name them only for a narrower choice. For a plugin this counts the agent that installed it plus the ones its skills were fanned out to, so a plugin narrowed with-acarries its agents like anything else.subpathlocates a skill inside a repository holding several. You can write it in the source instead, asowner/repo//skills/alpha.synccompares an entry's subpath against what the receipt it installed actually recorded, so a hand-written entry that omitssubpathfor a skill that lives at one reports a difference rather than syncing — install once andbundleto get the subpath right, rather than guessing at it by hand.localskills — a directory you linked withsatchel link ./path— are left out of a bundle and named on stderr, because an absolute path on one machine means nothing on another.tagsgroups skills forlist --tag/bundle --tagto filter by. A tag is any string; satchel imposes no vocabulary. Tags are set from the manifest only whensyncinstalls a skill for the first time — likeagents, they are metadata rather than identity, so re-syncing a manifest with different tags for an already-installed skill changes nothing.
sync only ever adds:
$ satchel sync skills.toml
installed alpha @ a1b2c3d into claude, codex, gemini
linked beta into claude
gamma differs: the manifest tracks develop, the install tracks main; remove it and run sync again, or bring the manifest in line
not in the manifest: epsilon (installed from https://github.com/owner/epsilon.git)
note: 2 of 3 entries applied, for the reasons above
It installs what is missing and links the agents an entry names. It never re-points a ref, never moves a pin and never removes a skill, so a second run changes nothing. A difference exits 2; a skill the manifest does not name is reported and changes the exit code not at all.
<file-or-source> is a local path if one exists there, and otherwise a git
source — owner/repo, a full URL, or scp-form, the same shapes install
accepts minus plugin and OCI, which name no file to read. skills.toml is
always read from that repository's root:
$ satchel sync team/skills-profile
$ satchel sync git@github.com:team/skills-profile.git --ref develop
--ref chooses the profile repository's branch, tag or sha (default: its
HEAD) and is ignored when the argument is a local file. A profile repo that
also hosts the skills it lists shares its mirror and revision cache with
them, so a second sync against an unchanged profile touches neither the
network nor the disk beyond one ls-remote.
All four channels are implemented: git, plugin (name@marketplace),
local (./path) and oci (oci://registry/repo:tag), and link serves
both of its forms. bundle and sync are also implemented, and doctor
reports without a --fix.
One thing the plugin channel deliberately does not do yet: outdated reports a
plugin as stale when claude has moved it since satchel last looked, but it
cannot tell you whether the marketplace has published a newer version.
See the design spec for the full intended surface.
Tooling is pinned in mise.toml and installed with mise:
mise install # go, golangci-lint, goreleaser at the pinned versions
make test
make lint
make tidy-check
make snapshot # build release artifacts locally, into dist/CI installs the same mise.toml, so local and CI tool versions never drift.
Issues and pull requests are welcome. Before opening one, run make test,
make lint and make tidy-check, and write commit messages and PR titles as
Conventional Commits — the release
changelog is generated from them.
AGENTS.md has the full conventions, architecture map and commit rules, for both human contributors and AI agents. CONTRIBUTING.md covers getting set up and the pull request process.
Some of satchel's functionality is inspired by the great
npx skills from Vercel, and by
Homebrew, whose install/update/remove model with a
receipt for every package is the one satchel brings to agent skills.
