Skip to content

Commit b1f1c05

Browse files
fix(cli): make skills update converge on a skill retired upstream (#2176)
`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.
1 parent 1d97dda commit b1f1c05

9 files changed

Lines changed: 587 additions & 12 deletions

File tree

packages/cli/src/cli.commands.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,17 @@ describe("CLI command registration", () => {
2626
'["keyframes", "Inspect keyframes and render onion-shot diagnostics"]',
2727
);
2828
});
29+
30+
// A command actively reconciling skills (`skills check`/`skills update`)
31+
// must not also nudge the user to go reconcile skills — that nudge is
32+
// either redundant (it just ran) or misleading (a stale cached count from
33+
// the 24h background check, contradicting whatever it just reported).
34+
it("excludes 'skills' from the background skills-nudge gate, alongside 'upgrade' and 'events'", () => {
35+
const match = cliSource.match(/if \(([\s\S]*?)\) \{\s*\/\/ Report any completed auto-install/);
36+
expect(match, "expected to find the background nudge gate's if-condition").toBeTruthy();
37+
const condition = match![1]!;
38+
expect(condition).toContain('command !== "upgrade"');
39+
expect(condition).toContain('command !== "events"');
40+
expect(condition).toContain('command !== "skills"');
41+
});
2942
});

packages/cli/src/cli.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,19 @@ if (!isHelp && command !== "telemetry" && command !== "events" && command !== "u
225225

226226
// `events` skips the update check too — a skill-usage beacon must not add
227227
// network latency or trigger a background self-upgrade on the calling skill.
228-
if (!isHelp && !hasJsonFlag && command !== "upgrade" && command !== "events") {
228+
// `skills` is excluded from the SKILLS nudge for the same reason `upgrade` is
229+
// excluded from the self-update notice: a command that is itself actively
230+
// checking/reconciling skills (`skills check`, `skills update`) must not also
231+
// tell the user to go run `skills update` — that's either redundant (it just
232+
// did) or, worse, misleading (it printed a stale nudge count from the last
233+
// cached check while reporting fresh results of its own).
234+
if (
235+
!isHelp &&
236+
!hasJsonFlag &&
237+
command !== "upgrade" &&
238+
command !== "events" &&
239+
command !== "skills"
240+
) {
229241
// Report any completed auto-install from the previous run first, before
230242
// kicking off the next check — so the user sees "updated to vX" once and
231243
// we don't over-print.

packages/cli/src/commands/skills.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,11 @@ vi.mock("../utils/skillsManifest.js", async (importOriginal) => {
9797
checkSkills: vi.fn(async () => DEFAULT_CHECK),
9898
hyperframesSkillNames: vi.fn(() => ["hyperframes"]),
9999
presentSkills: vi.fn((names: readonly string[]) => [...names]),
100+
// Default: nothing left to prune after `runSkillsRemove`. The real
101+
// (unmocked) fs-level behavior is covered in skillsManifest.test.ts;
102+
// here we only assert the wiring — what update passes in, and that it
103+
// isn't reached when there's nothing removed.
104+
pruneOrphanedLockEntries: vi.fn(() => []),
100105
};
101106
});
102107

@@ -383,6 +388,81 @@ describe("hyperframes skills", () => {
383388
expect(state.spawnCalls.some((s) => s.args.includes("remove"))).toBe(false);
384389
});
385390

391+
// Retired-skill regression (variant 1): the update engine's OWN targeted-
392+
// install check must resolve the canonical (published) manifest, never a
393+
// stale local `skills-manifest.json` a checkout might still have lying
394+
// around — see resolveLatestManifest's in-repo shortcut. Without this, a
395+
// skill retired upstream but still listed locally gets forced into
396+
// `targets` (isCoreSkill pattern-matches `hyperframes-*`), `skills add`
397+
// silently declines to install something that doesn't exist canonically,
398+
// and the old code strict-threw on a "failure" that was never real.
399+
it("checks freshness against the canonical manifest, never a possibly-stale local one", async () => {
400+
setPlatform("linux");
401+
const { checkSkills } = await import("../utils/skillsManifest.js");
402+
403+
await runSkillsUpdate();
404+
405+
// The update engine's own check (first call) must ask for canonical;
406+
// the prune's check (last call, tested separately) intentionally doesn't.
407+
expect(checkSkills).toHaveBeenNthCalledWith(1, expect.objectContaining({ canonical: true }));
408+
});
409+
410+
// Retired-skill regression (variant 2): `skills remove` is a silent no-op
411+
// for a lock entry with no on-disk bundle (upstream scans disk, not the
412+
// lock, to decide what's "installed" — see pruneOrphanedLockEntries's
413+
// doc comment). `skills update` must self-heal that lock entry itself so
414+
// `check || update` actually converges instead of re-flagging it forever.
415+
it("self-heals an orphaned lock entry after `skills remove` no-ops on it", async () => {
416+
setPlatform("linux");
417+
const { checkSkills, pruneOrphanedLockEntries } = await import("../utils/skillsManifest.js");
418+
vi.mocked(checkSkills)
419+
.mockResolvedValueOnce(DEFAULT_CHECK as never)
420+
.mockResolvedValueOnce({
421+
scope: "global",
422+
skills: [{ name: "hyperframes-captions", status: "removed" }],
423+
} as never);
424+
vi.mocked(pruneOrphanedLockEntries).mockReturnValueOnce(["hyperframes-captions"]);
425+
426+
await runSkillsUpdate();
427+
428+
expect(pruneOrphanedLockEntries).toHaveBeenCalledWith(["hyperframes-captions"], "global");
429+
expect(process.exitCode).toBe(0);
430+
});
431+
432+
// The idempotent-second-run contract at the command level: once nothing is
433+
// left attributed as removed (the fs-level idempotency of the prune itself
434+
// is covered directly in skillsManifest.test.ts), a second `skills update`
435+
// must be a clean no-op — no `skills remove` spawn, no prune call finding
436+
// anything, still exit 0.
437+
it("running update twice in a row converges — the second run prunes nothing", async () => {
438+
setPlatform("linux");
439+
const { checkSkills, pruneOrphanedLockEntries } = await import("../utils/skillsManifest.js");
440+
vi.mocked(checkSkills)
441+
.mockResolvedValueOnce(DEFAULT_CHECK as never)
442+
.mockResolvedValueOnce({
443+
scope: "global",
444+
skills: [{ name: "hyperframes-captions", status: "removed" }],
445+
} as never);
446+
vi.mocked(pruneOrphanedLockEntries).mockReturnValueOnce(["hyperframes-captions"]);
447+
448+
await runSkillsUpdate();
449+
expect(process.exitCode).toBe(0);
450+
expect(state.spawnCalls.some((s) => s.args.includes("remove"))).toBe(true);
451+
452+
// Second run: nothing attributed as removed anymore (the lock entry was
453+
// pruned above), so there's nothing left to reconcile.
454+
state.spawnCalls = [];
455+
vi.mocked(checkSkills)
456+
.mockResolvedValueOnce(DEFAULT_CHECK as never)
457+
.mockResolvedValueOnce({ scope: "global", skills: [] } as never);
458+
459+
await runSkillsUpdate();
460+
expect(process.exitCode).toBe(0);
461+
expect(state.spawnCalls.some((s) => s.args.includes("remove"))).toBe(false);
462+
// Nothing to prune this time — pruneOrphanedLockEntries isn't even reached.
463+
expect(pruneOrphanedLockEntries).toHaveBeenCalledTimes(1);
464+
});
465+
386466
// `update`'s prune runs the same removed-detection as `check`, so its
387467
// --source/--dir must reach the internal checkSkills() — otherwise the prune
388468
// reconciles against defaults even when the user pointed elsewhere.
@@ -615,6 +695,40 @@ describe("hyperframes skills update <names>", () => {
615695
expect(process.exitCode).toBe(1);
616696
});
617697

698+
it("a malformed canonical manifest warns distinctly, then still degrades to presence mode", async () => {
699+
setPlatform("linux");
700+
const clack = await import("@clack/prompts");
701+
vi.mocked(clack.log.warn).mockClear();
702+
const { checkSkills } = await import("../utils/skillsManifest.js");
703+
vi.mocked(checkSkills).mockRejectedValue(
704+
new Error("Malformed skills manifest from https://raw.githubusercontent.com/…"),
705+
);
706+
707+
await runSkillsUpdateWith(["pr-to-video"]);
708+
709+
const warnedMalformed = vi
710+
.mocked(clack.log.warn)
711+
.mock.calls.some((args) => String(args[0]).includes("malformed"));
712+
expect(warnedMalformed).toBe(true);
713+
// Still degrades rather than failing the whole command.
714+
expect(process.exitCode).toBe(0);
715+
});
716+
717+
it("a genuine offline error degrades silently — no malformed-manifest warning", async () => {
718+
setPlatform("linux");
719+
const clack = await import("@clack/prompts");
720+
vi.mocked(clack.log.warn).mockClear();
721+
const { checkSkills } = await import("../utils/skillsManifest.js");
722+
vi.mocked(checkSkills).mockRejectedValue(new Error("fetch failed"));
723+
724+
await runSkillsUpdateWith(["pr-to-video"]);
725+
726+
const warnedMalformed = vi
727+
.mocked(clack.log.warn)
728+
.mock.calls.some((args) => String(args[0]).includes("malformed"));
729+
expect(warnedMalformed).toBe(false);
730+
});
731+
618732
it("--json emits a parseable result on success", async () => {
619733
setPlatform("linux");
620734
const logSpy = vi.spyOn(console, "log");

packages/cli/src/commands/skills.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
hyperframesSkillNames,
1111
isCoreSkill,
1212
presentSkills,
13+
pruneOrphanedLockEntries,
1314
SKILLS_CLI_LOCK_PATHS_VERIFIED_AT,
1415
type SkillDiff,
1516
type SkillsCheckResult,
@@ -307,8 +308,29 @@ export async function updateSkills(
307308

308309
let check: SkillsCheckResult | null = null;
309310
try {
310-
check = await checkSkills({ cwd: opts.cwd });
311-
} catch {
311+
// `canonical: true` — target selection must match what `skills add`
312+
// actually installs from (the canonical published repo), never a local
313+
// checkout's `skills-manifest.json`. Without this, running from inside a
314+
// stale hyperframes checkout could resolve "latest" from that stale local
315+
// file, which may still list a skill that's since been retired/renamed
316+
// upstream. `isCoreSkill` would then force it into `targets`/`toInstall`,
317+
// `skills add` would correctly (and silently) decline to install a skill
318+
// that no longer exists, and verifyInstalled would strict-throw on a
319+
// "failure" that was never real. Resolving canonically means a retired
320+
// skill simply never appears as a target in the first place.
321+
check = await checkSkills({ cwd: opts.cwd, canonical: true });
322+
} catch (err) {
323+
// A *malformed* canonical manifest (the server was reached, but served a
324+
// bad shape) is otherwise indistinguishable from being offline — both fall
325+
// through to presence-only mode below. Surface it distinctly so ops can
326+
// tell an upstream/CDN problem apart from a genuine network failure.
327+
if (err instanceof Error && err.message.startsWith("Malformed skills manifest")) {
328+
clack.log.warn(
329+
c.warn(
330+
"Canonical skills manifest was malformed — falling back to presence-only mode (an upstream/CDN issue, not your network).",
331+
),
332+
);
333+
}
312334
check = null; // manifest unreachable (offline / rate-limited) — presence mode below
313335
}
314336
if (!check) return updateSkillsOffline(requested, { strict, cwd: opts.cwd });
@@ -687,6 +709,24 @@ const updateCommand = defineCommand({
687709
c.dim(`Removing ${removed.length} skill(s) no longer published: ${removed.join(", ")}`),
688710
);
689711
await runSkillsRemove(removed, { global: scope === "global" });
712+
// Self-heal: `skills remove` only clears a lock entry for a name it
713+
// found an on-disk bundle for (see pruneOrphanedLockEntries). A skill
714+
// retired before it ever shipped a bundle to this machine has none, so
715+
// the call above is a silent no-op for it — the lock entry lingers and
716+
// would be re-flagged "removed" on every future run. Prune whatever is
717+
// still attributed after the call so `check || update` converges
718+
// instead of looping forever. Best-effort and scoped to exactly the
719+
// lock the remove above targeted (same `scope`); a write failure here
720+
// must not fail the update — the install already succeeded.
721+
const scopeForPrune = scope ?? "global";
722+
const stillOrphaned = pruneOrphanedLockEntries(removed, scopeForPrune);
723+
if (stillOrphaned.length) {
724+
console.log(
725+
c.dim(
726+
`Reconciled ${stillOrphaned.length} orphaned lock entr${stillOrphaned.length === 1 ? "y" : "ies"} with no on-disk bundle: ${stillOrphaned.join(", ")}`,
727+
),
728+
);
729+
}
690730
}
691731
} catch (err) {
692732
clack.log.warn(c.warn(`Skipped removed-skill cleanup: ${(err as Error).message}`));

packages/cli/src/telemetry/config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ export interface HyperframesConfig {
6363
skillsOutdatedCount?: number;
6464
/** How many skills were missing (not installed) at the last check. */
6565
skillsMissingCount?: number;
66+
/** How many installed skills were flagged removed-upstream at the last check. */
67+
skillsRemovedCount?: number;
6668
}
6769

6870
const DEFAULT_CONFIG: HyperframesConfig = {
@@ -108,6 +110,7 @@ export function readConfig(): HyperframesConfig {
108110
skillsUpdateAvailable: parsed.skillsUpdateAvailable,
109111
skillsOutdatedCount: parsed.skillsOutdatedCount,
110112
skillsMissingCount: parsed.skillsMissingCount,
113+
skillsRemovedCount: parsed.skillsRemovedCount,
111114
};
112115

113116
cachedConfig = config;

0 commit comments

Comments
 (0)