mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 10:14:30 +00:00
`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.
102 lines
4.1 KiB
TypeScript
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);
|
|
}
|