Skip to content

Instantly share code, notes, and snippets.

@diegohb
Last active August 22, 2026 19:14
Show Gist options
  • Select an option

  • Save diegohb/e6a26a4027fc624a45f0f6f862bc1457 to your computer and use it in GitHub Desktop.

Select an option

Save diegohb/e6a26a4027fc624a45f0f6f862bc1457 to your computer and use it in GitHub Desktop.
prefer-global-skills: Based on skills.sh installed skills via npx skills add, will remove local skills that are newer at global

description: Prefer globally installed skills over local ones. Reconcile the repository-local skill lock against the global one and remove local duplicates. agent: general subtask: true meta: gist-source: "https://gist.github.com/diegohb/e6a26a4027fc624a45f0f6f862bc1457"

Goal

Keep one shared version of each skill by preferring the global install. If a skill exists in both local and global locks, remove the local duplicate.

Background — file layout and schema

skills CLI versions vary in lock-file names and per-entry fields. These instructions handle that by probing both lock names and matching only by skill-name keys.

Invariant schema

{
  "version": <number>,          // observed: 1 or 3 — preserve whatever is present
  "skills": {
    "<skill-name>": {            // per-entry fields vary (computedHash | skillFolderHash,
      "source": "...",           //   sourceUrl, installedAt, updatedAt, pluginName...).
      "skillPath": "...",        //   DO NOT depend on these — match by name key only.
      ...
    }
  }
}

Lock file locations (probe in order, use the first that exists)

  • Local lock: skills-lock.json in the repository root → fallback .agents/skills-lock.json → fallback .agents/.skill-lock.json.
  • Global lock: ~/.agents/skills-lock.json → fallback ~/.agents/.skill-lock.json.

Skill directories

  • Local: .agents/skills/<skill-name>/
  • Global: ~/.agents/skills/<skill-name>/ (read-only reference; never modify).

Procedure

