mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
* 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:
co-authored by
Claude Opus 4.8
parent
b9b5780396
commit
13b115e006
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user