Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions packages/cli/src/cli.commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,17 @@ describe("CLI command registration", () => {
'["keyframes", "Inspect keyframes and render onion-shot diagnostics"]',
);
});

// A command actively reconciling skills (`skills check`/`skills update`)
// must not also nudge the user to go reconcile skills — that nudge is
// either redundant (it just ran) or misleading (a stale cached count from
// the 24h background check, contradicting whatever it just reported).
it("excludes 'skills' from the background skills-nudge gate, alongside 'upgrade' and 'events'", () => {
const match = cliSource.match(/if \(([\s\S]*?)\) \{\s*\/\/ Report any completed auto-install/);
expect(match, "expected to find the background nudge gate's if-condition").toBeTruthy();
const condition = match![1]!;
expect(condition).toContain('command !== "upgrade"');
expect(condition).toContain('command !== "events"');
expect(condition).toContain('command !== "skills"');
});
});
14 changes: 13 additions & 1 deletion packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,19 @@ if (!isHelp && command !== "telemetry" && command !== "events" && command !== "u

// `events` skips the update check too — a skill-usage beacon must not add
// network latency or trigger a background self-upgrade on the calling skill.
if (!isHelp && !hasJsonFlag && command !== "upgrade" && command !== "events") {
// `skills` is excluded from the SKILLS nudge for the same reason `upgrade` is
// excluded from the self-update notice: a command that is itself actively
// checking/reconciling skills (`skills check`, `skills update`) must not also
// tell the user to go run `skills update` — that's either redundant (it just
// did) or, worse, misleading (it printed a stale nudge count from the last
// cached check while reporting fresh results of its own).
if (
!isHelp &&
!hasJsonFlag &&
command !== "upgrade" &&
command !== "events" &&
command !== "skills"
) {
// Report any completed auto-install from the previous run first, before
// kicking off the next check — so the user sees "updated to vX" once and
// we don't over-print.
Expand Down
114 changes: 114 additions & 0 deletions packages/cli/src/commands/skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@ vi.mock("../utils/skillsManifest.js", async (importOriginal) => {
checkSkills: vi.fn(async () => DEFAULT_CHECK),
hyperframesSkillNames: vi.fn(() => ["hyperframes"]),
presentSkills: vi.fn((names: readonly string[]) => [...names]),
// Default: nothing left to prune after `runSkillsRemove`. The real
// (unmocked) fs-level behavior is covered in skillsManifest.test.ts;
// here we only assert the wiring — what update passes in, and that it
// isn't reached when there's nothing removed.
pruneOrphanedLockEntries: vi.fn(() => []),
};
});

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

// Retired-skill regression (variant 1): the update engine's OWN targeted-
// install check must resolve the canonical (published) manifest, never a
// stale local `skills-manifest.json` a checkout might still have lying
// around — see resolveLatestManifest's in-repo shortcut. Without this, a
// skill retired upstream but still listed locally gets forced into
// `targets` (isCoreSkill pattern-matches `hyperframes-*`), `skills add`
// silently declines to install something that doesn't exist canonically,
// and the old code strict-threw on a "failure" that was never real.
it("checks freshness against the canonical manifest, never a possibly-stale local one", async () => {
setPlatform("linux");
const { checkSkills } = await import("../utils/skillsManifest.js");

await runSkillsUpdate();

// The update engine's own check (first call) must ask for canonical;
// the prune's check (last call, tested separately) intentionally doesn't.
expect(checkSkills).toHaveBeenNthCalledWith(1, expect.objectContaining({ canonical: true }));
});

// Retired-skill regression (variant 2): `skills remove` is a silent no-op
// for a lock entry with no on-disk bundle (upstream scans disk, not the
// lock, to decide what's "installed" — see pruneOrphanedLockEntries's
// doc comment). `skills update` must self-heal that lock entry itself so
// `check || update` actually converges instead of re-flagging it forever.
it("self-heals an orphaned lock entry after `skills remove` no-ops on it", async () => {
setPlatform("linux");
const { checkSkills, pruneOrphanedLockEntries } = await import("../utils/skillsManifest.js");
vi.mocked(checkSkills)
.mockResolvedValueOnce(DEFAULT_CHECK as never)
.mockResolvedValueOnce({
scope: "global",
skills: [{ name: "hyperframes-captions", status: "removed" }],
} as never);
vi.mocked(pruneOrphanedLockEntries).mockReturnValueOnce(["hyperframes-captions"]);

await runSkillsUpdate();

expect(pruneOrphanedLockEntries).toHaveBeenCalledWith(["hyperframes-captions"], "global");
expect(process.exitCode).toBe(0);
});

// The idempotent-second-run contract at the command level: once nothing is
// left attributed as removed (the fs-level idempotency of the prune itself
// is covered directly in skillsManifest.test.ts), a second `skills update`
// must be a clean no-op — no `skills remove` spawn, no prune call finding
// anything, still exit 0.
it("running update twice in a row converges — the second run prunes nothing", async () => {
setPlatform("linux");
const { checkSkills, pruneOrphanedLockEntries } = await import("../utils/skillsManifest.js");
vi.mocked(checkSkills)
.mockResolvedValueOnce(DEFAULT_CHECK as never)
.mockResolvedValueOnce({
scope: "global",
skills: [{ name: "hyperframes-captions", status: "removed" }],
} as never);
vi.mocked(pruneOrphanedLockEntries).mockReturnValueOnce(["hyperframes-captions"]);

await runSkillsUpdate();
expect(process.exitCode).toBe(0);
expect(state.spawnCalls.some((s) => s.args.includes("remove"))).toBe(true);

// Second run: nothing attributed as removed anymore (the lock entry was
// pruned above), so there's nothing left to reconcile.
state.spawnCalls = [];
vi.mocked(checkSkills)
.mockResolvedValueOnce(DEFAULT_CHECK as never)
.mockResolvedValueOnce({ scope: "global", skills: [] } as never);

await runSkillsUpdate();
expect(process.exitCode).toBe(0);
expect(state.spawnCalls.some((s) => s.args.includes("remove"))).toBe(false);
// Nothing to prune this time — pruneOrphanedLockEntries isn't even reached.
expect(pruneOrphanedLockEntries).toHaveBeenCalledTimes(1);
});

// `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.
Expand Down Expand Up @@ -615,6 +695,40 @@ describe("hyperframes skills update <names>", () => {
expect(process.exitCode).toBe(1);
});

it("a malformed canonical manifest warns distinctly, then still degrades to presence mode", async () => {
setPlatform("linux");
const clack = await import("@clack/prompts");
vi.mocked(clack.log.warn).mockClear();
const { checkSkills } = await import("../utils/skillsManifest.js");
vi.mocked(checkSkills).mockRejectedValue(
new Error("Malformed skills manifest from https://raw.githubusercontent.com/…"),
);

await runSkillsUpdateWith(["pr-to-video"]);

const warnedMalformed = vi
.mocked(clack.log.warn)
.mock.calls.some((args) => String(args[0]).includes("malformed"));
expect(warnedMalformed).toBe(true);
// Still degrades rather than failing the whole command.
expect(process.exitCode).toBe(0);
});

it("a genuine offline error degrades silently — no malformed-manifest warning", async () => {
setPlatform("linux");
const clack = await import("@clack/prompts");
vi.mocked(clack.log.warn).mockClear();
const { checkSkills } = await import("../utils/skillsManifest.js");
vi.mocked(checkSkills).mockRejectedValue(new Error("fetch failed"));

await runSkillsUpdateWith(["pr-to-video"]);

const warnedMalformed = vi
.mocked(clack.log.warn)
.mock.calls.some((args) => String(args[0]).includes("malformed"));
expect(warnedMalformed).toBe(false);
});

it("--json emits a parseable result on success", async () => {
setPlatform("linux");
const logSpy = vi.spyOn(console, "log");
Expand Down
44 changes: 42 additions & 2 deletions packages/cli/src/commands/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
hyperframesSkillNames,
isCoreSkill,
presentSkills,
pruneOrphanedLockEntries,
SKILLS_CLI_LOCK_PATHS_VERIFIED_AT,
type SkillDiff,
type SkillsCheckResult,
Expand Down Expand Up @@ -307,8 +308,29 @@ export async function updateSkills(

let check: SkillsCheckResult | null = null;
try {
check = await checkSkills({ cwd: opts.cwd });
} catch {
// `canonical: true` — target selection must match what `skills add`
// actually installs from (the canonical published repo), never a local
// checkout's `skills-manifest.json`. Without this, running from inside a
// stale hyperframes checkout could resolve "latest" from that stale local
// file, which may still list a skill that's since been retired/renamed
// upstream. `isCoreSkill` would then force it into `targets`/`toInstall`,
// `skills add` would correctly (and silently) decline to install a skill
// that no longer exists, and verifyInstalled would strict-throw on a
// "failure" that was never real. Resolving canonically means a retired
// skill simply never appears as a target in the first place.
check = await checkSkills({ cwd: opts.cwd, canonical: true });
} catch (err) {
// A *malformed* canonical manifest (the server was reached, but served a
// bad shape) is otherwise indistinguishable from being offline — both fall
// through to presence-only mode below. Surface it distinctly so ops can
// tell an upstream/CDN problem apart from a genuine network failure.
if (err instanceof Error && err.message.startsWith("Malformed skills manifest")) {
clack.log.warn(
c.warn(
"Canonical skills manifest was malformed — falling back to presence-only mode (an upstream/CDN issue, not your network).",
),
);
}
check = null; // manifest unreachable (offline / rate-limited) — presence mode below
}
if (!check) return updateSkillsOffline(requested, { strict, cwd: opts.cwd });
Expand Down Expand Up @@ -687,6 +709,24 @@ const updateCommand = defineCommand({
c.dim(`Removing ${removed.length} skill(s) no longer published: ${removed.join(", ")}`),
);
await runSkillsRemove(removed, { global: scope === "global" });
// Self-heal: `skills remove` only clears a lock entry for a name it
// found an on-disk bundle for (see pruneOrphanedLockEntries). A skill
// retired before it ever shipped a bundle to this machine has none, so
// the call above is a silent no-op for it — the lock entry lingers and
// would be re-flagged "removed" on every future run. Prune whatever is
// still attributed after the call so `check || update` converges
// instead of looping forever. Best-effort and scoped to exactly the
// lock the remove above targeted (same `scope`); a write failure here
// must not fail the update — the install already succeeded.
const scopeForPrune = scope ?? "global";
const stillOrphaned = pruneOrphanedLockEntries(removed, scopeForPrune);
if (stillOrphaned.length) {
console.log(
c.dim(
`Reconciled ${stillOrphaned.length} orphaned lock entr${stillOrphaned.length === 1 ? "y" : "ies"} with no on-disk bundle: ${stillOrphaned.join(", ")}`,
),
);
}
}
} catch (err) {
clack.log.warn(c.warn(`Skipped removed-skill cleanup: ${(err as Error).message}`));
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/telemetry/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ export interface HyperframesConfig {
skillsOutdatedCount?: number;
/** How many skills were missing (not installed) at the last check. */
skillsMissingCount?: number;
/** How many installed skills were flagged removed-upstream at the last check. */
skillsRemovedCount?: number;
}

const DEFAULT_CONFIG: HyperframesConfig = {
Expand Down Expand Up @@ -108,6 +110,7 @@ export function readConfig(): HyperframesConfig {
skillsUpdateAvailable: parsed.skillsUpdateAvailable,
skillsOutdatedCount: parsed.skillsOutdatedCount,
skillsMissingCount: parsed.skillsMissingCount,
skillsRemovedCount: parsed.skillsRemovedCount,
};

cachedConfig = config;
Expand Down
Loading
Loading