Skip to content

Sync only the skills this CLI owns to basecamp/skills - #434

Open
jeremy wants to merge 2 commits into
mainfrom
sync-skills-ownership
Open

Sync only the skills this CLI owns to basecamp/skills#434
jeremy wants to merge 2 commits into
mainfrom
sync-skills-ownership

Conversation

@jeremy

@jeremy jeremy commented Sep 13, 2026

Copy link
Copy Markdown
Member

The bug

basecamp/skills is shared by several CLIs, and each one's scripts/sync-skills.sh recorded what it published in the same file, .managed-skills, then deleted every name in that file its own skills/ tree lacked. So the CLIs took turns deleting each other's skills (basecamp/skills#5):

Today the distribution repo holds only the basecamp skills; HEY's is gone.

The fix

  • Per-source manifest. Each publisher owns .managed-skills.<source> at the target root (basecamp-cli, hey-cli, …), one sorted name per line, and reads only its own file to decide what to remove.
  • Removal only within own manifest, with a cross-manifest guard. skills/<name> is removed only when this source's manifest lists it, this release no longer ships it, and no other .managed-skills.* claims it. Two publishers claiming one name is a collision to settle upstream, so it is warned about and left alone. Nothing else ever calls rm -rf on the target besides the per-skill refresh (a name in this source's set, immediately re-copied).
  • No first-run fallback. A target with no .managed-skills.<source> yet removes nothing and writes the manifest from the current set. The old "no manifest, so everything under skills/ is ours" fallback is gone — it is exactly what deleted the sibling's skills.
  • Legacy tombstone. .managed-skills stays but is rewritten on every run as a comment-only file. The pre-fix script validates each line against ^[a-zA-Z0-9._-]+$ and skips the rest, so a CLI still running it deletes nothing; had the file been deleted instead, that script's fallback would have claimed every skills/*. This is what makes the rollout order across CLIs irrelevant.
  • SYNC_SOURCE overrides the source name (bot identity and commit message derive from it) so the test can play the other CLI; SKILLS_TARGET points the script at an existing checkout instead of cloning, and with DRY_RUN=local applies and commits without pushing. The remote-URL and branch asserts and the copy filter are unchanged.

The script is byte-identical to the shared CLI seed's (seed/scripts/sync-skills.sh in basecamp/cli#78 at 5aec9a1) apart from the CLI_NAME default, which is what carries the per-repo SYNC_SOURCE. The seed went three steps beyond the design, and this PR carries them:

  • Publish-side guard. The sync refuses outright (before touching anything) to publish a name another source's manifest holds, not only to delete one.
  • Fetch-first retry. A rejected push — fetch first on a depth-1 clone (the old non-fast-forward match never fires there) or non-fast-forward — drops the stale commit, resets to the remote's new tip and applies the whole sync again, collision guard included, before pushing once more; no rebase of decisions made against a stale tree.
  • Hygiene. SYNC_SOURCE and DRY_RUN are validated, the checkout must be clean before git add -A sweeps it into a commit, and the token reaches git through a private GIT_CONFIG_GLOBAL insteadOf rewrite instead of the clone URL (so it appears in neither argv nor the remote URL, and the remote-URL assert now checks the configured url and pushurl rather than the rewritten view).
  • The script is now identical to basecamp-cli's apart from the default source name (basecamp-cli's gains SKILLS_SOURCE, which this one already had). The pre-existing tests/e2e/sync_skills.bats stays (default skills/ discovery from the working directory is the one contract the seed test does not exercise), with its two Found … assertions adapted to the script's new line.

Migration walkthrough

A clone of basecamp/skills main as of today, then the sibling basecamp-cli branch's script run against it, then this branch's script run against the result, both with SKILLS_TARGET + DRY_RUN=local (apply and commit, no push):

=== BEFORE (basecamp/skills main @ 42716d7) ===
skills
skills/basecamp
skills/basecamp-doctor
--- .managed-skills
basecamp
basecamp-doctor

=== new basecamp-cli script (SKILLS_TARGET, DRY_RUN=local) ===
Syncing into /private/tmp/claude-501/-Users-jeremy-Work-basecamp-hey-sdk/d14b77af-17c6-45e5-a24e-93794a00ec89/scratchpad/skillswork/migration2
Copying skills into /private/tmp/claude-501/-Users-jeremy-Work-basecamp-hey-sdk/d14b77af-17c6-45e5-a24e-93794a00ec89/scratchpad/skillswork/migration2/skills/...
No .managed-skills.basecamp-cli yet: first run for basecamp-cli, removing nothing
 .managed-skills                 |  6 ++++--
 .managed-skills.basecamp-cli    |  2 ++
 skills/basecamp-doctor/SKILL.md |  1 +
 skills/basecamp/SKILL.md        | 14 ++++++++++----
