fix(cli): make skills update converge on a skill retired upstream#2176
Conversation
06c7411 to
eded948
Compare
|
Thanks for the thorough pass. Addressed the non-blockers in
New test asserts the atomic write, no trailing newline, no leftover temp file, and preserved mode; plus a Acknowledged, intentionally left as-is:
Lock-JSON indentation confirmed against the on-disk upstream lock (2-space). |
vanceingalls
left a comment
There was a problem hiding this comment.
R1 — Verdict: 🟢 LGTM
The mystery of the perpetually reappearing hyperframes-media, and its cousin the nightly nudge counting to two when the truth was three, has been closed with — I am pleased to report — the correct instruments. Two failure modes, two mechanisms, and a convergence proof that both a. the immediate bug and b. the second-run-still-fails class are neutralised.
I audited head eded948c89972bef776f4138ab1908cf08a4b3a2. (An earlier SHA I began with, 06c74119, had already been superseded by force-push during the read; the two atomic-write refinements below are new to eded948c and I re-read the whole surface at the new SHA.)
The five load-bearing invariants — verified
1. pruneOrphanedLockEntries — the new self-heal primitive — packages/cli/src/utils/skillsManifest.ts:539-566. Reads the lock at exactly the same pinned path the upstream CLI reads/writes (SKILLS_CLI_LOCK_PATHS_VERIFIED_AT = vercel-labs/skills@v1.5.13, unchanged constant), filters names against lock.skills (idempotent by construction — a second call finds nothing left, no rewrite), and — this is the interesting refinement at the new SHA — writes the lock atomically via <path>.tmp + renameSync, preserving mode. So a crash mid-write cannot leave a truncated JSON behind that would poison every subsequent load. The false-prune-on-live-skill hazard I looked for is absent: the function only ever touches entries whose names it was passed, which come solely from check.skills.filter(status === "removed") — the source-attributed, manifest-diff'd removed set. A concurrent live install writing new keys would sit in a different position in the map and remain untouched.
2. checkSkills({ canonical: true }) bypasses the in-repo shortcut — packages/cli/src/utils/skillsManifest.ts:662-677. resolveLatestManifest now branches: canonical: true skips the findRepoManifest(cwd) climb entirely, always fetching from raw.githubusercontent.com with git ls-remote SHA-pinning. An explicit --source still wins (deliberate caller intent). Failure mode when the fetch dies: the try/catch in updateSkills at packages/cli/src/commands/skills.ts:309-325 swallows to null and falls into updateSkillsOffline, which uses FALLBACK_CORE_SKILLS — a pinned enumeration that does not include hyperframes-media. So there is no silent path where a retired skill re-enters the target set on a degraded network. Belt AND braces.
3. Nudge exclusion + removed count threaded through — packages/cli/src/cli.ts:224-230 excludes skills from firing the nudge (the loop vance hit outside a repo cannot recur — skills update no longer re-prints "run skills update"). packages/cli/src/utils/skillsUpdateCheck.ts:83-88 computes total = outdated + missing + removed, and — again at the new SHA — the background refresh at line 41 now calls checkSkills({ canonical: true }), so the cached nudge count agrees with what updateSkills will actually reconcile rather than a stale in-repo manifest's view. Test at skillsUpdateCheck.test.ts:38-50 pins skillsRemovedCount: 3 and total: 4, exactly the pre-fix "misleading 2 vs 3" shape.
4. Two-runs-converge — packages/cli/src/commands/skills.test.ts:437-464. First run: removed: ["hyperframes-captions"] → runSkillsRemove spawns, pruneOrphanedLockEntries returns ["hyperframes-captions"], exit 0. Second run: checkSkills mocked to return skills: [], so no remove spawn, no prune call. pruneOrphanedLockEntries remains called only once across both runs. That is the exact contract for vance's Shape A (the in-repo hyperframes-media install-verify-throw): after run one, the lock entry is gone; run two finds nothing to reconcile.
5. isCoreSkill prefix match is defused by ordering — packages/cli/src/utils/skillsManifest.ts:130-132. isCoreSkill still matches hyperframes-* as a prefix, which is fine because it is now applied AFTER the canonical filter: manifestSkills = check.skills.filter(s => s.status !== "removed") at commands/skills.ts:329, and only THEN .filter(s => ... isCoreSkill(s.name) ...) at line 343. A retired skill never appears in manifestSkills at all (canonical no longer ships it), so the pattern match has nothing to force in. The prefix check itself is deliberately unchanged — it still needs to catch future hyperframes-* additions before they land in the fallback list.
Both of vance's shapes are covered
- Shape A (in-repo) —
Skill(s) still missing after install: hyperframes-media— the install-path/verify-throw. Covered by the canonical-manifest test atskills.test.ts:399-408(mechanism) plus the two-runs-converge test at 437-464 (end-to-end). The retired skill never enterstoInstall,verifyInstallednever asserts on it, no strict-throw. - Shape B (outside repo) —
No matching skills found for: hyperframes-captions, hyperframes-compose, hyperframes-tts+2 HyperFrames skills out of date or missing. Run: npx hyperframes skills update— the nudge-loop. Covered by the exclusion atcli.ts:224-230(the nudge never fires from insideskills updateitself), the count fix atskillsUpdateCheck.ts:84-87(removed skills counted), and the self-heal atskills.ts:709-718+skillsManifest.ts:539-566(the underlying lock entry actually gets cleared, so the cached count on the next run's refresh drops to zero).
Small notes, not requesting changes
- The atomic-write on the lock (
renameSync) is the correct pattern for a file the upstream CLI also reads/writes; a crash-mid-write leaving the lock corrupt would have been a nastier failure mode than the one this PR was fixing, so I appreciate seeing it addressed proactively rather than as a follow-up. - The failed-fetch path in
updateSkillsis a silent swallow tonull(line 322 catch). That is the correct choice for offline-degrade — but if some future refactor turns a malformed canonical response (asSkillsManifest throw) into a silent skip, it would look identical to genuine offline and land users inupdateSkillsOfflinepresence-only mode. Not a fix-now concern (the guard is already there and the mock covers offline correctly), just worth flagging: a malformed-canonical case masquerading as offline would degrade the guarantee. If future you cares, a distinct log line for "unreachable" vs "malformed" would help ops.
CI green when I looked. Envelope hygiene: no Co-Authored-By: Claude trailers, no 🤖 Generated with [Claude Code] in the body — clean.
R1 by Via
eded948 to
50f9672
Compare
`hyperframes skills update` failed hard or looped forever once a skill was
retired/renamed upstream while still installed locally (hyperframes-media folded
into media-use; hyperframes-captions/compose/tts consolidated earlier). Two
paths dead-ended:
- Install: target selection could trust a stale local skills-manifest.json
(findRepoManifest) while `skills add` always installs from the canonical repo.
isCoreSkill matches the `hyperframes-` prefix, so a retired skill was forced
into the target set, `skills add` silently declined it (exit 0), and strict
verifyInstalled threw "Skill(s) still missing after install".
- Prune: upstream `skills remove` scans on-disk directories, so a lock entry
retired before it ever shipped a bundle has nothing to match — a silent
exit-0 no-op that never clears the lock, so detectRemoved re-flags it on
every run.
The stale-skills nudge compounded it: it fired even from `skills update` itself
(pointing users back at the failing command) and its count ignored the removed
bucket.
Resolve update targets against the canonical manifest (checkSkills({ canonical:
true })) so a retired skill is never targeted. Add pruneOrphanedLockEntries to
clear the orphaned lock entries the upstream remover can't (idempotent, so a
second run is a clean no-op). Exclude `skills` from the update-nudge gate and
thread the removed count through the nudge total.
50f9672 to
9b5638d
Compare
|
Thanks for the deep audit — and good catch on the malformed-vs-offline ambiguity. I folded it in ( The Two tests cover it: a malformed manifest warns and still degrades (exit 0); a plain network error degrades silently with no false malformed warning. Everything else stands as you verified. Appreciated the note on the atomic write too. |
Summary
hyperframes skills updatenow converges when a skill has been retired/renamed upstream while still installed locally — instead of failing hard or looping forever.check || updatereconciles instead of re-flagging them on every run.skills/skills updateitself, and its count now includes removed-upstream skills.Reproduction
Both surfaced after
hyperframes-mediawas folded intomedia-use(and earlierhyperframes-captions/-compose/-ttswere consolidated), for users who still had the old skills installed:npx hyperframes skills update→Update failed: Skill(s) still missing after install: hyperframes-media.npx hyperframes skills update→No matching skills found for: hyperframes-captions, hyperframes-compose, hyperframes-tts, and the same nudge reappears on every rerun.Root cause
skills updatehad no notion of "gone upstream". A retired skill reached the engine two ways, and both dead-ended:resolveLatestManifestcould resolvelatestfrom a stale localskills-manifest.json(findRepoManifest), whileskills addalways installs from the canonical repo.isCoreSkillmatches by thehyperframes-prefix, so a retired skill was forced into the target set;skills addsilently declined it (exit 0), and strictverifyInstalledthrew.skills removescans on-disk directories, so a lock entry retired before it ever shipped a bundle has nothing to match. The call is a silent exit-0 no-op that never touches the lock, sodetectRemovedre-flags the same entry forever.The nudge compounded it: it fired even from
skills update, pointing users back at the command that just failed, and its count ignored theremovedbucket (the "2 vs 3" mismatch).Fix
updateSkillsresolves targets against the canonical manifest (checkSkills({ canonical: true })), so a retired skill never becomes a target.pruneOrphanedLockEntriesclears the orphaned lock entries the upstream remover can't, reusing the pinned lock path (SKILLS_CLI_LOCK_PATHS_VERIFIED_AT). Idempotent — a second run is a clean no-op.skillsfrom the update-nudge gate, and thread theremovedcount through the nudge total.Test plan
bun run --filter @hyperframes/cli test— green. Covers the canonical-manifest check, lock self-heal, two-runs-converge wiring, and thepruneOrphanedLockEntriesunit cases (remove-only-named, idempotent, missing-lock no-op, scope resolution).bunx oxlint/bunx oxfmt --checkon changed files.bun run build.