Skip to content
Sergei Silnov edited this page Jul 4, 2026 · 20 revisions

Skiletto — Design

A package manager for agent skills: manifest for intent, lockfile pinned to commit SHAs, reproducible installs on any machine.

Problem

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.

Core decisions

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
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

Skills

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.

Scopes

  • Project: skiletto.toml + skiletto.lock in the repo root. Skills materialize in .agents/skills/<name>.
  • Machine (global): manifest + lock in the platform config dir (~/.config/skiletto/ on Linux, os.UserConfigDir equivalents elsewhere). Skills materialize in ~/.agents/skills/<name>.

Install model

  1. 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/).
  2. Each enabled harness adapter links individual skill directories (not the whole skills dir) into its expected location.

Adapter = knows the harness's skill directories per scope + how to link. v1 adapter: claude — symlinks into .claude/skills/ (project) and ~/.claude/skills/ (machine).

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).

Local skills

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 marked editable = true with 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.

File formats

skiletto.toml:

[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/repohttps://github.com/owner/repo) are expanded by add before writing; skiletto.toml and skiletto.lock never contain shorthands or provider special cases.
  • path — subdirectory of the skill inside the repo; omitted means repo root.
  • 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).

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

CLI

Command Behavior
skiletto add <source> [--global] [--editable] [--all] 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, otherwise an actionable error. 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): map each entry to a canonical source (github and git sourceTypes; others fail per-entry), 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).

All mutating commands take --global (machine scope) and --no-input (root-level persistent flag).

Source spec for add: <repo>[//subdir][@ref], e.g. skiletto add github.com/anthropics/skills//skills/pdf@main.

Interactivity

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/repo on a repo containing several skills → multi-select picker; --all installs everything, //path picks 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-input forces the non-interactive path even in a terminal; a set CI env var implies it.

Drift

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.

Non-goals (v1)

  • 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 skills beyond one-way import.

Rabbit holes / verify early

  1. Symlinked skill dirsverified 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.
  2. Windowsresolved 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.
  3. Whole-repo skills — works; path is simply omitted.
  4. Sparse/shallow git flags — implemented with capability detection and a full-fetch fallback; hermetic tests run against local repos.

Code layout

skiletto/
├── main.go                  # tiny: calls internal/cli (root main ⇒ go install github.com/kumekay/skiletto@latest)
├── 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

Extension points (deliberate interfaces)

  1. source.SourceResolve(ref) → commit + Fetch(commit, subpath, dest). Implementations: git (via gitcli) and local path (pinned or editable). Later: other backends without touching the engine.
  2. adapter.AdapterSkillsDir(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.
  3. gitcli boundary — all exec git calls behind one small client, so a go-git swap or capability-based fallbacks (old git → full clone) stay local.
  4. 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.

Plan/apply core

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.

Deliberately NOT abstracted

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.

Milestones

All v1 milestones shipped (2026-07-04):

  1. add + sync, project scope, canonical dir + claude adapter (#9)
  2. Machine scope (--global) (#10)
  3. update, remove, list with drift status (#12)
  4. import from skills-lock.json (#13)
  5. Distribution: goreleaser → GitHub releases, npm wrapper, pip wrapper (#14; publishing awaits the first v* tag + NPM_TOKEN/PYPI_TOKEN secrets)

Plus: interactive picker with --all/--no-input (#15), Windows link fallback + Windows CI (#16).

Clone this wiki locally