DRY_RUN=local: committed in /private/tmp/claude-501/-Users-jeremy-Work-basecamp-hey-sdk/d14b77af-17c6-45e5-a24e-93794a00ec89/scratchpad/skillswork/migration2, skipping push.

=== new hey-cli script (SKILLS_TARGET, DRY_RUN=local) ===
Syncing into /private/tmp/claude-501/-Users-jeremy-Work-basecamp-hey-sdk/d14b77af-17c6-45e5-a24e-93794a00ec89/scratchpad/skillswork/migration2
Copying skills into /private/tmp/claude-501/-Users-jeremy-Work-basecamp-hey-sdk/d14b77af-17c6-45e5-a24e-93794a00ec89/scratchpad/skillswork/migration2/skills/...
No .managed-skills.hey-cli yet: first run for hey-cli, removing nothing
 .managed-skills.hey-cli |   1 +
 skills/hey/SKILL.md     | 812 ++++++++++++++++++++++++++++++++++++++++++++++++
DRY_RUN=local: committed in /private/tmp/claude-501/-Users-jeremy-Work-basecamp-hey-sdk/d14b77af-17c6-45e5-a24e-93794a00ec89/scratchpad/skillswork/migration2, skipping push.

=== AFTER: find skills -maxdepth 1 ===
skills
skills/basecamp
skills/basecamp-doctor
skills/hey

=== AFTER: cat .managed-skills* ===
--- .managed-skills
# Superseded by the per-source manifests (.managed-skills.<cli>), one per publishing CLI.
# Each CLI deletes only the skill directories listed in its own manifest.
# Kept so a CLI still running the pre-fix sync script deletes nothing: that script skips
# every line it cannot parse as a skill name and only deletes names it can.
--- .managed-skills.basecamp-cli
basecamp
basecamp-doctor
--- .managed-skills.hey-cli
hey

--- git log ---
49fff15 hey-cli[bot] Sync skills from hey-cli v1.5.0
bc5492f basecamp-cli[bot] Sync skills from basecamp-cli v0.12.0
42716d7 basecamp-cli[bot] Sync skills from basecamp-cli v0.11.0

So the first hey-cli release after this merges restores HEY's skill to basecamp/skills, and no release of either CLI touches the other's skills again.

Test

