fix(cli): close skills removed-detection power-user gaps (follow-up to #1740)#1743
Conversation
…#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>
miga-heygen
left a comment
There was a problem hiding this comment.
Review — PR #1743
Clean, well-scoped follow-up. Four independent fixes that each address a real gap from #1740, shipped with matching tests and solid documentation of the one honest limitation. Notes below.
scopeForDir — global vs project inference
The heuristic (dir under $HOME = global, else project) is the right call for an inferred scope. The norm helper properly appends a trailing separator before startsWith, which prevents a false positive where e.g. /home/user2/... would match /home/user/ — good edge-case awareness.
One subtlety worth noting (not blocking): on macOS, homedir() returns /Users/foo but a user might pass --dir /var/root/... or a symlinked path. resolve normalises . and .. but not symlinks — so a dir that is physically under HOME but reached via a symlink outside HOME would scope as "project". This is conservative (falls back to the less-privileged lock), which is the right direction to be wrong in, and it matches upstream's own path logic. No change needed, just flagging the known boundary.
Lock-path version pin
SKILLS_CLI_LOCK_PATHS_VERIFIED_AT with the inline comment listing both paths and linking the upstream source files is exactly the right pattern. A future upstream lock-path change becomes a reviewable diff here rather than a silent regression. The comment is load-bearing documentation — well done.
lockMissing propagation
The new RemovedResult type is clean. detectRemoved returns { removed, lockMissing }, checkSkills destructures it, and renderCheck surfaces the warning with the pin version. The --json path also gets lockMissing via the spread into the result — no separate plumbing needed. Good type-level design.
One minor observation: when root is null (no install found at all), lockMissing is false. That's technically correct (we never looked for a lock, so it wasn't "missing"), but the renderCheck early-return for !result.location means the warning never displays in that case anyway. Consistent.
update --source/--dir parity
The flags are correctly plumbed into checkSkills({ dir, source }) inside update's run handler. The documented limitation — --dir/--source scope only the prune detection, not the install, because upstream skills add has no --dir flag — is the honest, correct interpretation. The inline comment in update's body plus the PR description make this unambiguous. Shipped a clear contract instead of faking pass-through semantics.
Test coverage
All four items have dedicated tests:
- All-rejected-names — asserts no
removespawn AND the install still runs AND exit 0. Correctly validates the early-return guard doesn't abort the update. - --dir under $HOME resolves global lock — writes a global lock, passes
--dirunder home, verifiesscope === "global"and removed-detection fires. The complementary test (--dir outside $HOME stays project-scoped) reads the project lock at<cwd>/skills-lock.json. Both are clean integration-level tests against real FS fixtures. - lockMissing — asserted on the no-lock case (the existing "no removed when no lock" test now also checks
lockMissing: true). - update plumbs --source/--dir — mocks
checkSkills, callsrunSkillsUpdate({ source, dir }), assertscheckSkillswas called with both args. Tight unit test.
The extracted installSkills helper in skillsManifest.test.ts is a nice refactor that reduces duplication across the new and existing tests.
Nits (non-blocking)
skills.tsline:const dir = args.dir as string | undefined/const source = args.source as string | undefined— citty's type inference fortype: "string"args isstring | undefinedalready, so theascasts are redundant. Not worth a follow-up, just noting for future citty-typed commands.
Verdict
LGTM. All four gaps closed, tests are thorough, the upstream limitation is documented honestly rather than papered over. CI is green across the board (all required checks pass). Ready to merge.
88daa82
— Miga
miguel-heygen
left a comment
There was a problem hiding this comment.
Reviewed 88daa820. The follow-up shape is good, but I found one scope bug that needs to be fixed before merge.
Strengths:
packages/cli/src/utils/skillsManifest.ts:360pins the upstreamvercel-labs/skills@v1.5.13lock-path contract next to the code that depends on it, which makes future upstream drift reviewable instead of silent.packages/cli/src/commands/skills.ts:220mirrorscheck's--source/--dirflags ontoupdate's prune detection, and the inline limitation is honest about upstreamskills addlacking--dirinstall targeting.
Blocker:
packages/cli/src/utils/skillsManifest.ts:232classifies an explicit--diras global whenever the path is under$HOME. That misclassifies the common project-local case: a repo checkout under home, e.g.~/work/hyperframes/.claude/skillsor--dir .claude/skillsfrom~/work/hyperframes, also starts with$HOME, but it should read<cwd>/skills-lock.jsonand prune without-g. With the current code,checkSkills({ dir: "~/work/proj/.claude/skills", cwd: "~/work/proj", home: "~/" })reads the global lock andskills update --dir ...can callskills remove -g, pruning the global install while the user explicitly pointed at a project install. The new tests only cover a project dir outside$HOME, which avoids the realistic case. Fix by preferringcwdcontainment overhomecontainment (project under cwd => project, else under home => global, else project), and add a regression forcwdnested underhomewith--dir <cwd>/.claude/skills.
Verdict: REQUEST CHANGES
Reasoning: The PR closes the intended follow-ups, but the new --dir scope heuristic can direct removed-skill cleanup at the wrong lock/scope for normal projects under $HOME.
— Magi
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed 88daa820. Layering on Miga (88daa82 LGTM) and Magi (CHANGES_REQUESTED, blocker on scopeForDir) — I confirm Magi's blocker independently and add a small follow-on.
Verified upstream pins (skillsManifest.ts:367):
getSkillLockPath()atvercel-labs/skills@v1.5.13returns$XDG_STATE_HOME/skills/.skill-lock.jsonelse~/.agents/.skill-lock.json— matches the inline comment exactly.local-lock.tsusesskills-lock.jsonat project cwd — matches.parseAddOptionsat v1.5.13 has only-g/--globaland-y/--yes, no--dir— confirms the documented honest limit.SKILLS_CLI_LOCK_PATHS_VERIFIED_AT = "vercel-labs/skills@v1.5.13"is the right pin shape; future upstream lock-path drift becomes a reviewable edit instead of silent no-op.
Blocker (concur with Magi)
🛑 scopeForDir misclassifies project checkouts under $HOME as global — packages/cli/src/utils/skillsManifest.ts:232
The heuristic norm(dir).startsWith(norm(home)) ? "global" : "project" is correct for the canonical --dir ~/.claude/skills case the PR was built around, but false-positives the equally-common case of a project checkout under $HOME:
- User runs
cd ~/work/hyperframes && hyperframes skills update --dir .claude/skills(or--dir ~/work/hyperframes/.claude/skills). dirresolves underhome=/home/user→scopeForDirreturns"global".detectRemovedreads~/.agents/.skill-lock.json(wrong lock), and inupdate(skills.ts:283),runSkillsRemove(removed, { global: scope === "global" })callsskills remove -g <name>— pruning the user's global install while they explicitly pointed at a project install.
This is a worse failure than the pre-PR "silently zero removed" — it's an active wrong-scope prune. The two new tests both sidestep this case:
--dir under $HOMEtest points dir DIRECTLY at<home>/.agents/skills(no nested project) — pure global shape.--dir outside $HOMEtest puts the project tree at<root>/proj2and home at<root>/home2(siblings, not nested) — avoids the realistic shape.
Magi's prescribed fix is the right shape: prefer cwd-containment first.
function scopeForDir(dir: string, cwd: string, home: string): "project" | "global" {
// Project under cwd ⇒ project. Else under home ⇒ global. Else project (conservative).
if (norm(dir).startsWith(norm(cwd))) return "project";
if (norm(dir).startsWith(norm(home))) return "global";
return "project";
}And add a regression: cwd = <root>/work/proj, home = <root>, dir = <cwd>/.claude/skills ⇒ scope "project", reads <cwd>/skills-lock.json.
Concerns
🟡 update --help doesn't surface the detection-only scope — packages/cli/src/commands/skills.ts:222-227
The --dir / --source description strings on update read "Skills directory to reconcile (default: auto-detect)" / "Where 'latest' comes from: …", which parallel check's wording. The honest limit (these flags scope prune detection only, not the install) is documented in the PR body and an inline comment in update's body — but a user running hyperframes skills update --help won't see either. Suggest:
dir: { type: "string", description: "Skills directory whose prune is reconciled (default: auto-detect; install always targets the canonical HyperFrames source)" },
source: { type: "string", description: "Manifest source for prune detection only — local path, owner/repo, or URL (install always pulls the canonical HyperFrames source)" },Matches the inline update-body comment so the docstring + --help + comment all tell the same story.
Nits
💭 args.dir as string | undefined / args.source as string | undefined (skills.ts:230-231) — citty already infers string | undefined for type: "string" args, so the casts are redundant. Miga also flagged. Non-blocking.
What I didn't verify
- Symlink boundary on macOS (
/var/folders/...→/private/var/...) — Miga covered this as conservative; I agree the direction-of-failure is right (under HOME via symlink falls to "project", which is the less-privileged lock). Note this assessment doesn't change with the cwd-first fix above; both heuristics treat unresolved symlinks the same way. - Whether
homedir()returning''at runtime (rare;os.userInfo()failure) interacts pathologically withnorm("")—resolve("")=process.cwd(), so under the current PRscopeForDir(dir, "")would comparedirto cwd, which is the cwd-first behavior Magi prescribes anyway. So this edge case actually heals under Magi's fix. Note for whoever lands the fix.
✅ Resolved well
- All-rejected-names early-return test (
skills.test.ts:256-273) — asserts noremovespawn AND the install still ran AND exit 0. Tight pin, not a tautology. lockMissingpropagation (skillsManifest.ts:392-413,skills.ts:165-176) — clean type-level signal throughRemovedResult→SkillsCheckResult→renderCheck. Warning includes the verified-at pin so an operator can correlate. The--jsonpath gets it via the spread, no separate plumbing needed.update--source/--dirplumbing (skills.ts:268) — callscheckSkills({ dir, source })correctly; the unit test mockscheckSkillsand asserts the call shape.
LGTM on intent + most of the execution. The scopeForDir blocker is real and well-named by Magi — fix that one and the help-text wording, and this is a clean follow-up to #1740.
— Rames D Jusso
…s 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>
miguel-heygen
left a comment
There was a problem hiding this comment.
R2 at d95f207. My prior blocker is resolved.
Specific checks:
packages/cli/src/utils/skillsManifest.ts:240now classifies explicit--dirpaths withcwdcontainment first, then$HOME, then project as the safe default. That prevents normal~/work/proj/.claude/skillsproject installs from being misread as global.packages/cli/src/utils/skillsManifest.test.ts:391adds the exact regression I asked for:cwdnested underhome,dir = <cwd>/.claude/skills, project lock present, no global lock, andgammais still detected as removed via project scope.packages/cli/src/commands/skills.ts:225/:230now make theupdate --helpwording explicit that--dir/--sourcescope removed-detection/prune only and do not retarget the install source.- The redundant citty casts are gone in both
checkandupdatehandlers.
CI is green, including Windows required checks. The remaining skipped jobs are legitimately skipped for this diff.
Verdict: APPROVE
Reasoning: The wrong-scope prune blocker is fixed with the correct precedence and a regression that covers the realistic project-under-home case.
— Magi
What
Power-user follow-ups deferred from #1740 (surface & prune skills removed upstream). Four small, mostly-independent fixes to the
hyperframes skills check/updateremoved-detection path, plus the test coverage #1740 left open.Why
#1740 made
skills check/updateaware of skills renamed or dropped upstream by cross-referencing the vercel-labs/skills lock. Four gaps were flagged for follow-up:runSkillsRemove(skills.ts) had no test — the only existing test covered a mix of valid + invalid names, never the all-invalid path.--dirinstalls skipped removed-detection entirely.locateInstallhardcodedscope: "project"for any--dir, sodetectRemovedlooked up<cwd>/skills-lock.json. For a global-style--dir ~/.claude/skills, that project lock doesn't exist →readSkillLock→null→ zero removed skills, silently.updatewas asymmetric withcheck.checkplumbs--source/--dir;updatehadargs: {}, so its internal prunecheckSkills()always hit defaults — reconciling the auto-detected install against the default manifest even when the user pointed elsewhere.How
skills updatespawns noskills removewhen every removed-candidate name is rejected as non-slug (the install still runs, exit 0).--dirremoved-detection). NewscopeForDir(dir, home)infers global vs project for a--dirby whether the (resolved, separator-normalised) dir lives under$HOME.locateInstalluses it instead of the hardcoded"project", so a--dir ~/.claude/skillsreads the global lock (~/.agents/.skill-lock.json/ XDG) and a project-tree--dirreads<cwd>/skills-lock.json. Verified against upstream: global =getSkillLockPath()insrc/skill-lock.ts, project =getLocalLockPath(cwd)insrc/local-lock.ts.SKILLS_CLI_LOCK_PATHS_VERIFIED_AT = "vercel-labs/skills@v1.5.13"next tolockPathForScope, with a comment listing both lock paths and links to the upstream source files, so a bump is a deliberate reviewable edit.detectRemovednow reportslockMissingwhen the lock is absent at the expected path;checkSkillssurfaces it (and--jsonincludes it viawithMeta), andrenderCheckprints a loud "Skills lock not found — can't check for skills removed upstream" warning instead of implying a clean "up to date".updategains--source/--dirflags, plumbed into its internal prunecheckSkills({ dir, source }). Conservative scope, called out below.Deferred / conservative-interpretation note (item 4)
The upstream
skills addandskills removeCLIs have no--dirflag — they install/remove into detected agent dirs, scoped only by-g/--global(verified insrc/add.tsparseAddOptionsandsrc/remove.tsparseRemoveOptionsat v1.5.13). So--dir/--sourceonhyperframes skills updatedeliberately scope only the prune detection (which lock/manifest to reconcile against), not the install — the install always targets the canonical HyperFrames repo soupdatereliably pulls the published set, and the prune'srunSkillsRemovecontinues to scope via-gfrom the detected scope. This is the conservative reading; full--dirpassthrough to the underlyingskills addinstall isn't possible without an upstream flag, so it's left out. The behaviour is documented inline inupdate's body. No UX change beyond the two new flags affecting prune detection.Test plan
How was this tested?
skillsManifest.test.ts(--dirunder$HOMEresolves the global lock so removed-detection fires;--diroutside$HOMEstays project-scoped;lockMissingasserted on the no-lock case) andskills.test.ts(noremovespawn when all names rejected;updateplumbs--source/--dirto its prunecheckSkills).bunx vitest run packages/cli: 81 files / 986 tests, including both*.browser.test.ts). Targeted skills tests: 53 passing.oxlint .,oxfmt --check .,tsc --noEmit(--filter '*' typecheck),fallow audit --base origin/main --fail-on-issues(no issues — remaining test-fixture duplication is warn-level / inherited),gen-skills-manifest --check(in sync, no skill content changed), andlint-skillsall clean.Follow-up to #1740.
— Jerrai