skill-sync
Sync Claude Code skills between local .claude/skills/ and a remote skills repository.
Terminology: which repository this tool talks to
A project that installs this tool may sync against two different upstream repositories, and calling either one "canonical" makes an instruction about one of them read as an instruction about the other. This has already cost a round trip: an agent authorised to "push to the publish repo, do not push to canonical" read the two names as one repository, judged the two clauses mutually exclusive, and stopped. This document and this tool's code therefore never use "canonical" for the repository below.
| Repository | What it holds | How it syncs | Named here as |
|---|---|---|---|
| tarrragon/claude-skills | Published, shareable skills | This tool: skill-sync push / pull | publish repo / 發佈庫 |
| tarrragon/claude | A project's whole .claude/ framework | A separate project-level script, not this tool | framework canonical |
Everything below refers to the publish repo unless it says framework canonical. This tool never touches the framework canonical repository.
Before you push: resolve direction first
Push is not the first step. The default order is:
- Determine direction. Run
skill-sync pullwith no name for a report across every installed skill, orskill-sync push <name>and read its preview without confirming. Either one tells you whether the remote has moved. - If the remote is ahead, pull first. Pushing over a remote that moved discards whatever moved it, and the push preview cannot tell you what you are about to overwrite — it lists files, not the reasoning behind their contents.
- After pulling, check your local-only changes survived. A pull overwrites the local copy wholesale. Any edit you made locally and never pushed is gone at this point, and nothing announces it: the pull reports files updated, not intent lost. Compare against what you know you changed.
- Restore anything the pull overwrote, then push.
This is a procedure, not a gate: nothing in the tool enforces it, and no step here blocks a push. It is written down because skipping step 3 is the failure that actually happens. A real run of these four steps on skill-design-guide pulled 1.11.1, found a local backtick correction that the pull had overwritten, restored it, and pushed 1.11.2 — the operator was not looking for that conflict and did not know it was being tracked separately at the time. The order caught it anyway, which is the argument for following it by default rather than only when a conflict is already suspected.
Installation
uv tool install --from .claude/skills/skill-sync skill-sync
Commands
| Command | Description |
|---------|-------------|
| skill-sync pull | Compares every installed skill against the remote manifest by content hash and prints what diverges, grouped by direction ([SHOULD PULL] / [SHOULD PUSH] / [CONFLICT] / [DIVERGED] / [SUSPECT REVERT], see below), along with the pull / push command for each. --force has no effect on this path. One side effect: for any skill already matching the remote (up to date), it re-records .skill-sync-base when that marker is missing or stale — this is the only file this path ever writes, and only when local already equals remote |
| skill-sync pull <name> | Copy the remote copy of one skill over .claude/skills/<name>/. Preview lists each modified file with its added/removed line count, and prints a [WARNING] to stderr when a recorded sync base shows local, not remote, moved since the last sync (pulling would overwrite the local-only change). Files that exist only locally are kept. Prompts before applying unless --force. On success, records the post-pull content hash as this skill's sync base |
| skill-sync push <name> | Copy one local skill to the remote repo. Preview lists each modified file with its added/removed line count, and prints a [WARNING] to stderr when a recorded sync base shows remote, not local, moved since the last sync (pushing would overwrite the remote-only change), or when the version string is unchanged despite content having drifted from the recorded sync base. When the local skill has no recorded sync base of its own (never pushed or pulled through this CLI before), also checks whether local content matches an older, non-latest historical commit of that skill in the publish repo — a [WARNING] names the matching commit, since pushing it would overwrite newer publish-repo content with a stale copy the usual base-comparison warning above has no basis to catch (see Notes). Prompts before applying unless --force, which for push has three effects: skips the confirmation prompt; bypasses the portability gate below; and removes the confirmation stop that would otherwise follow any [WARNING] above — the checks still run and still print, --force only takes away the reader's forced pause, restated as a summary line and logged to .claude/hook-logs/skill-sync-divergence-force.jsonl (see Notes). Files that exist only on the remote are kept; -m sets the commit message. Also prints a [BannedTerm] report to stderr, reporting only, when a language-constraints.md banned term (規則 2 — e.g. 文檔/代碼/默認) shows up on a line the push actually adds or changes in a .md file — an existing line the push leaves untouched never triggers this, so pre-existing debt elsewhere in the same file is not re-reported on every unrelated push. On success, records the local content hash as this skill's sync base |
| skill-sync push <name> --prune | Push, and delete files that exist only on the remote. Use after deleting or renaming files locally, otherwise the removal never reaches other consumers |
| skill-sync list | List the remote repo's top-level entries with the first line of each SKILL.md. Non-skill entries at the root (README.md, versions.json) appear too, with an empty description |
A content hash alone proves two copies differ, not which one is newer — that half still cannot be inferred. What changed: pull <name> and push <name> now leave behind a .skill-sync-base marker recording the content hash at that sync point (excluded from every comparison and from the content hash itself, same treatment as .skill-sync-override). The next skill-sync pull (no name) reads it back and, for a skill that has one, resolves direction from three points instead of two: local unchanged since that hash but remote isn't -> [SHOULD PULL]; remote unchanged but local isn't -> [SHOULD PUSH]; both moved independently -> [CONFLICT] (still needs manual comparison, same as before). A skill with no recorded base — never synced through this mechanism yet — falls back to the original [DIVERGED] output with both commands listed and no direction claimed. Version strings are still shown for reading, never compared: two copies that evolved separately can carry the same version number and different content.
A [SHOULD PULL] classification only means "remote moved since local's last known point" — it says nothing about whether that move was forward. If the party who moved remote had no .skill-sync-base marker of their own (never synced this skill through this CLI before), their push carries no direction check at all, so a stale copy pushed over newer publish-repo content lands silently, and everyone else's [SHOULD PULL] recommendation would, if acted on, pull that regression back down. skill-sync pull (no name) closes this by checking, only for [SHOULD PULL] candidates, whether the remote's current content matches an older commit of that skill in the publish repo's own git history (a one-time, targeted git log/git rev-parse per candidate — see Notes for cost) rather than genuinely new content; a match reclassifies the entry as [SUSPECT REVERT] and names the historical commit it matches, so pulling it is a deliberate choice rather than a trap disguised as routine advice.
Configuration
| Environment Variable | Default | Description |
|---------------------|---------|-------------|
| SKILL_SYNC_REPO | https://github.com/tarrragon/claude-skills.git | Remote skills repository URL |
Notes
project-integration/,hook-logs/,.venv/,__pycache__/,.pytest_cache/,build/and*.egg-infoare excluded from every comparison, and--prunecannot reach them — neither their files nor a directory of theirs left empty- Deletion is opt-in on purpose: keeping remote-only files by default protects upstream content from being wiped by a stale local copy, at the cost of local deletions not propagating until
--pruneis passed pushruns a portability gate immediately after cloning the remote and before any file is copied into that clone. It sat before the clone until the "already exists in the publish repo" note below started costing a clone of its own: the note reads the remoteversions.json, which the push clone already contains, so fetching it separately meant two round trips on every push of a skill with any violation at all — the common case, since framework-bound skills legitimately name.claude/paths. Measured against the publish repo: the separate manifest fetch is a blob-less sparse clone (~1.98s, 388 KB) and the push clone is a--depth 1full clone (~4.78s, 21.5 MB). Reading the manifest out of the push clone removes the extra round trip from every push; the cost is that an abort now happens after the heavier clone rather than instead of it, which is the deliberate trade — aborting is the error path for a skill that declared itself portable while still carrying consumer references, not the path every push takes. A skill whoseSKILL.mdfrontmatter declaresmetadata.portable: trueis aborted when its markdown or Python files name another project's files (any.claude/...path) or carry a project ticket ID (x.y.z-Wn-nnn); the violating file, line and matched text are printed to stderr, and--forcepushes anyway: it prints the same violation list to stdout as well (not just stderr) before recording it to.claude/hook-logs/skill-sync-portability-force.jsonl, so a bypass leaves both an interactive and a persisted record — a caller that only captures stdout still sees it, not just an entry in a log file nobody checks.--force's own--helptext names this and a third effect (below), since by default it reads only as "skip the confirmation prompt." A skill that hasn't declaredmetadata.portable: truestill only gets a one-line count on stderr, never an abort — but if it already exists in the publish repo, that line gains a suggestion to declaremetadata.portable: trueif it is genuinely shared. Existence in the publish repo proves only that the skill was pushed before, not that any other consumer installed it (a consuming project can have as few as a third of the publish repo's skills; framework-only tools likedocorticketare publish-repo members everywhere yet installed nowhere outside the originating project), so it cannot carry abort-level severity — the note is information, not a verdict. The scan itself covers.mdfiles in full and, for.pyfiles, only docstrings and#comments (not general code, where a.claude/...string is often a real runtime path rather than narrative prose). Consumer-specific passages belong inreferences/project-integration/, which the gate does not scan because sync excludes it. Framework-bound tools (ticket, doc, worktree) talk about.claude/paths as their actual subject matter, and a strict default for undeclared skills not yet in the publish repo would freeze them out of sync entirely. Known gap, evaluated and left undetected: a consumer-specific reference phrased in natural language (Chinese prose naming "this project" or an agent name, with no.claude/...path or ticket ID attached) has no stable anchor a regex can match without a high false-positive rate against ordinary narrative text — left to human review when declaring a skill portable- What
metadata.portable: trueguarantees, and what it doesn't: the gate above only scans text — consumer-specific paths and ticket IDs in.mdprose and Python docstrings/comments. It says nothing about whether a skill's Python code can actually run without this framework's shared infrastructure (.claude/lib/, project-specific config files) — a file that unconditionally doesfrom lib import (...)at module load time carries no text a regex can flag, yet crashes withModuleNotFoundErrorthe moment a consumer without.claude/lib/invokes it. Three declared-portable skills hit exactly this: seven Python files acrosstdd,wrap-decisionandcompositional-writingpassed the gate with zero violations while crashing outright in a real consumer project, undetected for two review passes because both reviews read the degrade-gracefully branch of a config-file check and missed the unconditional import above it. The declaration's actual promise is narrower than "runs standalone anywhere": prose is portable in the sense the gate checks; code is only guaranteed not to crash, not to have zero dependencies, not to keep working at full capability. The minimum bar for code: wrap the import intry/except ImportError, set an availability flag, and have the entry point print a[WARNING]naming the missing module and the disabled behavior before returning early — see the seven fixed files for the pattern. One trap in that pattern itself: a bareexcept ImportErroris not sufficient when the failed import's names are also used in module-level type annotations, since Python evaluates those at load time withoutfrom __future__ import annotations—comment-qa-hook.py'sLanguage/ParserFactory/Functionstill needed placeholder objects even after thetry/exceptwas in place, or the crash just moved from the import line to the first function signature naming them - A
.skill-sync-overridefile inside a skill directory declares its local content as deliberate customization.skill-sync pull(no name) then skips that skill when reporting divergence, and the marker itself is left out of the content hash - A
.skill-sync-basefile inside a skill directory records the content hash from the last successfulpull <name>orpush <name>of that skill. It is written automatically (never by hand), excluded from every comparison and from the content hash the same way the override marker is, and never leaves the local machine —pushcannot copy it to the remote, so one consumer's sync history never becomes another's.skill-sync pull(no name) also re-records it for any skill it finds already up to date whose marker is missing or stale — content can reach a skill through channels other than this CLI (a project's own framework-canonical overlay sync script, or a hand edit) without ever touching this file, and a stale marker there makes a later, genuine one-sided remote change look like a[CONFLICT]needing manual comparison instead of the clean[SHOULD PULL]it actually is. This only fires when local content already matches remote, so it can never paper over a real divergence, and it never rewrites a marker that already matches, so a report run over an already-healthy set of skills touches no files at all push --force's third effect: without--force, a[WARNING]about direction (_diverge_warning) or a stale version (_stale_version_warning) is followed by the same[y/N]prompt as everything else, so reading it is unavoidable before the push proceeds.--forcedoesn't skip either check — both still run, both still print to stderr — it only removes that stop, turning a warning that forced a pause into a few lines of stderr ahead of the success message. This was previously silent: the checks looked identical whether or not anyone read them, and the exit code and push outcome gave no signal either way.cmd_pushnow restates, at the end, how many of these warnings were printed on a run that used--force(--force ignored N direction warning(s) above), and appends one line per push to.claude/hook-logs/skill-sync-divergence-force.jsonlrecording which of the two warnings fired — so a bypass leaves the same kind of persisted trail the portability gate's force-log leaves above, and a run with no warnings or without--forcewrites nothing extra[BannedTerm]incremental scan:push <name>scans only.mdfiles, and within those only lines the push adds outright or actually changes (added files: every line; modified files: the samedifflib.SequenceMatcheropcode mechanism the+A/-Rline count already uses, restricted toinsert/replaceranges on the new side). This is a deliberate choice over scanning the whole file: this project's own skills already carried real baseline debt (banned-term lines nobody touched in years) at the time this landed, and a full-file gate would report every one of them on every unrelated push to that file. Reporting only, never blocking — there is nometadata.portable-style declaration to gate severity on, since this is a wording rule, not a portability judgment, so unlike the portability gate below there is no "declared vs undeclared" distinction and no--forceinteraction. Same three exemptions as the underlying hook: a term inside a fenced code block or an inline code span, or on a line carrying<!-- banned-term-exempt: reason -->, is not reported — a fence's open/close state is tracked from the file's first line even though only the flagged range is reported, so a fence opened earlier by an untouched line still correctly covers new lines inside it. The term list and these three exemption rules are a literal copy ofskill-banned-term-scan-hook.py's, not an import (skill-sync ships as a zero-dependency standalone package and cannot depend on.claude/hooks/); a project-level test (.claude/hooks/tests/test_skill_sync_banned_term_sync.py) asserts the two copies stay identicalpush's no-marker revert check: this is the same "content moved, but did it actually advance?" gap[SUSPECT REVERT]closes on the report side, applied at push time instead — a push whose local content has no recorded.skill-sync-basegets no benefit from the base-comparison warnings above (there is no base to compare against), so a stale copy pushed over newer publish-repo content would otherwise go through with zero warning. It shares the same git-history walk[SUSPECT REVERT]uses (clone--filter=blob:none --no-checkout, no--depthlimit), but the comparison differs: local content isn't a git object inside that clone, so instead of comparing tree-object hashes directly, this checks out each historical commit's copy of the skill path in turn and hashes it with the samecompute_content_hashthe rest of this tool uses, comparing against the local hash already computed for the base-comparison warning above (no extra hashing pass). Skipped entirely when a sync base already exists — that case is already correctly handled by the existing three-way direction logic[SUSPECT REVERT]detection cost: for each[SHOULD PULL]candidate only,skill-sync pull(no name) clones the remote repo a second time with--filter=blob:none --no-checkout(no file content, no working tree) to walk that skill's git history and compare tree-object hashes — no blob data is ever downloaded, but the object transfer still covers the publish repo's entire commit graph, not just the one skill's, since git's transport filters operate on the whole repository, not a single path. This scales with the publish repo's total history length, not per-skill file count; at its currentcal's current size this stays fast, and it only runs for skills already flagged[SHOULD PULL], typically a small minority. A clone failure or timeout here is swallowed and treated as "no match" — this check is a refinement on top of the report, not a required step, so its failure must not fail the whole report- A "modified" file's line in either preview reads
~ path/to/file (+A/-R lines), or(binary or unreadable content)when either side can't be decoded as UTF-8 text. The count comes from an actual line-by-line diff, not a total-line-count subtraction — two files with the same line count can still be a complete rewrite of one another, and only the real diff catches that - Consumers outside this CLI should call
sync_status_report(skills_dir)rather than assembling the pipeline from its parts; re-deriving the fetch also re-derives which repo it points at sync_status_reportand_classify_sync_statusaccept an optionalexcluded_skillsiterable. This module has no concept of a project-level "private" policy and never reads one on its own — a caller that has one (e.g. async-skills.yamlprivate:list) supplies the names here so they land in theexcluded_by_policycategory instead ofskipped_remote_missing. Boundary:skill-sync pull(no name,cmd_pull_all) is a standalone CLI path that does not call into any consumer's config file, so it never passesexcluded_skillsand does not benefit from this — that path stays zero-dependency by design- This tool has zero framework dependencies and works in any project
版本紀錄在同目錄的 CHANGELOG.md。