scripts/test-sync-skills.sh (the seed's test, unchanged; make test-sync-skills, in make check, the CI e2e job and the release gate) builds a throwaway target reproducing basecamp/skills as the history above left it — the basecamp skills present, skills/hey gone, the legacy .managed-skills listing the basecamp names, plus a README that must survive — and two fixture trees (one with a nested file, a *.go, a dotfile and a dot-directory that must not be copied). It runs the script interleaved hey-cli, basecamp-cli, hey-cli, basecamp-cli, asserting after each run that both sources' skills are present, each manifest lists exactly its own names and .managed-skills is the tombstone. Then: a skill dropped from one tree removes only that directory; a stale legacy manifest listing the sibling's names (a pre-fix sibling having run) deletes nothing; a name listed in both manifests survives with a warning; publishing a name another source owns is refused before anything changes; DRY_RUN=remote shows the diff and commits nothing; the DRY_RUN=local preview stays offline; a concurrent publisher winning the race to origin (a real push into a local bare repo) is absorbed by the fetch-first retry, and a concurrent claim on a name this source ships makes that retry refuse; a dirty checkout, a wrong remote/pushurl and a wrong branch are refused; and the commit author is <source>[bot].

scripts/sync-skills.sh is on the sensitive-change list, so expect the gate label on this PR.

The sibling PR in basecamp-cli carries the same change: basecamp/basecamp-cli#708

Copilot AI balanced review requested due to automatic review settings September 13, 2026 00:09
@jeremy
jeremy requested a review from a team as a code owner September 13, 2026 00:09
@github-actions

github-actions Bot commented Sep 13, 2026

Copy link
Copy Markdown

Sensitive Change Detection (shadow mode)

This PR modifies control-plane files:

  • .github/workflows/release.yml
  • .github/workflows/sync-skills.yml
  • .github/workflows/test.yml
  • scripts/sync-skills.sh

Shadow mode — this check is informational only. When activated, changes to these paths will require approval from a maintainer.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 13, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-13T03:17:33.756680Z 7cfcf58 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Active cross-publisher name collisions can still silently overwrite another publisher’s skill.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Introduces per-publisher skill manifests to prevent CLIs from deleting each other’s distributed skills.

Changes:

  • Adds source-specific ownership and legacy-manifest migration.
  • Adds interleaved publisher and collision tests.
  • Updates workflow and release documentation.

[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

File summaries
File Description
scripts/sync-skills.sh Implements per-source synchronization.
tests/e2e/sync_skills.bats Tests ownership and migration behavior.
RELEASING.md Documents the new contract.
.github/workflows/sync-skills.yml Clarifies workflow behavior.
Review details
  • Files reviewed: 4/4 changed files
  • Comments generated: 1
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread scripts/sync-skills.sh Outdated
basecamp/skills is shared by several CLIs, and every one of them ran the
same sync script against one shared .managed-skills: publish skills/*,
then delete every name in that file that the publisher's own tree lacks.
So hey-cli's v0.1.1 release deleted skills/basecamp and
skills/basecamp-doctor (basecamp/skills@08ef7ea), and basecamp-cli's
v0.10.0 and v0.11.0 deleted skills/hey (728a916, 42716d7). Today the
distribution repo holds only the basecamp skills; HEY's is gone
(basecamp/skills#5).

Each publisher now owns a manifest of its own, .managed-skills.<source>,
and reads only that file to decide what to remove. A skills/<name> goes
only when this source's manifest lists it, this release no longer ships
it, and no other source's manifest claims it — a name two manifests
list is a collision to settle upstream, so it is warned about and left,
and a name another source's manifest holds is refused for publishing.
A target with no manifest for this source yet removes nothing; the old
"no manifest, so everything under skills/ is ours" fallback is gone,
since it is exactly what deleted the sibling's skills.

The legacy .managed-skills stays, rewritten on every run as a
comment-only tombstone. The pre-fix script skips every line it cannot
parse as a skill name, so a sibling still running it deletes nothing;
had the file been removed, that script's fallback would have claimed
every skills/* directory. That is what makes the rollout order across
CLIs irrelevant.

A push rejected because a sibling published first ("fetch first" on a
depth-1 clone, or non-fast-forward) drops the stale commit and applies
the sync again from the remote's new tip, collision guard included. The
token reaches git through a private GIT_CONFIG_GLOBAL insteadOf rewrite
rather than the clone URL, and the checkout must be clean before the
sync sweeps it into a commit.

SYNC_SOURCE overrides the source name (the bot identity and commit
message derive from it) so a test can play the other CLI, and
SKILLS_TARGET points the script at an existing checkout instead of
cloning; with DRY_RUN=local it applies and commits but skips the push.
scripts/test-sync-skills.sh (make test-sync-skills, in make check and
CI) builds a target reproducing basecamp/skills as the history above
left it and runs the script interleaved as both CLIs, through a rejected
push included. The existing bats tests keep the source-discovery
contract, adapted to the script's new "Found" line.

The script is the shared CLI seed's, byte-identical apart from the
CLI_NAME default. The first stable release after this restores
skills/hey.
@jeremy
jeremy force-pushed the sync-skills-ownership branch from 0e923e8 to e967fb5 Compare September 13, 2026 00:46
…ards

Mirrors basecamp/cli@966966e (the seed is the source of truth; only the
CLI_NAME default differs here), and picks up the two seed commits this copy
was behind: the remote-URL assert that read every configured URL, and two
comment rewordings — both now moot, since the assert is gone.

SKILLS_TARGET let the script adopt an existing checkout of basecamp/skills.
Every release path clones its own target, and each review round found
another corner of "any checkout" to guard. The script now always clones
into a temp directory from SKILLS_REPO_URL (default
https://github.com/basecamp/skills.git, the token carried through the same
insteadOf rewrite as before), applies, pushes and cleans up, so the only
commit it can push is the one it made. The remote-URL, branch and
clean-tree asserts are gone with the knob; the retry from the fetched tip,
the per-source manifests, the collision guard, the tombstone and DRY_RUN
validation are unchanged.

The test points SKILLS_REPO_URL at a local bare repository and reads every
result back from a clone of its own; the race is staged with a post-commit
hook that pushes the sibling's commit between the script's clone and push.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants