Files
hyperframes/packages/cli/src/utils/skillsUpdateCheck.ts
T
Miguel Ángel b1f1c0571e 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.
2026-07-10 19:57:55 -04:00

102 lines
4.1 KiB
TypeScript

// Passive "your skills are stale" nudge. Mirrors updateCheck.ts: a background
// check populates a 24h cache; printSkillsUpdateNotice() reads the cache
// synchronously and prints one line on exit.
//
// Why a passive nudge (not just `skills check`): agents don't reliably run a
// check on their own, but they DO run render/lint/validate — so we piggyback
// the reminder on the commands they already run.
import { readConfig, writeConfig } from "../telemetry/config.js";
import { checkSkills } from "./skillsManifest.js";
import { updateNoticesSuppressed } from "./updateCheck.js";
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
export interface SkillsUpdateMeta {
updateAvailable: boolean;
outdated: number;
missing: number;
/** Installed skills flagged removed-upstream (renamed/dropped) at the last check. */
removed: number;
}
/** Synchronous read from cache — never fetches. */
function getSkillsUpdateMeta(): SkillsUpdateMeta {
const config = readConfig();
return {
updateAvailable: config.skillsUpdateAvailable ?? false,
outdated: config.skillsOutdatedCount ?? 0,
missing: config.skillsMissingCount ?? 0,
removed: config.skillsRemovedCount ?? 0,
};
}
function cacheFresh(lastSkillsCheck: string | undefined, now: number): boolean {
if (!lastSkillsCheck) return false;
return now - new Date(lastSkillsCheck).getTime() < CHECK_INTERVAL_MS;
}
/** Run the real check and persist the result to the cache. */
async function refreshSkillsCache(): Promise<SkillsUpdateMeta> {
// `canonical: true` so this nudge's counts agree with `updateSkills`'s
// source of truth — otherwise a stale in-repo skills-manifest.json (e.g.
// inside a hyperframes checkout) can produce a false-positive count here.
const result = await checkSkills({ canonical: true });
// Only record a meaningful check when skills were actually found.
if (result.location) {
const config = readConfig();
config.lastSkillsCheck = new Date().toISOString();
config.skillsUpdateAvailable = result.updateAvailable;
config.skillsOutdatedCount = result.summary.outdated;
// Core-missing only: skills that install on demand (workflows not yet
// triggered on this machine) are not "missing" worth nagging about.
config.skillsMissingCount = result.summary.coreMissing;
// Removed-upstream skills are just as reconcilable as outdated/missing
// ones (a plain `skills update` prunes them) — omitting them here is what
// made the nudge undercount (e.g. reporting "2 skills out of date or
// missing" while a 3rd, renamed/dropped skill sat unmentioned).
config.skillsRemovedCount = result.summary.removed;
writeConfig(config);
}
return {
updateAvailable: result.updateAvailable,
outdated: result.summary.outdated,
missing: result.summary.coreMissing,
removed: result.summary.removed,
};
}
/**
* Refresh the skills freshness cache if it is older than 24h. Best-effort:
* any failure (offline, no manifest published yet, no skills installed) leaves
* the cache untouched and reports "no update".
*
* @param force - skip the cache and check now
*/
export async function checkSkillsForUpdate(force?: boolean): Promise<SkillsUpdateMeta> {
if (!force && cacheFresh(readConfig().lastSkillsCheck, Date.now())) return getSkillsUpdateMeta();
try {
return await refreshSkillsCache();
} catch {
return getSkillsUpdateMeta();
}
}
/** The stale-skills nudge text, or null when nothing is outdated, missing, or removed. */
function skillsNoticeText(meta: SkillsUpdateMeta): string | null {
const total = meta.outdated + meta.missing + meta.removed;
if (total < 1) return null;
const noun = total === 1 ? "skill" : "skills";
return `\n ${total} HyperFrames ${noun} out of date or missing.\n Run: npx hyperframes skills update\n\n`;
}
/**
* Print a one-line nudge to stderr if installed skills are stale. Same gating
* as the CLI self-update notice (CI, non-TTY, dev, HYPERFRAMES_NO_UPDATE_CHECK).
*/
export function printSkillsUpdateNotice(): void {
if (updateNoticesSuppressed()) return;
const text = skillsNoticeText(getSkillsUpdateMeta());
if (text) process.stderr.write(text);
}