SkillHub

sync

Reconcile installed skills with the resolved subscription set — apply adds, updates, and removes within declared ranges, and verify hashes.

skillhub sync reconciles the skills installed in a scope with the resolved subscription set — the skillsets and registries you're subscribed to. It applies adds, updates, and removes within your declared version ranges and verifies content hashes.

It runs both ways. Before it resolves anything, sync can publish the store skills you've edited back to the registries they came from — see Publishing what you changed.

Use --frozen in CI to fail if the lockfile would drift or a store skill was edited, and --prune to drop skills the subscription set no longer contains.

Synopsis

skillhub sync [--global] [--agent <ids>] [--dry-run] [--frozen] [--prune]
       [--publish] [--bump <level>] [--discard-modified]

Flags

FlagDescription
--global, -gSync the machine-global scope
--agent, -aAgent ids to fan out to for this run (comma-separated, or * for all)
--dry-runShow what would change without touching anything
--frozenCI mode: fail (exit 1) if the lockfile would change or a store skill was edited; never write
--pruneRemove skills the resolved subscription set no longer contains
--publishPublish every locally modified store skill back to its registry
--bumpVersion increment for published skills: patch (default), minor, major
--discard-modifiedOverwrite or delete store skills that carry local edits

Examples

skillhub sync
skillhub sync --publish            # push every edited store skill, then pull
skillhub sync --publish --bump minor
skillhub sync --frozen   # CI: fail if lockfile drifts
skillhub sync --prune
skillhub sync --agent claude-code,universal

Skills you've edited in the store

Skill directories in the store — .skillhub/skills/<name>/ at the repo root, or the same path under your home directory in the global scope — are yours to edit, and an edit there is protected. Each install records a content baseline for the directory it wrote, so a store skill is either clean (byte-identical to what was installed, and replaced freely) or modified (it carries your edits).

A sync at the version you already have leaves a modified skill byte-identical and reports it under modified instead of unchanged. A run that would replace or delete one — a version bump, --prune, or remove — asks first on a terminal. With nobody to ask (--json, or a piped stdin) it refuses, touching nothing, with exit 1 and error code STORE_MODIFIED naming every skill at risk and the version on offer for each:

2 skill(s) in the store carry local edits and would be replaced:
formatter@1.4.0 (1.5.0 incoming), linter@2.0.0. Copy your changes out of the
store to keep them, or re-run with --discard-modified.

sync --discard-modified restores the published content instead, over every edited skill — including at the version you already have, where there is no update to carry those bytes back in. --yes does not discard: it answers a confirmation with its default, and this one defaults to No, so a script that carries --yes by habit still stops.

The other direction keeps the edit: publish it back to the registry, and the store stops reading as modified because it now matches what was published.

Two boundaries worth knowing. --frozen verifies store content as well as the lockfile, and it reads the store on the machine running it: a fresh CI checkout has no store at all (.skillhub/ is gitignored), so there every skill is missing rather than edited and the content check has nothing to compare. It bites where edits are made — developer machines, and cached or reused CI workspaces. And a copy in an agent skills directory (copyMode skills, the Windows fallback) is rebuilt from the store on every sync that touches the skill, so the store is where a skill is edited: a sync carries a store edit out to the copy, and says so when the copy differed.

Publishing what you changed

The edits a sync protects are also the edits it can ship. A sync that finds modified store skills offers to publish each one back to the registry it was installed from, and does it before it resolves the pull half — so a version that lands with the latest tag is installed by the same run, and one sync closes the loop.

  • On a terminal, the modified skills are listed and you select which to publish. Selecting none is an answer; the pull half runs either way.
  • --publish publishes all of them without asking.
  • Without a terminal and without the flag, sync publishes nothing. A scripted sync is a local-convergence command, and it never writes to a registry. --yes is not permission either — publishing takes the flag that names it.

Nothing is written into SKILL.md. The registry comes from the lockfile entry that installed the skill, the next version is derived from the version that entry records — --bump patch by default, minor or major on request — and the result is recorded in the lockfile, so the second publish needs no more typing than the first. A clean store costs nothing at all: no prompt, no registry read, no write.

Two refusals happen before a single byte is uploaded. If the registry has released a version beyond the one your store holds, sync says so and stops rather than offering a stale tree as the newer one — take the newer version first, or pass an explicit publish --version <semver>. And publish permission is the registry's answer, reported per skill: one skill you can't publish to doesn't stop the others. Each publish is recorded as it lands, the pull half runs whatever the outcomes were, and the run then fails with error code PUBLISH_FAILED — carrying every per-skill outcome and the complete sync result in error.details — so a run with a failed push can never report success.

On a review-required registry the derived version lands untagged, and that withheld tag is the review: an admin moving latest onto it is the approval. It's reported as accepted, held for review — the version is on the registry, reachable by its exact number — and the skill is listed under pendingReview on every run until the tag moves, never as "modified" and never as behind. The number it used is recorded, so no later run re-attempts it. See Review-required flow.

The agent directories a sync writes to

A sync fans skills out to the resolved agent set: --agent for this run, else the agents key in the scope config, else the universal default (.agents/skills). That set can be narrower than the one a skill was installed for — install --agent fans out but writes nothing to the config — and a sync reconciles to it, so the skill's entry is removed from every agent directory outside the set.

The skill itself stays installed: its store directory and lockfile entry are untouched, and only the entry those agents read is gone. Since no skill-level change describes that, each removed entry is reported separately as unlinked (and each newly created one as linked), and a warning names both routes back — config set agents <ids> to change the configured set, or sync --agent <ids> for a single run. --prune is a different axis entirely: it acts on skills the subscription set dropped, never on agent directories.

JSON output

With --json, the data object contains:

  • scope, plus the booleans dryRun, frozen, offline, upToDate.
  • added[], updated[], removed[], unchanged[], orphaned[] — each { ref, name, version, previousVersion? }.
  • modified[] — installed skills whose store content carries local edits, left exactly as found; each { ref, name, version, storeDir, incomingVersion? }. incomingVersion is set when a newer version is on offer, so "edited and behind" reads as one fact.
  • publishes[] — what became of each skill this run published; each { ref, name, status, version, tag, code, message }. status is one of published, held-for-review, forbidden, failed, skipped, stated rather than inferred from a null tag. code is non-null for anything that did not go cleanly, a landed version whose lockfile record failed (PUBLISH_UNRECORDED) included. Always present, [] when the run published nothing.
  • pendingReview[] — skills published from this store without a dist-tag that the subscription hasn't caught up to yet; each { ref, name, version, publishedVersion }. version is what this scope installs, publishedVersion is what's awaiting promotion. Always present, [] when there are none.
  • linked[], unlinked[] — agent-directory entries this run created or removed for skills that stay installed; each { ref, name, agents[], dir }.
  • copyMode[], agents[].
  • gitignore (schema-optional) — { action: extended | unchanged | skipped, reason?: no-managed-block | malformed-block | not-a-git-repo | global-scope | dry-run, added[] }: when a fan-out reaches an agent directory the managed .gitignore block does not yet cover, sync appends it. Purely additive, and refused outright when there is no well-formed block to extend. See install.

On this page