-
Notifications
You must be signed in to change notification settings - Fork 0
Home
A package manager for agent skills: manifest for intent, lockfile pinned to commit SHAs, reproducible installs on any machine.
The popular tool in this space (Vercel's npx skills) records installations in
skills-lock.json, but that file stores only a normalized source
(owner/repo) and a content hash of the installed files β no git ref, no
commit SHA, and no separate user-intent manifest. It can detect that a skill
changed, but cannot reproduce an installation. Their own issue tracker
asks for exactly this (issues #283, #549).
Every mature dependency manager solves this with two files:
- a manifest β hand-written, expresses what the user wants;
- a lockfile β generated, pins exact versions (commit IDs for git sources).
Skiletto brings that model to agent skills.
| Decision | Choice |
|---|---|
| Language | Go β single small static binary |
| Distribution | Standalone binary via goreleaser (GitHub releases on v* tags); npm package skiletto (postinstall downloads the release binary) and PyPI package skiletto (platform wheels carrying the binary), publish steps gated on tokens. The command ships under its short name tto and the long alias skiletto, which are interchangeable: the npm bin entries and PyPI console scripts both point at the same launcher, goreleaser archives ship both binaries, and go install offers both too β .../skiletto/cmd/tto@latest builds tto while the root package builds skiletto (Go names the binary after the package path), and go install .../skiletto/...@latest installs both commands at once. |
| Git access | Shell out to system git β user's auth (SSH, .netrc, credential helpers) works for free. Shallow fetch + sparse checkout for cheap subdir installs. Library (go-git) is a possible later swap, not v1 β it has no credential-helper support and brings its own SSH stack, so auth would need reimplementing. |
| Manifest |
skiletto.toml (TOML, hand-editable) |
| Lockfile |
skiletto.lock (TOML, generated, committed to VCS) |
| Harness support | Adapter architecture; v1 ships only the Claude Code adapter |
| Drift policy | Warn and skip; --force overwrites |
A skill is a directory containing SKILL.md inside any git repo β GitHub,
private Gitea, anything system git can clone. No registry, no publishing step,
no extra metadata required. Skills with missing/partial frontmatter are
installed as-is (possibly with a warning); whether a harness accepts them is
the harness's business.
Skill name defaults to its directory name; collisions require an explicit rename in the manifest.
-
Project:
skiletto.toml+skiletto.lockin the repo root. Skills materialize in.agents/skills/<name>. -
Machine (global): manifest + lock in the platform config dir
(
~/.config/skiletto/on Linux,os.UserConfigDirequivalents elsewhere;HOMEandXDG_CONFIG_HOMEare honored on every platform). SettingSKILETTO_CONFIG_DIRoverrides both: it names the directory holding the manifest and lock directly, noskiletto/subdirectory appended. Skills materialize in~/.agents/skills/<name>regardless.
The machine manifest is resolved on every command, whatever the scope: its
harnesses key applies in every project (see Harness enablement).
The machine scope is always explicit. Running without --global in the
home directory is an actionable error rather than a project rooted at ~:
such a "project" would put its skills in ~/.agents/skills β the machine
scope's canonical dir β and the two scopes would silently share state. The
check compares directory identity (os.SameFile), so a symlinked home
cannot dodge it.
- Resolve the source ref to a commit SHA, fetch, and materialize the skill
directory in the canonical location for the scope (
.agents/skills/or~/.agents/skills/). This step is unconditional β the canonical dir is the install, and harnesses that read.agents/skills/natively (the majority in Vercel's registry: cursor, codex, opencode, zed, β¦) are covered with no links at all. - Each enabled harness adapter links individual skill directories (not the whole skills dir) into its expected location. Adapters that are registered but not enabled have their links removed on the same pass (only links skiletto can prove its own), so flipping the config and syncing converges.
Adapter = knows the harness's skill directories per scope + how to link +
how to detect the harness (config dir exists β seeds the picker only,
never enables anything by itself).
v1 adapter: claude β symlinks into .claude/skills/ (project) and
~/.claude/skills/ (machine).
Linking is opt-in intent, recorded as a top-level harnesses = [...] key in
the manifest (both scopes) β not detection re-run on every command (Vercel's
model, which leaves nothing reproducible in the project). The effective set
for a project command is the union of the project key and the machine
key: the repo commits team intent, the machine manifest adds personal
harnesses in every project. Union means a project can add but not subtract
machine-enabled harnesses; the alias no-op below keeps stray links harmless,
and a negation syntax can be added later if ever needed. Machine-scope
commands use the machine key alone. Unknown names warn and are skipped
(forward compatibility with configs written by newer binaries).
When neither scope has the key:
- Interactive run (
add/sync/import): a one-time multi-select picker, detected harnesses pre-checked; the answer β even "none" β is written to the operating scope's manifest asharnesses = [...], so it never asks again. The prompt is sugar forskiletto harness enable. - Non-interactive run: install to the canonical dir only, print a one-line
note naming
skiletto harness enable claude, exit 0, persist nothing (a later interactive run still gets the prompt). This deviates from the "no TTY + ambiguous β error" rule deliberately: canonical-only is a safe subset, and erroring would break agent-driven and scripted bootstraps.remove/updatenever prompt (a first-time picker there would be a surprise); they fall back the same way.
Alias no-op: users commonly symlink a harness dir at the directory level
(.claude/skills β ../.agents/skills). Before linking or unlinking, the
link helper resolves both sides (EvalSymlinks); if the link path already
resolves to the canonical skill dir it is treated as linked and nothing is
touched β without this, the adapter would see its own canonical tree through
the user's symlink and refuse (or destroy) it. A foreign real directory in
an enabled harness's dir still refuses as before; in a disabled
harness's dir it is silently left alone (the user never asked skiletto to
manage that harness).
Link strategies (shared link helper): unix uses symlinks only. Windows
tries symlink (needs Developer Mode) β directory junction β copy. The
strategy is derived at inspection time, never persisted: symlinks and
junctions self-identify as reparse points; a copy counts as ours only while
it hash-matches the canonical tree (a diverged copy is a local modification β
refused without --force). Pristine copies update/sync/remove exactly like
symlinks (links are cleared against the old canonical tree before the new
version is promoted). Editable installs need a live link, so they fail with
guidance on a copy-only filesystem.
Skills already present but not in the manifest are ignored (shown by
skiletto list, never touched).
Two distinct intents, both using a filesystem path as source:
-
Editable (
skiletto add --editable <path>, primary use case: developing a skill in a local clone and installing it machine-wide): the canonical location gets a symlink to the working tree, so edits are live. The lock entry is markededitable = truewith no commit and no content hash; drift checking is skipped β the working tree is expected to change. -
Pinned local (
skiletto add <path>without--editable): the path must be a git repo; treat it like any other git source β resolve the ref to a commit SHA against the local clone, copy the content out, lock the SHA.
Path sources are inherently machine-specific. They are the normal case in the
machine-scope manifest; in a project-scope manifest add warns (once per
invocation) that teammates' sync will fail on them. Relative paths are
absolutized against the invocation cwd at add time; ~/ is stored literally
and expanded at use time.
skiletto.toml:
harnesses = ["claude"] # optional: harnesses whose dirs get per-skill links
[hooks] # machine scope only: pre-install gate (see Pre-install hook)
pre-install = 'skillspector scan --no-llm "$SKILETTO_SKILL_DIR"'
[skills]
pdf = { source = "https://github.com/anthropics/skills", path = "skills/pdf", ref = "main" }
deploy = { source = "ssh://gitea@git.kumekay.com:30009/ku/skills.git", path = "deploy" }
my-skill = { source = "~/p/my-skills", path = "my-skill", editable = true }-
sourceβ anything git can clone (stored as a full canonical URL) or a local filesystem path. CLI shorthands (owner/repoβhttps://github.com/owner/repo) are expanded byaddbefore writing;skiletto.tomlandskiletto.locknever contain shorthands or provider special cases. -
pathβ subdirectory of the skill inside the repo; omitted means repo root (the single skill is discovered wherever it lives). An explicit"."pins the skill to the source root itself:SKILL.mdmust exist there, and nested skills elsewhere in the repo are ignored rather than making the entry ambiguous. A root skill's content is the whole repo tree, so its drift hash covers every file in the repo β any change anywhere in the source shows up as drift. -
refβ branch, tag, or SHA; omitted means the repo's default branch. -
editableβ local path sources only; link the working tree instead of copying a pinned commit (see Local skills). -
harnesses(top-level) β harness adapters whose link dirs this scope populates. Absent = not yet configured (one-time prompt / canonical-only fallback); explicit[]= canonical dir only. See Harness enablement. -
[hooks](top-level table) β hook commands; honored only in the machine-scope manifest, and the only key ispre-install. Unknown keys still parse (a manifest from a newer skiletto must not brick read-only commands) but fail every install-type command, so a typo cannot silently disable the gate. See Pre-install hook.
skiletto.lock:
version = 1
[[skill]]
name = "pdf"
source = "https://github.com/anthropics/skills"
path = "skills/pdf"
ref = "main"
commit = "8c1f2ab90d3e4f56a7b8c9d0e1f2a3b4c5d6e7f8"
hash = "sha256:β¦" # content hash of the installed tree, for drift detection| Command | Behavior |
|---|---|
skiletto add <source> [--global] [--editable] [--all] [--skill <name>]... |
Add to manifest, resolve, lock, install, link. --editable requires a local path source. Multi-skill source without //path: interactive multi-select on a TTY, --all installs everything, --skill <name> (repeatable, mutually exclusive with --all) installs the named skills without prompting, otherwise an actionable error. --skill composes with //path, which narrows the search root; a name matching nothing (or several skills) is an error listing the choices. Relative path sources are absolutized against the invocation cwd at add time (~/ stays literal). |
skiletto sync [--force] |
Make installed state match the lock exactly; resolve and lock anything unlocked. Never moves already-locked versions β including manifest entries edited while the installed tree is drifted (warn/skip without --force). Prunes only what it removes from the lock itself (in lock, gone from manifest) β and refuses to delete a drifted skill without --force. |
skiletto update [name] [--force] |
The only command that re-resolves refs and rewrites lock entries. No name = all. Editable entries are skipped with a note; drifted trees are skipped (exit 1) without --force. |
skiletto remove <name> [--force] |
Remove from manifest + lock, unlink, delete materialized copy. Editable: only the canonical link is removed, never the worktree. Drifted: refused without --force. |
skiletto list |
Managed skills (name, source, pinned commit or editable, status: ok / drifted / missing / not-locked), lock-only orphans as pruned on next sync, and unmanaged skills found in the canonical dir or any adapter dir. Observes only; always exits 0. |
skiletto import [path] [--global] [--force] |
Bootstrap skiletto.toml from an existing Vercel skills-lock.json (one-way): read only the current version 3 lock format β older versions are rejected, since Vercel wipes any lock below the current version, so none exist on disk. The lock stores skillPath as the SKILL.md file, whose directory import strips back out β a repo-root skill becomes path ".", pinning the source root even when the repo also contains nested skills. Then map each entry to a canonical source (github and git sourceTypes; local, well-known, and others fail per-entry with guidance), resolve the default branch HEAD to a SHA, write a fully pinned skiletto.lock, install and link. Partial-but-honest: failures are reported per entry, exit non-zero. Existing manifest names are skipped; trees import cannot prove pristine are refused without --force; real dirs left in adapter dirs by npx skills are never touched (error names the dir and the rm -r remedy). |
| skiletto harness list | Registered harnesses, where each is enabled (project / machine), its link dir for the scope, and whether it is detected. |
| skiletto harness enable <name>... [--global] [--force] | Validate names against the registry, add to the scope manifest's harnesses key, and link every installed skill immediately. |
| skiletto harness disable <name>... [--global] [--force] | Remove from the scope's key and unlink every installed skill. Disabling a name the scope never enabled is an error; if the harness stays enabled machine-wide, a warning says the links stay (union semantics). |
All commands take --global/-g (machine scope), --no-input, and
--verbose/-v (root-level persistent flags). Verbose prints extra
diagnostics to stderr β a line for each pre-install hook run, naming the
skill and the event; it claims the -v shorthand, so --version is
long-only. The commands that can install new content β
add, sync, update, import β also take --no-hooks to skip the
pre-install hook for one run.
Installing commands (add, sync, update, import) show per-skill
progress on stderr while slow git operations run: a transient
<name>: resolving⦠/ <name>: fetching⦠line updated in place, then a
persistent <name>: installed line. Progress renders only when stderr is
a terminal and neither --no-input nor a set CI env var forces the
non-interactive path; piped and scripted runs keep today's plain (silent)
output. On Windows, virtual-terminal processing is enabled for the
console first; a legacy conhost that rejects it gets the plain output
rather than literal escape fragments. Errors, notes, and hook output are routed through the renderer so
they never land mid-way through a transient status line.
Source spec for add: <repo>[//subdir][@ref], e.g.
skiletto add github.com/anthropics/skills//skills/pdf@main.
A pasted GitHub browser URL
(https://github.com/owner/repo/tree/<ref>[/<path>], or a /blob/ URL
pointing at a file β a pasted SKILL.md link β which maps to the file's
directory, a root-level file pinning the source root as path ".") is
normalized to the canonical repo//path@ref form at add time, like the
other CLI shorthands. Browser residue is tolerated: a
trailing #fragment/?query is dropped, and an @ inside a path
segment (skills/@scope/pkg) is not mistaken for a ref separator. The
ref is taken to be the single segment after /tree/ β a ref containing
/ cannot be split from the path without asking the remote, so when the
guessed ref does not resolve, the error (applied in the engine at the
failed resolve) suggests the explicit repo//path@ref form. Combining a
/tree/ URL with an extra @ref or //path is rejected as
contradictory.
Symlinks inside a fetched source are recreated as symlinks, never
followed: a link may target a directory (the harness-mirror layout,
.agents/skills/x β ../../skills/x) or dangle, so dereferencing would
fail or duplicate content. Discovery does not traverse symlinks, so
mirror links never surface as duplicate skills. The drift hash treats a
symlink as its target string (git's model): the hash stays a pure
function of the tree, so a skill whose links point outside its own
subtree hashes identically staged and installed, and sync converges. A
//path that is itself a symlink resolves to its target inside the repo
(the sparse cone is widened accordingly); a link escaping the repository
is an error.
Non-interactive is the contract; interactive is sugar on top (the gh CLI
model):
- Every piece of information a prompt can collect has a flag or argument equivalent β scripts and CI never need a TTY.
- Prompts appear only when stdin/stdout are a TTY and the invocation is
ambiguous. Canonical case:
skiletto add owner/repoon a repo containing several skills β multi-select picker;--allinstalls everything,--skill <name>picks by name,//pathpicks one explicitly. - No TTY + ambiguous invocation β exit non-zero with an actionable error listing the available skills and the exact syntax to script the choice.
-
--no-inputforces the non-interactive path even in a terminal; a setCIenv var implies it. - One deliberate exception: an unconfigured
harnesseskey without a TTY does not error β the command proceeds canonical-only with a note (see Harness enablement). Errors are for ambiguity where any guess could be wrong; canonical-only is always safe.
sync compares the lock's content hash with the installed tree. On mismatch:
warn, leave the files alone, continue with other skills, exit with a warning
status. sync --force restores the locked version. The same guard protects
every other path that would replace a drifted tree: manifest entries edited
while drifted, update, remove, and import all warn/skip (or fail the
entry) without --force. Editable entries are never drift-checked. On
Windows, a copy-linked adapter dir that no longer hash-matches counts as a
local modification under the same rules.
An optional command that gates content entering the lock β the natural
place for a security scanner such as NVIDIA's SkillSpector. Configured as
pre-install under [hooks] in the machine-scope manifest only.
Contract:
- Runs after the skill's content is staged, before it is hashed, the existing install is unlinked, the staged tree is promoted, or the lock is written. A failing hook therefore aborts with nothing changed: the previously installed version (if any) stays installed and linked. (Import distinguishes hook rejections from other install failures for exactly this reason: only the latter have partial state to clean up.)
- Hashing after the hook means a hook that rewrites staged content (a sanitizer or formatter) locks the hash of what is actually promoted β no phantom drift.
- The staged directory is exported as
SKILETTO_SKILL_DIR; the command references it itself ("$SKILETTO_SKILL_DIR";%SKILETTO_SKILL_DIR%on Windows). It is deliberately not appended as an argument: splicing a path into a shell string cannot be made robust (a trailing;detaches it; cmd.exe expands%in paths).SKILETTO_SKILL_NAME,SKILETTO_SOURCE,SKILETTO_COMMIT, andSKILETTO_EVENT(add,update,sync, orimport) are also set. The command runs throughsh -c(cmd.exe /S /Con Windows), so it can carry flags and env-var prefixes. Its stdout/stderr stream through to the user. - Exit 0 β install proceeds; any other exit β the install is aborted with
an error naming the skill and pointing at
--no-hooks. - The gate fails closed: an unreadable machine manifest or an unknown hook name aborts install-type commands instead of proceeding unscanned.
When it runs:
-
add,update,import: always (new content enters the lock). -
sync: only for manifest entries that are not locked yet. Materializing already-locked content is not gated: the lock's content hash β whichsyncverifies β guarantees the bytes are exactly the ones scanned when the entry was locked, and re-scanning every sync would make it slow and nondeterministic (a scanner rule update could brick a reproducible install halfway through). - Editable skills: never. Their working tree changes after any scan, so a scan-at-install-time guarantee cannot hold for them.
Trust model: a hook is arbitrary command execution, so only the
machine-scope manifest β the user's own dotfile, never checked into a
project β may define one, and it applies in every project. A [hooks]
table in a project skiletto.toml is ignored with a warning: honoring it
would hand every cloned repository code execution on sync and let it
replace the user's scanner with a no-op. Project-level hooks would need a
trust/confirmation mechanism first; attestation (recording scanner verdict
- version in the lock) is a possible future hardening, deliberately not built now.
- No registry or publishing.
- No skill-to-skill dependencies.
- No bundled git implementation.
- No adopting/managing pre-existing unmanaged skills (list them only).
- No interop with
npx skillsbeyond one-wayimport.
-
Symlinked skill dirsβ verified 2026-07-03: Claude Code loads a skill whose individual directory in.claude/skills/is a symlink (tested headless against a real-dir control). Whole-dir symlinks (~/.claude/skills β ~/.agents/skills) also known to work. Multi-level chains (adapter symlink β canonical symlink β editable working tree) should resolve at the filesystem level but haven't been explicitly tested. -
Windowsβ resolved 2026-07-04: symlink β junction β copy chain in the shared link helper (see Install model), exercised by a dedicated Windows CI job including a forced-junction and forced-copy e2e. Note: Go 1.23+ does not report junctions as symlinks, so link inspection routes through reparse-point-aware helpers. -
Whole-repo skillsβ works;pathis simply omitted. -
Sparse/shallow git flagsβ implemented with capability detection and a full-fetch fallback; hermetic tests run against local repos.
skiletto/
βββ main.go # tiny: calls internal/cli (root main β go install github.com/kumekay/skiletto@latest β `skiletto`)
βββ cmd/
β βββ tto/main.go # same entry point, short alias (go install .../cmd/tto@latest β `tto`)
βββ internal/
β βββ cli/ # cobra commands β thin: parse flags, call engine, format output
β βββ engine/ # orchestration: diff(manifest, lock, disk) β Plan β apply
β βββ manifest/ # skiletto.toml read/write + CLI source-spec parsing/expansion
β βββ lockfile/ # skiletto.lock read/write
β βββ source/ # Source interface + git and path implementations
β βββ gitcli/ # system-git wrapper: exec, version/capability detection
β βββ skill/ # SKILL.md discovery in a tree, naming, content hashing
β βββ scope/ # project vs machine: manifest/lock/canonical-dir paths
β βββ adapter/ # Adapter interface, registry, link helper w/ Windows fallback
β β βββ claude/
β βββ ui/ # Prompter interface: huh-backed TTY impl + non-interactive impl that errors
β βββ vercelimport/ # skills-lock.json reader (isolated, one-way)
βββ packaging/
β βββ npm/ # npm wrapper package (postinstall downloads release binary)
β βββ pypi/ # wheel builder bundling the release binary
βββ .goreleaser.yaml
-
source.SourceβResolve(ref) β commit+Fetch(commit, subpath, dest). Implementations: git (viagitcli) and local path (pinned or editable). Later: other backends without touching the engine. -
adapter.AdapterβSkillsDir(scope),Link/Unlink(skill). Compiled-in registry (a map), no plugin system. New harness = new package + one registry line. Shared link helper owns the symlink/junction/copy decision. -
gitcliboundary β allexec gitcalls behind one small client, so a go-git swap or capability-based fallbacks (old git β full clone) stay local. -
ui.Prompterβ commands never talk to a prompt library directly; the non-TTY implementation returns the actionable error. Enforces the "prompts are sugar for flags" contract structurally.
engine computes a Plan (list of actions: fetch, materialize, link,
unlink, prune, warn-drift) from manifest + lock + disk state, then applies
it. Buys --dry-run for free, makes list/status the same diff rendered
instead of applied, and lets tests assert on plans against fake
sources/adapters without touching the network or filesystem.
Lockfile format, the canonical store layout, and TOML parsing stay concrete β single implementations, no interfaces. Dependencies stay minimal: cobra, a TOML library, and (later, for pickers) charmbracelet/huh.
All v1 milestones shipped (2026-07-04):
-
(#9)add+sync, project scope, canonical dir + claude adapter -
Machine scope ((#10)--global) -
(#12)update,remove,listwith drift status -
(#13)importfromskills-lock.json -
Distribution: goreleaser β GitHub releases, npm wrapper, pip wrapper(#14; publishing awaits the firstv*tag +NPM_TOKEN/PYPI_TOKENsecrets)
Plus: interactive picker with --all/--no-input (#15), Windows link
fallback + Windows CI (#16).