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
| Flag | Description |
|---|---|
--global, -g | Sync the machine-global scope |
--agent, -a | Agent ids to fan out to for this run (comma-separated, or * for all) |
--dry-run | Show what would change without touching anything |
--frozen | CI mode: fail (exit 1) if the lockfile would change or a store skill was edited; never write |
--prune | Remove skills the resolved subscription set no longer contains |
--publish | Publish every locally modified store skill back to its registry |
--bump | Version increment for published skills: patch (default), minor, major |
--discard-modified | Overwrite 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,universalSkills 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.
--publishpublishes all of them without asking.- Without a terminal and without the flag,
syncpublishes nothing. A scripted sync is a local-convergence command, and it never writes to a registry.--yesis 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 booleansdryRun,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? }.incomingVersionis 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 }.statusis one ofpublished,held-for-review,forbidden,failed,skipped, stated rather than inferred from a null tag.codeis 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 }.versionis what this scope installs,publishedVersionis 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.gitignoreblock does not yet cover,syncappends it. Purely additive, and refused outright when there is no well-formed block to extend. Seeinstall.