Skip to content
Sergei Silnov edited this page Jul 5, 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>.

The machine manifest is resolved on every command, whatever the scope: its harnesses key applies in every project (see Harness enablement).

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

Harness enablement

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 as harnesses = [...], so it never asks again. The prompt is sugar for skiletto 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/update never 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).

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:

harnesses = ["claude"]   # optional: harnesses whose dirs get per-skill links

[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 (the single skill is discovered wherever it lives). An explicit "." pins the skill to the source root itself: SKILL.md must 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.

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): 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 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.
  • One deliberate exception: an unconfigured harnesses key 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.

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