feat(cli): surface and prune skills removed upstream#1740
Conversation
`skills add` / `init` / `hyperframes skills update` only ever add or refresh — none of them delete a skill that was renamed or dropped upstream (e.g. graphic-overlays → talking-head-recut). `skills check` also ignored any installed skill not in the manifest, so a stale bundle lingered forever with no signal and no cleanup path. - skills check: detect "removed" skills by cross-referencing the vercel-labs/skills lock — a skill the lock attributes to our manifest `source` that the manifest no longer lists. Surface them in the human and --json output and count them toward the non-zero exit so the `check || update` contract gates on them. - skills update: after `skills add --all`, prune those skills via `skills remove -g --yes` so the install fully reconciles with the manifest. Best-effort — a cleanup failure doesn't fail the update. Attribution is via the lock's source field, never the bare directory name: `.../skills` is shared across sources, so skills from other sources (e.g. greensock/gsap-skills) are never touched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
miga-heygen
left a comment
There was a problem hiding this comment.
Review — feat(cli): surface and prune skills removed upstream
SHA reviewed: 51edaac
Good feature — closing the "skills linger forever after a rename/removal" gap is the right thing to do, and the attribution-via-lock approach is exactly right. The separation between the manifest diff (what diffSkills does) and the removed-detection (what detectRemoved does) is clean, the repoSlug normalization covers the realistic URL variants, and the best-effort semantics on the cleanup step respect the CI contract without being fragile. Tests cover the important paths well.
A few things I want to flag:
1. runSkillsRemove missing -g test assertion
The implementation passes -g (global scope) to skills remove:
return spawnNpx(["skills", "remove", ...names, "-g", "--yes"]);But the "prunes skills removed upstream" test only asserts on --yes, never on -g:
expect(removeCall!.args).toContain("graphic-overlays");
expect(removeCall!.args).toContain("--yes");If someone accidentally dropped -g, the prune would target the wrong scope and the test would still pass. Add expect(removeCall!.args).toContain("-g");.
2. Command injection surface via skill names in runSkillsRemove
runSkillsRemove spreads names directly into the spawn args:
return spawnNpx(["skills", "remove", ...names, "-g", "--yes"]);The names come from checkSkills() → detectRemoved() → skillsAttributedToSource(), which reads them as JSON keys from the lock file on disk. The lock is local-only (not user-supplied over the network), so the practical risk is low. But if a lock file were ever corrupted or crafted (e.g. a name like --config=evil.js), the spawn would interpret it as a flag. Spawning via an array (not shell) already avoids classic shell injection, but -- before the names would be a cheap hardening:
return spawnNpx(["skills", "remove", "--", ...names, "-g", "--yes"]);Low priority — just a defense-in-depth note.
3. SkillsDiff type exported but only used internally
The new SkillsDiff interface is exported but never consumed outside skillsManifest.ts — diffSkills returns it and checkSkills immediately destructures it. Consider keeping it unexported (interface SkillsDiff) unless there is an upcoming consumer. Minor — no blocking concern.
4. Edge case: what if a removed skill is also in the manifest?
detectRemoved filters !(name in latest.skills), which is correct. But checkSkills then concatenates [...diff.skills, ...removed] without deduplication. If a skill appeared in both the lock (attributed to our source) AND the manifest (somehow), detectRemoved would correctly filter it out, so this is fine in practice. Just confirming I traced the logic end to end — the filter is in the right place.
5. printSkillSection — nice DRY refactor
The extraction of the three repeated console-log blocks into printSkillSection is a clean simplification. The uniform coloring per line (mark + name) matches the existing style. Good.
Verdict: Looks good overall. The lock-based attribution is the right design for a shared skills/ directory. The one actionable item is adding the -g assertion in the test (#1). The rest are minor observations — not blockers. Approve after #1.
— Miga
… from Make the cleanup in `skills update` impossible to misfire onto a user's own skills. The prune already only targets names the lock attributes to our source, but it hardcoded `skills remove -g` (global) while `skills add` defaults to project scope — so detection could attribute from one scope's lock while removal hit another, potentially deleting a global skill of the same name from a different source. - checkSkills now returns the located install's `scope`. - skills update removes in that exact scope (`-g` only when global), so attribution scope and removal scope always match. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Review — feat(cli): surface and prune skills removed upstream
SHA reviewed: 51edaac8c92ce17bae5da31b7b434e9ab5cc159f
Layering on: Miga's R1 (same SHA, 51edaac) — agreeing with her findings and adding a few more from a different angle.
This is a clean follow-up to #1738 — the lock-attribution approach is the right way to handle "ours vs another source's" in a shared .../skills directory, and the best-effort cleanup respects the check || update CI contract (install is the gated surface, prune is icing). SkillStatus / summary.removed is an additive extension to the existing JSON shape, so downstream CI consumers won't break. --json rendering, updateAvailable rollup, and renderCheck formatting all thread through correctly.
Per the iterative-merge rubric: nothing here is a hard-to-walk-back contract change. Additive --json field, additive enum value, and the prune step is gated on positive lock attribution (no false positives by construction). Comfortable with the merge-and-iterate stance.
Commenting (not approving) — leaving the stamp call to you per usual.
🟡 Concerns
1. --dir users silently skip removed-detection — skillsManifest.ts:222-225.
locateInstall hard-codes scope: "project" whenever --dir is given, regardless of whether the override path is actually under cwd or $HOME:
if (opts.dir) {
return existsSync(opts.dir)
? { dir: opts.dir, agent: agentFromDir(opts.dir), scope: "project" }
: null;
}That scope feeds lockPathForScope (skillsManifest.ts:329-337), which then reads ./skills-lock.json — almost certainly absent for the typical --dir ~/.claude/skills invocation. Result: detectRemoved reads a missing file, falls through to readSkillLock's catch → null, and silently reports zero removed skills. A user pointing --dir at their actual home-global install would never see a removed row.
Two reasonable fixes:
- Infer scope from the path (compare against
homedir()/ known XDG paths), OR - Try both
projectandgloballock paths indetectRemovedand union the attributions.
Not a blocker because --dir is power-user-only and the existing check/update happy path (no --dir) is unaffected. Worth a follow-up.
2. update doesn't accept --source or --dir, but check does — skills.ts:181-225 vs :154-177.
checkCommand plumbs through --json, --dir, --source; updateCommand has args: {}. So:
hyperframes skills check --source fork/repo # checks against the fork manifest
hyperframes skills update # installs from heygen-com/hyperframes
For the check || update CI contract this is fine — both default to the same source. But update's prune step calls checkSkills() (no args, skills.ts:212) to discover what to remove, so a user testing against a fork would see different removed-sets between check and update. Probably worth either:
- Adding
--source/--dirtoupdatefor symmetry, OR - Documenting that
updateis heygen-com-only in themeta.description.
3. <HOME>/.agents/.skill-lock.json is plausible but version-pinning is implicit — skillsManifest.ts:336.
The comment says "mirroring that CLI's paths", but if vercel-labs/skills ever moves the lock (e.g. to <XDG_CONFIG_HOME>/.agents/.skill-lock.json, or under .config/skills/), detectRemoved silently returns nothing again. The same failure mode as #1 above: best-effort cleanup that just stops working with no signal to the user. Worth pinning the assumed lock-path schema to a vercel-labs/skills version in the comment, so future drift is at least obvious during code search.
💭 Layering on Miga's findings
- Her #1 (test missing
-gassertion) — strong agree. Easy fix, prevents silent scope regression. - Her #2 (
--for flag-injection hardening) — agree this is defense-in-depth, not active risk.spawnNpxusesspawnwith an args array (no shell interpolation), so classic injection is already out. The realistic risk is a corrupted/crafted lock with a name like--foogetting interpreted as a flag by upstreamskills remove. Cheap to harden:Only worth it if upstreamreturn spawnNpx(["skills", "remove", "--", ...names, "-g", "--yes"]);
skills removeactually respects--as the end-of-flags sentinel — worth a one-line check before adding. - Her #3 (
SkillsDiffexported but unused externally) — agree, minor. - Her #4 (dedup trace) — also traced this end-to-end, also confirmed it's fine —
detectRemovedfilters!(name in latest.skills)so the concatenation incheckSkills(skillsManifest.ts:496) can't double-report.
✅ Strong points
printSkillSectionrefactor — clean DRY, and the uniform coloring per line matches the existing style.repoSlugnormalization handlesgit+,https://github.com/,.git, lowercase — covers the realistic URL/slug variants the lock would record.- Best-effort prune semantics: install failure exits non-zero (gated CI contract surface), prune failure is a
clack.log.warn(icing, not contract). Right rubric. - Test fixtures use real filesystem writes via
mkdtempSync— nof(x)===f(x)reflexivity, no shared-fallback duration-source. The "removed-alone tripsupdateAvailable" test in particular is a good one — confirms the rollup logic. --jsonoutput stably extended (additivesummary.removed+ additive"removed"enum value), so existing CI consumers won't break.
— Rames D Jusso
Addresses review feedback: skill names fed to `skills remove` originate as lock-file JSON keys, so a corrupt or crafted lock entry could smuggle a flag-like (`--config=…`) or shell-special token into the spawn — which matters most on the Windows cmd.exe path where arg escaping is fragile. Filter the names through a strict kebab-case pattern and warn on any that are rejected, rather than relying on a `--` separator (the upstream `skills` arg parser silently ignores unknown `-`-prefixed tokens and has no `--` end-of-options handling, so `--` would be a no-op there). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks @miga-heygen — both points addressed. 1.
This also closes a latent bug your point implied: hardcoded 2. Crafted skill-name in the spawn — hardened in |
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
R2 verification — feat(cli): surface and prune skills removed upstream
SHA reviewed: 314c6f6db4b30b32c276ec209830ab35c6dc2b2e (R1 was 51edaac, +2 commits: 0e13a759 scope-aware prune, 314c6f6d skill-name validation)
Layering on: Miga's R1 + my R1, both at 51edaac.
Verified each R1 finding against HEAD. Both Miga's load-bearing items addressed — one straightforwardly, one resolved-differently in a better shape than the suggestion. My three 🟡s are acknowledged as documented power-user follow-ups (per James's "addressed feedback" framing in his issue comment); no blocker movement.
Commenting (not approving) — stamp call is yours.
Miga's R1 findings — verified at HEAD
Miga #1 — -g test assertion gap → ✅ resolved-and-extended
skills.test.ts:187 now asserts toContain("-g") on the global path; the rename "prunes skills removed upstream, in the attributed scope" reflects the new shape. Sibling test added at :194-208 asserts not.toContain("-g") on the project path. Per James's reply: the original hardcoded -g masked a latent cross-scope bug (project attribution + global remove could have hit a same-named global skill from another source). Both directions now scope-symmetric.
Miga #2 — -- flag-injection hardening → ✅ resolved-differently (stronger than suggested)
skills.ts:58-77: runSkillsRemove now filters names through PLAIN_SKILL_NAME = /^[a-z0-9][a-z0-9._-]*$/i, warns on rejects via clack.log.warn, and early-returns Promise.resolve() if zero valid names remain. James verified upstream skills arg parser ignores unknown --prefixed tokens and has no -- handling, so the -- suggestion would have been a no-op. The validation approach is better:
- Parser-independent — works regardless of upstream behavior.
- Covers the Windows
cmd.exespawn path where arg escaping is fragile (you flagged this rationale). - Per-name granularity: a valid name in the same batch as a rejected one still gets removed (test at
skills.test.ts:218-235confirms--config=evil.jsfiltered whilegraphic-overlayspasses through).
Regex check: starts with [a-z0-9] (locks out - / . / _ prefixes including all flag forms), then [a-z0-9._-]*. Realistic skill names (graphic-overlays, talking-head-recut, 2d-animation, mixed-case via /i) all match. Anything with =, space, ;, &, |, or shell metachars is rejected.
Miga #3 — SkillsDiff exported but only used internally → 📝 acknowledged-kept
skillsManifest.ts:73 still export interface SkillsDiff; still only used at line 254. Author judgment call — minor, not a blocker.
Miga #4 — Dedup trace → ✅ non-issue confirmed (no code change needed; logic is correct).
Miga #5 — printSkillSection praise → 📝 still present at skills.ts:114-126.
My R1 findings — verified at HEAD
My #1 — --dir users silently skip removed-detection (skillsManifest.ts:222-225) → ❌ open, acknowledged-power-user
locateInstall still hardcodes scope: "project" for any --dir (now at :220-228). Not addressed in this PR. Acceptable as a documented power-user follow-up — fix shape (path-aware scope inference, or both-scope union in detectRemoved) is still viable for a follow-up.
My #2 — update doesn't accept --source / --dir (skills.ts:181-225) → ❌ open, acknowledged-symmetry-gap
updateCommand.args still {} at :202. update's prune step still hits defaults via checkSkills() at :232. Documented divergence with check — fine for the heygen-com primary path.
My #3 — Lock-path version pinning is implicit (skillsManifest.ts:336) → ❌ open, acknowledged
lockPathForScope comment at :330 still says "mirroring that CLI's paths" with no vercel-labs version pin. Same failure-mode class as #1. Not addressed; same follow-up rubric.
NEW at HEAD
🟡 NEW #1 — All-rejected path uncovered by tests (skills.ts:71).
if (!safe.length) return Promise.resolve(); — the early return on all-names-rejected is the right behavior (don't spawn skills remove with zero positional args), but no test covers it. A regression that flipped the early-return condition or dropped it would silently start spawning skills remove -g --yes with nothing to remove, which skills remove would either error on or interpret unexpectedly. Cheap to add: a third test case where removed: ["--config=evil.js"] (sole entry, all-rejected) → assert state.spawnCalls.find(s => s.args.includes("remove")) === undefined. Test-only, not a blocker.
✅ NEW #2 — Latent cross-scope bug surfaced by Miga's #1.
Worth calling out explicitly: as your issue comment notes, the original -g hardcode + project-default skills add could have produced cross-scope mismatches that silently deleted same-named global skills from other sources. The R2 fix — checkSkills returning scope, update threading it through { global: scope === "global" } — closes this end-to-end. The new safety comment at skills.ts:225-230 ("Safety: removed only ever contains skills the lock records as installed from our source… We remove in the exact scope detection attributed from") locks the invariant in for the next maintainer.
Strong points (still)
- Scope plumbed end-to-end:
SkillRoot.scope→SkillsCheckResult.scope(additive atskillsManifest.ts:84) →runSkillsRemove({global})→ spawn args. Single source of truth, easy to trace. - Kebab-case validator is parser-independent and Windows-spawn-aware — stronger than the
--shape Miga and I both initially proposed. - Test diff is high-density: one renamed/extended test, one new scope-symmetric test, one new validator test. All assertions are exact-args on
spawnCalls, nof(x)===f(x)reflexivity. --jsoncontract stays additive-only (scopefield appended toSkillsCheckResult); existing CI consumers unaffected.
— Rames D Jusso
miguel-heygen
left a comment
There was a problem hiding this comment.
Reviewed current HEAD 314c6f6d.
Strengths:
packages/cli/src/utils/skillsManifest.tsuses lock-source attribution for removed-upstream detection, so sharedskills/directories do not produce false removals.packages/cli/src/commands/skills.tsnow prunes in the same scope returned bycheckSkills(), avoiding cross-scope deletion.packages/cli/src/commands/skills.tsvalidates lock-derived names before spawningskills remove;packages/cli/src/commands/skills.test.tspins global/project scope and invalid-name behavior.
Notes:
- Rames’s
--dirscope,update --source/--dirsymmetry, lock-path version comment, and all-invalid-name test are valid follow-ups, but not merge blockers for the defaultcheck || updatepath. - Focused local CLI tests (49/49) and oxfmt passed. Required CI is green at review time; optional regression shards are still running.
Verdict: APPROVE
Reasoning: The current head resolves the load-bearing feedback and keeps removal constrained to lock-attributed skills in the attributed scope; remaining issues are power-user or test-polish follow-ups.
— Magi
…#1740) (#1743) * fix(cli): close skills removed-detection power-user gaps (follow-up to #1740) Address power-user follow-ups deferred from #1740 (skills removed-detection): - `--dir` installs now run removed-detection. `locateInstall` hardcoded scope "project" for every `--dir`, so a `--dir ~/.claude/skills` (a global install) read a non-existent `<cwd>/skills-lock.json` and found zero removed skills. New `scopeForDir` infers global vs project from whether the dir is under $HOME, so the right lock is read. - Pin the upstream lock paths to vercel-labs/skills@v1.5.13 (verified against src/skill-lock.ts + src/local-lock.ts) and warn loudly when the lock is absent where expected, so removed-detection no longer silently no-ops if upstream moves the lock. checkSkills returns lockMissing; --json surfaces it. - skills update gains --source/--dir (parity with check), plumbed into its internal prune checkSkills() so the prune respects the same overrides. - Add the missing test for the all-rejected-names early-return in runSkillsRemove (no skills remove spawned when every candidate name is rejected), plus tests for the --dir scope inference and update flag parity. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(cli): scope --dir by CWD-containment before HOME (project installs under $HOME) Address Magi's REQUEST_CHANGES on #1743 (88daa82). The new scopeForDir heuristic treated every explicit --dir under $HOME as GLOBAL, but the common project-local case is also under $HOME (e.g. ~/work/proj/.claude/skills, or --dir .claude/skills run from ~/work/proj). So checkSkills could read the GLOBAL lock and `skills update --dir ...` could prune with `skills remove -g` even when the user pointed at a PROJECT install — a wrong-scope prune. Change precedence to CWD-containment FIRST, then HOME: - dir resolves under cwd -> project (even when cwd is itself under $HOME) - else dir under home -> global - else -> project (safe default, never prune globally) Keeps the existing resolve/normalize + trailing-separator guard (so /home/user2 does not false-match /home/user). scopeForDir now takes cwd; locateInstall threads opts.cwd through. Add a regression test for Magi's exact failing case: cwd nested under home (cwd = <home>/work/proj) with --dir <cwd>/.claude/skills resolves to PROJECT. Existing tests stay green (global --dir ~/.claude/skills from an unrelated cwd still resolves to GLOBAL). Also (Miga's nit): drop the redundant `as string | undefined` casts on the citty args in skills.ts (citty already infers that type), and clarify the `skills update` --dir/--source help text to note they scope removed-detection only, not the install location. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolve the skills-distribution conflicts. main already carried the manifest/version-check + prune work (#1738/#1740/#1743) and the #1748 "scope installs via skillsTargets" approach; this branch reworks install to the global symlink-mirror model, so: - Keep main's prune/removed-detection (skillsManifest.ts, the rm + update prune path, the related tests) verbatim; re-apply only the global-first locateInstall on top. - Replace #1748's skillsTargets-based scoped install with the global --copy + --full-depth install + mirrorGlobalSkills fan-out; delete the now-superseded skillsTargets.ts (+ test). - Keep main's 0.7.13 version bump + add the gen:agent-dirs script. - Regenerate skills-manifest.json against the merged skills/ tree. Verified: typecheck, oxlint, oxfmt, fallow gate all clean; 1044 CLI tests pass (main's prune suite + the new mirror/global suite together).
What
Make
hyperframes skills check/updateaware of skills that were renamed or removed upstream (e.g.graphic-overlays→talking-head-recutin #1720).skills checknow reports aremovedcategory and counts it toward the non-zero exit.skills updateprunes those skills after installing, socheck || updatefully reconciles the install with the manifest.Why
skills add/init/hyperframes skills update(which wrapsskills add --all) only ever add or refresh — none of them delete a skill that's been renamed or dropped upstream. AnddiffSkillsdeliberately ignored any installed skill not in the manifest, so a stale bundle (likegraphic-overlaysafter thetalking-head-recutrename) lingered forever:skills checksaid "up to date" while a dead skill sat in the agent's skills dir, and there was no first-class cleanup path.This came up cleaning up a real install that had accumulated renamed/removed skills across several releases.
How
.../skillsis shared across sources, so we can't call a directory "ours but removed" from its name alone. We cross-reference the vercel-labs/skills lock ($HOME/.agents/.skill-lock.jsonor the XDG path;skills-lock.jsonfor project scope): a skill the lock attributes to our manifestsource(heygen-com/hyperframes, matched by slug or clone URL) that the manifest no longer lists isremoved.SkillStatusgains"removed";SkillsCheckResultgainsremoved(summary) andscope.diffSkillsis unchanged (pure manifest diff) — removed-detection lives incheckSkills, which reads the lock for the located install's scope.updateAvailablenow also trips on removed skills, sinceupdatecan fix them — thecheck || updateCI/agent contract stays coherent.skills updaterunsskills add --all, then prunes viaskills remove <names> --yes. Best-effort: a cleanup failure doesn't fail the update (the install — what the contract gates on — already succeeded).renderCheckprints a "Removed upstream" section;--jsonincludes the new fields.No change to
skills-manifest.json, the generator, or the published skill set.Safety — we never touch a user's own (or another source's) skills
Two guarantees, both tested:
sourceasheygen-com/hyperframes. A hand-placed/custom skill (no lock entry) or one from another source (e.g.greensock/gsap-skills,anthropics/skills) is never matched, so never flagged and never removed.checkSkillsreturnsscope;-gis passed only when global). This prevents a project-scoped attribution from deleting a same-named global skill (or vice-versa) that could belong to the user or another source.Test plan
skillsManifest.test.ts(pureskillsAttributedToSource: slug/URL matching, other-source isolation, empty lock; end-to-endcheckSkills: removed detection, other-source not flagged, removed-alone-trips-updateAvailable, no-lock → no removed) andskills.test.ts(update prunes in global scope with-g; prunes in project scope without-g; doesn't prune when nothing was removed).bunx vitest run packages/cli— the 2*.browser.test.tserrors are a localhappy-domresolution quirk under ad-hoc vitest, unrelated).oxfmt --check,oxlint,tsc, andfallow audit --base origin/mainall clean.🤖 Generated with Claude Code