Perform these steps in order. Do not improvise file paths or names.

  1. Resolve paths. Expand ~ to the user's home directory for global paths. Do not assume a specific OS shell.

  2. Locate the local lock file by probing the local locations above, in order.

    • If none exists → stop and report: "No local skills lock file found (checked skills-lock.json, .agents/skills-lock.json, .agents/.skill-lock.json); nothing to reconcile."
  3. Locate the global lock file by probing the global locations above, in order.

    • If none exists → stop and report: "No global skills lock file found (checked ~/.agents/skills-lock.json, ~/.agents/.skill-lock.json); cannot reconcile. Install skills globally first with npx skills add --scope global."
  4. Parse both files as JSON. If either parse fails, stop and report the error verbatim with the file path. Do not repair corrupted JSON.

  5. Build two sets of skill names from the keys of the skills object:

    • localNames = keys of local.skills
    • globalNames = keys of global.skills
    • If a file has no skills object, treat it as an empty set and report that.
  6. Compute toRemove = localNames ∩ globalNames (skills present in both).

    • If toRemove is empty → stop and report: "No local skills duplicated in the global lock; nothing to remove."
  7. Capture SKILL.md mtimes — READ-ONLY, before deletion (step 9). Step 9 removes local SKILL.md files, so capture mtimes now. For each skill in toRemove, generate two listings keyed by skill name (local and global) using the procedure below. Store as localMtimes and globalMtimes. This step does no writes.

    ⚠️ mtimes are unreliable for freshness. SKILL.md mtime reflects when the file was extracted to disk, which tracks installation order, not source repo freshness. Two installs at the same git commit get different mtimes (the earlier install wins the earlier mtime). Always use the GitHub commit comparison from step 8 for the regression gate; mtimes are captured only for the report table.

  8. Fetch GitHub commit hashes for each skill — authoritative freshness signal. For each skill in toRemove, query the GitHub API for the latest commit that touched the skill's skillPath in the source repo. Store the result as githubCommits[skillName] = { sha, date }. This step is read-only and network-bound.

    API call per skill:

    GET https://api.github.com/repos/{owner}/{repo}/commits?path={skillPath}&per_page=1
    

    Extract from the response:

    • sha: full 40-char commit SHA (shorten to 12 chars for display)
    • date: commit.committer.date — the authoritative last-modified instant of this skill's content in the source repo

    If the API call fails (rate limit, network error, repo not found), record { sha: "error", date: null } for that skill and continue. A failed fetch for some skills does not stop the procedure; those skills appear as ⚪ unknown in the report.

    Use the GitHub commit SHA from the global lock's skillFolderHash (if available) or the local lock's computedHash as a fast-path equality check before making the API call:

    • If local.computedHash === global.skillFolderHash (or equivalent content-hash equality), both installs are at identical content — record SHA as "same" and skip the API call for this skill.
    • If the hashes differ, make the API call to determine which commit is newer.

    Build two lookups from this data:

    • localGithubCommit[skillName] — the commit sha/date this skill's local content corresponds to (use local.computedHash + GitHub API if needed).
    • globalGithubCommit[skillName] — the commit sha/date this skill's global content corresponds to (use global.skillFolderHash + GitHub API if needed).

    If a lock entry lacks skillFolderHash/computedHash (e.g., schema version 1 without a hash field), fall back to the API call using the skill's skillPath.

  9. Run a regression preflight check (report + gate), using GitHub commit dates.

    Why commit dates, not mtimes? Two installs at the same git commit produce different SKILL.md mtimes because the earlier installation wins the earlier mtime. Mtimes track installation order, not source freshness. Commit dates from GitHub API track actual content changes in the source repo — the only signal that determines whether global is older than local.

    • comparablePairs: skills in toRemove where both localGithubCommit[skill].sha and globalGithubCommit[skill].sha are known and not "error". This includes "same" SHA pairs — identical content is a valid, meaningful comparison.
    • regressionPairs: comparablePairs where globalGithubCommit[skill].sha is strictly older than localGithubCommit[skill].sha (i.e., the SHA strings differ and the global commit date is earlier). "same" SHA pairs are never regressions because identical content cannot be older or newer than itself.
    • If comparablePairs is non-empty and regressionPairs.count > comparablePairs.count / 2, stop before deletion and report: "Stopped: most comparable duplicated skills have a newer local commit than the global one; reconcile global installs first (npx skills add --scope global )."

    Note: A skill where both installs are at the same GitHub SHA (localSha === globalSha) is never a regression, because identical content cannot be older or newer than itself — regardless of installation timestamps. The step-9 gate cannot fire on same-SHA pairs.

  10. For each skill in toRemove, remove it from the local install with per-skill atomicity:

    1. Delete the local skill directory .agents/skills/<skill-name>/ if it exists.
    2. Delete the key from local.skills.
    3. Verify both: the key is gone from the in-memory local.skills, and the directory no longer exists (or was already missing). If verification fails for a skill, stop immediately and report exactly which skill failed and which check failed.
  11. Write the updated local lock file to the same path read in step 2. Serialize with 2-space indentation and a trailing newline, preserving the original version field and other top-level keys. Never minify; avoid unnecessary reordering.

  12. Leave local-only skills untouched. Any skill in localNames but not in globalNames must remain in the file and on disk. Do not delete it.

  13. Never delete:

    • The local lock file itself (even if skills becomes empty — write {"version": <original>, "skills": {}} instead).
    • Any path outside .agents/skills/ (besides rewriting the lock file).
    • The global lock file or any global skill directories (global is read-only).
  14. Idempotency. Running this command again must be a safe no-op: once a duplicated local skill is removed, subsequent runs simply find nothing in toRemove.

  15. Report — produce the two-part report described in the Reporting section below, using the localMtimes captured in step 7 and the localGithubCommit / globalGithubCommit from step 8.

Reporting

After execution, return: (A) a changes summary and (B) a per-touched-skill freshness table.

A. Changes summary

  • Removed (name — was duplicated globally), with the local lock path that was rewritten.
  • Kept local (name — not present globally).
  • Counts of each, plus the resulting number of entries in the local lock file.

B. Freshness comparison for each skill touched (removed)

