fix(cli): close skills removed-detection power-user gaps (follow-up to #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 (88daa820). 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>
This commit is contained in:
James Russo
2026-06-26 10:39:05 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent b9b5780396
commit 13b115e006
4 changed files with 257 additions and 21 deletions
+40 -2
View File
@@ -64,11 +64,11 @@ function setPlatform(platform: NodeJS.Platform): void {
}
/** Invoke the `skills update` subcommand from a freshly-imported module. */
async function runSkillsUpdate(): Promise<void> {
async function runSkillsUpdate(args: Record<string, unknown> = {}): Promise<void> {
const { default: skillsCmd } = await import("./skills.js");
const subs = skillsCmd.subCommands as unknown as Record<string, typeof skillsCmd>;
expect(subs.update).toBeDefined();
await subs.update!.run?.({ args: {}, rawArgs: [], cmd: subs.update } as never);
await subs.update!.run?.({ args, rawArgs: [], cmd: subs.update } as never);
}
describe("hyperframes skills", () => {
@@ -213,6 +213,19 @@ describe("hyperframes skills", () => {
expect(state.spawnCalls.some((s) => s.args.includes("remove"))).toBe(false);
});
// `update`'s prune runs the same removed-detection as `check`, so its
// --source/--dir must reach the internal checkSkills() — otherwise the prune
// reconciles against defaults even when the user pointed elsewhere.
it("skills update plumbs --source/--dir to its prune detection (parity with check)", async () => {
setPlatform("linux");
const { checkSkills } = await import("../utils/skillsManifest.js");
vi.mocked(checkSkills).mockResolvedValueOnce({ scope: "project", skills: [] } as never);
await runSkillsUpdate({ source: "owner/repo", dir: "/custom/skills" });
expect(checkSkills).toHaveBeenCalledWith({ source: "owner/repo", dir: "/custom/skills" });
});
// Skill names come from lock-file JSON keys; a flag-like / shell-special name
// must never reach the spawn (esp. the Windows cmd.exe path).
it("skills update never passes a non-slug skill name to remove", async () => {
@@ -233,4 +246,29 @@ describe("hyperframes skills", () => {
expect(removeCall!.args).toContain("graphic-overlays");
expect(removeCall!.args).not.toContain("--config=evil.js");
});
// The early-return guard in runSkillsRemove: when EVERY candidate name is
// rejected as non-slug, no `skills remove` is spawned at all (the prior test
// only covers a mix of valid + invalid). A spawn here would run `skills remove
// --yes` with no names — which the upstream CLI treats as "remove nothing" at
// best, or prompts interactively at worst — so we must not reach it.
it("skills update spawns no remove when every removed name is rejected", async () => {
setPlatform("linux");
const { checkSkills } = await import("../utils/skillsManifest.js");
vi.mocked(checkSkills).mockResolvedValueOnce({
scope: "global",
skills: [
{ name: "--config=evil.js", status: "removed" },
{ name: "../escape", status: "removed" },
],
} as never);
await runSkillsUpdate();
expect(state.spawnCalls.some((s) => s.args.includes("remove"))).toBe(false);
// The install still ran and the update still succeeded — a cleanup no-op
// doesn't fail the update.
expect(state.spawnCalls[0]?.args).toContain("add");
expect(process.exitCode).toBe(0);
});
});
+48 -6
View File
@@ -4,7 +4,12 @@ import * as clack from "@clack/prompts";
import { c } from "../ui/colors.js";
import { buildNpxCommand } from "../utils/npxCommand.js";
import { withMeta } from "../utils/updateCheck.js";
import { checkSkills, type SkillDiff, type SkillsCheckResult } from "../utils/skillsManifest.js";
import {
checkSkills,
SKILLS_CLI_LOCK_PATHS_VERIFIED_AT,
type SkillDiff,
type SkillsCheckResult,
} from "../utils/skillsManifest.js";
import type { Example } from "./_examples.js";
export const examples: Example[] = [
@@ -157,6 +162,19 @@ function renderCheck(result: SkillsCheckResult): void {
c.warn,
);
// Removed-detection cross-references the upstream skills lock. If that lock is
// absent where we expect it (e.g. upstream moved its path), removed-detection
// silently reports zero — so warn rather than imply a clean "up to date".
if (result.lockMissing) {
console.log();
console.log(
` ${c.warn(`! Skills lock not found — can't check for skills removed upstream.`)}`,
);
console.log(
` ${c.dim(` (lock paths verified against ${SKILLS_CLI_LOCK_PATHS_VERIFIED_AT})`)}`,
);
}
console.log();
if (result.updateAvailable) {
console.log(` ${c.accent("Update: npx hyperframes skills update")}`);
@@ -178,8 +196,8 @@ const checkCommand = defineCommand({
},
async run({ args }) {
const result = await checkSkills({
dir: args.dir as string | undefined,
source: args.source as string | undefined,
dir: args.dir,
source: args.source,
});
if (args.json) console.log(JSON.stringify(withMeta(result), null, 2));
@@ -199,12 +217,36 @@ const updateCommand = defineCommand({
description:
"Update all HyperFrames skills to the latest — installs any not yet present, removes any no longer published",
},
args: {},
async run() {
// Mirror `check`'s flags: the prune step runs the same removed-detection, so it
// must respect the same overrides. Without these, `update`'s internal
// checkSkills() fell back to defaults — pruning the auto-detected install
// against the default manifest even when the user pointed `check` elsewhere.
args: {
dir: {
type: "string",
description:
"Skills dir for removed-detection only — scopes the prune, not the install (default: auto-detect)",
},
source: {
type: "string",
description:
"Where 'latest' comes from for removed-detection (local path, owner/repo, or URL) — does not change the install source",
},
},
async run({ args }) {
const dir = args.dir;
const source = args.source;
// `skills add --all` re-fetches every skill to the latest AND installs ones
// not yet present — so "update" pulls the full set, not just what is already
// installed. This is where `init` and the stale-skills nudge both lead.
//
// Note: the upstream `skills add` CLI has no `--dir` flag (it installs into
// detected agent dirs), so `--dir` here scopes only the *prune* detection
// below, not the install. `--source` likewise drives where the prune's
// "latest" manifest comes from; the install always targets the canonical
// HyperFrames repo so `update` reliably pulls the published set.
//
// strict: this is the documented recovery path for the agent/CI contract
// `hyperframes skills check || hyperframes skills update`. If the install
// fails (no npx, `skills add` exits non-zero) it must exit non-zero too —
@@ -229,7 +271,7 @@ const updateCommand = defineCommand({
// failure doesn't fail the update — the install the CI contract gates on
// already succeeded.
try {
const { skills, scope } = await checkSkills();
const { skills, scope } = await checkSkills({ dir, source });
const removed = skills.filter((s) => s.status === "removed").map((s) => s.name);
if (removed.length) {
console.log();