Use the GitHub commit data from step 8 for the authoritative comparison. The localMtimes / globalMtimes are shown alongside for informational purposes when both installs are at the same SHA.

For every skill in toRemove, render one row per skill:

| Skill | Local commit | Global commit | Local SKILL.md mtime | Global SKILL.md mtime | Status | |---|---|---|---|---|

Where:

  • Local/Global commit: sha (date) from localGithubCommit[skill] / globalGithubCommit[skill]. Shorten SHA to 12 chars. Use "same" if both installs are at identical content (no SHA shown in that case). Use "unknown" if the GitHub API fetch failed.
  • Local/Global SKILL.md mtime: from localMtimes[skill] / globalMtimes[skill] — shown for all skills. When SHA is "same", the mtime delta tells you which install ran first (the earlier mtime = installed first). This is informational only — identical SHA means identical content regardless of mtime.
  • Status:
    • globalSha === localSha → 🟢 safe — same source commit; the mtime columns show which was installed first (global with earlier mtime = installed first).
    • globalGithubCommit[skill].date ≥ localGithubCommit[skill].date → 🟢 safe (global is at same or newer commit).
    • globalGithubCommit[skill].date < localGithubCommit[skill].date → 🔴 regression (global is at an older commit; removing local loses newer content).
    • Either SHA is "error" or "unknown" → ⚪ unknown (could not determine).

Highlighting. For any row where globalGithubCommit[skill].date < localGithubCommit[skill].date, color the row red with <span style="color: red">…</span> and include both commit SHAs and dates plus the delta in the Status cell, e.g. 🔴 REGRESSION: global abc123 (2026-07-01) < local def456 (2026-08-05). If the host renders plain Markdown only, the 🔴 REGRESSION: prefix is the fallback signal.

After the table, if any row was flagged as a regression (🔴), add a prominent note: "⚠️ N skill(s) have a newer local commit than the global one; removing the local copy reverts to the older global version. Consider re-installing those globally (npx skills add --scope global <name>) before relying on the global version."

If any row has "same" SHA, add an informational note: "ℹ️ N skill(s) have identical source commits but different SKILL.md mtimes, meaning they were installed at different times. The global copy (earlier mtime) was installed first; the local copy is a redundant duplicate of the same content."

Diagnostic: why mtime comparison is unreliable

SKILL.md mtime tracks when the file was extracted to disk, which follows installation order — not source repo freshness. In the bug this fixes: local and global were both at git commit 84fdeffd12f2 (identical content), but global was installed ~2 min before local, so global's SKILL.md mtime was ~2 min earlier than local's. An mtime-based gate would report a regression where none exists. The GitHub API commit date (commit.committer.date) is the only reliable freshness signal because it tracks when content actually changed in the source repo.

Safety rules

  • Operate only on the local lock file (whichever variant was found) and .agents/skills/<skill-name>/ within the current repository, plus read-only access to the global lock and global skill directories.
  • Removal membership is by name key only (toRemove = localNames ∩ globalNames). GitHub commit comparisons (step 9) gate the preflight check and report, but never block removal — the goal is de-duplication, not version pinning.
  • If a skill name appears locally but its directory is missing on disk, still remove the stale lock entry and note it in the report.
  • If a directory exists under .agents/skills/ but has no lock entry, leave it alone (do not infer or invent lock entries).
  • If the step-9 majority-regression condition is met, stop before deletion and inform the user. Use the GitHub commit SHA comparison, not SKILL.md mtime — see the diagnostic note in the Reporting section for why.
  • Do not touch .git, dependencies, or any non-skill files.

Path Hints

Windows

  • Use os.path.expanduser('~') in Python or os.homedir() in Node.js to expand ~.
  • $env:USERPROFILE may not be reliable for all users; prefer the above methods.
  • $environment variables like %USERPROFILE% may not be reliable for all users; prefer the above methods.

Linux / macOS

  • Use os.path.expanduser('~') in Python or os.homedir() in Node.js to expand ~.
  • $HOME is usually reliable, but prefer the above methods for cross-platform consistency.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment