mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +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.
730 lines
30 KiB
TypeScript
730 lines
30 KiB
TypeScript
// Skills freshness: give the HyperFrames skill bundle a content fingerprint so
|
|
// we can answer "are the installed skills the latest version?" across every
|
|
// agent platform (Claude Code, Codex, …) — independent of how they were
|
|
// installed.
|
|
//
|
|
// Why our own hash instead of the `skills-lock.json` `computedHash`: the
|
|
// vercel-labs/skills lock hashes only `SKILL.md` with an algorithm we can't
|
|
// recompute from source. A skill is a whole directory (SKILL.md + references/ +
|
|
// scripts/ + palettes/ + templates/), so we fingerprint the *entire* bundle.
|
|
// The same function hashes the source tree (to build the published manifest)
|
|
// and the installed tree (to compare) — so equal content ⇒ equal hash.
|
|
//
|
|
// The manifest is intentionally minimal — `{ source, skills }`, no version
|
|
// label or timestamp. Per-skill hashes are the source of truth for "current vs
|
|
// outdated", so a top-level version number would only add a second, confusable
|
|
// signal. The published manifest lives at the repo root (`skills-manifest.json`).
|
|
|
|
import { execFile } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import {
|
|
existsSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
renameSync,
|
|
statSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { homedir } from "node:os";
|
|
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
import { promisify } from "node:util";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
// File extensions we treat as text — line endings are normalised (CRLF→LF)
|
|
// before hashing so a Windows checkout doesn't read as "outdated". Everything
|
|
// else is hashed as raw bytes.
|
|
const TEXT_EXT = new Set([
|
|
".md",
|
|
".txt",
|
|
".mjs",
|
|
".js",
|
|
".ts",
|
|
".jsx",
|
|
".tsx",
|
|
".html",
|
|
".css",
|
|
".json",
|
|
".svg",
|
|
".csv",
|
|
".yml",
|
|
".yaml",
|
|
]);
|
|
|
|
export interface SkillEntry {
|
|
/** Short sha256 (16 hex chars) over the skill's whole directory. */
|
|
hash: string;
|
|
/** Number of files in the bundle (for a quick human sanity signal). */
|
|
files: number;
|
|
}
|
|
|
|
export interface SkillsManifest {
|
|
/** Source repo, e.g. "heygen-com/hyperframes". */
|
|
source: string;
|
|
/** Per-skill fingerprint, keyed by skill name. */
|
|
skills: Record<string, SkillEntry>;
|
|
}
|
|
|
|
// "removed" = installed from our source but no longer in the manifest (renamed
|
|
// or dropped upstream). Attributed via the skills lock — see detectRemoved.
|
|
export type SkillStatus = "current" | "outdated" | "missing" | "removed";
|
|
|
|
export interface SkillDiff {
|
|
name: string;
|
|
status: SkillStatus;
|
|
installedHash?: string;
|
|
latestHash?: string;
|
|
}
|
|
|
|
/** The pure manifest diff (current / outdated / missing — what `diffSkills` returns). */
|
|
export interface SkillsDiff {
|
|
updateAvailable: boolean;
|
|
/** `coreMissing` ⊆ `missing`: the missing skills that are core (see "Skill tiers"). */
|
|
summary: { current: number; outdated: number; missing: number; coreMissing: number };
|
|
skills: SkillDiff[];
|
|
}
|
|
|
|
export interface SkillsCheckResult {
|
|
/** Install location that was checked (absolute path), or null if none found. */
|
|
location: string | null;
|
|
/** Agent convention inferred from the location (claude-code, codex, …). */
|
|
agent: string | null;
|
|
/** Scope of the located install — so a caller prunes in the same scope it attributed from. */
|
|
scope: "project" | "global" | null;
|
|
updateAvailable: boolean;
|
|
summary: {
|
|
current: number;
|
|
outdated: number;
|
|
missing: number;
|
|
coreMissing: number;
|
|
removed: number;
|
|
};
|
|
skills: SkillDiff[];
|
|
/**
|
|
* True when an install was located but the upstream skills lock was absent at
|
|
* the expected path, so removed-detection couldn't run (it silently reports
|
|
* zero removed). Lets the CLI warn instead of misreporting "up to date" — a
|
|
* guard against the lock path silently no-op'ing if upstream moves it.
|
|
*/
|
|
lockMissing: boolean;
|
|
}
|
|
|
|
const DEFAULT_REPO_SLUG = "heygen-com/hyperframes";
|
|
/** Manifest filename, published at the repo root. */
|
|
export const MANIFEST_FILE = "skills-manifest.json";
|
|
const FETCH_TIMEOUT_MS = 4000;
|
|
|
|
// ── Skill tiers ──────────────────────────────────────────────────────────────
|
|
//
|
|
// Two tiers decide what installs eagerly vs on demand:
|
|
//
|
|
// core — the `/hyperframes` entry router plus the shared domain skills
|
|
// (`hyperframes-*`, `media-use`) that every creation workflow
|
|
// references structurally (sibling `../hyperframes-animation/…`
|
|
// paths, "call /media-use" preambles). These must be present and
|
|
// current for ANY workflow to run, so `init` / `skills update`
|
|
// keep them fresh.
|
|
// on-demand — everything else: the end-user workflow skills (pr-to-video,
|
|
// embedded-captions, …) and optional integrations (figma). They
|
|
// install lazily, when their workflow is actually triggered
|
|
// (`hyperframes skills update <name>`), instead of being sprayed
|
|
// onto every machine that runs `init`.
|
|
|
|
/** The entry/router skill — the capability map that routes every request. */
|
|
const ENTRY_SKILL = "hyperframes";
|
|
|
|
/** True for skills every workflow depends on (see "Skill tiers" above). */
|
|
export function isCoreSkill(name: string): boolean {
|
|
return name === ENTRY_SKILL || name.startsWith("hyperframes-") || name === "media-use";
|
|
}
|
|
|
|
/**
|
|
* Pinned enumeration of the core tier, used ONLY when the live manifest is
|
|
* unreachable (offline / rate-limited): isCoreSkill is a pattern, and a
|
|
* pattern can't be enumerated without a name list. Best-effort by design —
|
|
* if this lags the skills/ tree, the degraded path misses (or over-asks for)
|
|
* a core skill until the next release, and the online path self-corrects on
|
|
* the next run. A unit test pins this list to the repo's skills/ tree so it
|
|
* can't drift silently; update it when core membership changes.
|
|
*/
|
|
export const FALLBACK_CORE_SKILLS: readonly string[] = [
|
|
"hyperframes",
|
|
"hyperframes-animation",
|
|
"hyperframes-cli",
|
|
"hyperframes-core",
|
|
"hyperframes-creative",
|
|
"hyperframes-keyframes",
|
|
"hyperframes-registry",
|
|
"media-use",
|
|
];
|
|
|
|
// ── Hashing ────────────────────────────────────────────────────────────────
|
|
|
|
function listFilesSorted(dir: string): string[] {
|
|
const out: string[] = [];
|
|
const walk = (d: string): void => {
|
|
for (const name of readdirSync(d)) {
|
|
if (name === ".DS_Store") continue;
|
|
const p = join(d, name);
|
|
if (statSync(p).isDirectory()) walk(p);
|
|
else out.push(p);
|
|
}
|
|
};
|
|
walk(dir);
|
|
// Sorting the full path list once is what guarantees a deterministic,
|
|
// filesystem-order-independent hash — no need to also sort per directory.
|
|
return out.sort();
|
|
}
|
|
|
|
/**
|
|
* Fingerprint one skill directory. Deterministic: files are sorted by relative
|
|
* POSIX path, text files are line-ending normalised, and the relative path is
|
|
* folded into the hash so a moved file changes the fingerprint.
|
|
*/
|
|
export function hashSkillBundle(skillDir: string): SkillEntry {
|
|
const files = listFilesSorted(skillDir);
|
|
const h = createHash("sha256");
|
|
for (const f of files) {
|
|
const rel = relative(skillDir, f).split(sep).join("/");
|
|
h.update(rel);
|
|
h.update("\0");
|
|
const ext = rel.slice(rel.lastIndexOf("."));
|
|
const buf = readFileSync(f);
|
|
if (TEXT_EXT.has(ext)) h.update(buf.toString("utf8").replace(/\r\n/g, "\n"), "utf8");
|
|
else h.update(buf);
|
|
h.update("\0");
|
|
}
|
|
return { hash: h.digest("hex").slice(0, 16), files: files.length };
|
|
}
|
|
|
|
/**
|
|
* Build a manifest from a `skills/` root directory (a folder of
|
|
* `<name>/SKILL.md` skill bundles). Used by the manifest generator. Output is
|
|
* fully deterministic — same content in, byte-identical manifest out.
|
|
*/
|
|
export function buildManifest(skillsRoot: string, meta: { source: string }): SkillsManifest {
|
|
const names = readdirSync(skillsRoot)
|
|
.filter((n) => existsSync(join(skillsRoot, n, "SKILL.md")))
|
|
.sort();
|
|
const skills: Record<string, SkillEntry> = {};
|
|
for (const name of names) skills[name] = hashSkillBundle(join(skillsRoot, name));
|
|
return { source: meta.source, skills };
|
|
}
|
|
|
|
// ── Locating installed skills ────────────────────────────────────────────────
|
|
|
|
interface SkillRoot {
|
|
/** Absolute path to a `.../skills` directory. */
|
|
dir: string;
|
|
/** Agent convention this directory belongs to. */
|
|
agent: string;
|
|
/** project = under cwd, global = under $HOME. */
|
|
scope: "project" | "global";
|
|
}
|
|
|
|
/**
|
|
* Map a host directory name to an agent label: ".claude" → "claude-code",
|
|
* ".factory" → "factory", "opencode" (under .config) → "opencode".
|
|
*/
|
|
function agentLabel(hostDir: string): string {
|
|
const name = hostDir.replace(/^\.+/, "");
|
|
return name === "claude" ? "claude-code" : name || "unknown";
|
|
}
|
|
|
|
/** Infer the agent from a `.../skills` path by its host segment (the dir above "skills"). */
|
|
function agentFromDir(dir: string): string {
|
|
const parts = dir.split(sep).filter(Boolean);
|
|
const i = parts.lastIndexOf("skills");
|
|
return agentLabel(i > 0 ? parts[i - 1]! : (parts[parts.length - 1] ?? ""));
|
|
}
|
|
|
|
/** Immediate subdirectory names of `dir` (including symlinked dirs); [] if unreadable. */
|
|
function listSubdirs(dir: string): string[] {
|
|
try {
|
|
return readdirSync(dir, { withFileTypes: true })
|
|
.filter((e) => e.isDirectory() || e.isSymbolicLink())
|
|
.map((e) => e.name);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Auto-discover candidate `<host>/skills` dirs under a scope base instead of
|
|
* enumerating a fixed list of agents. The upstream `skills` CLI installs into
|
|
* 70+ agent conventions; each lands as `<base>/<host>/skills` (or the XDG
|
|
* `<base>/.config/<host>/skills`), so we find them by structure — future-proof
|
|
* as upstream adds agents. claude-code is ordered first; the rest
|
|
* deterministically by agent then path.
|
|
*/
|
|
function discoverSkillRoots(base: string, scope: "project" | "global"): SkillRoot[] {
|
|
const candidates: SkillRoot[] = [];
|
|
const add = (hostBase: string, host: string): void => {
|
|
const dir = join(hostBase, host, "skills");
|
|
if (existsSync(dir) && statSync(dir).isDirectory())
|
|
candidates.push({ dir, agent: agentLabel(host), scope });
|
|
};
|
|
for (const host of listSubdirs(base)) add(base, host);
|
|
const xdg = join(base, ".config");
|
|
for (const host of listSubdirs(xdg)) add(xdg, host);
|
|
return candidates.sort((a, b) => {
|
|
if (a.agent !== b.agent) {
|
|
if (a.agent === "claude-code") return -1;
|
|
if (b.agent === "claude-code") return 1;
|
|
return a.agent.localeCompare(b.agent);
|
|
}
|
|
return a.dir.localeCompare(b.dir);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Decide whether an explicit `--dir` is a project- or global-scoped install, so
|
|
* removed-detection reads the *right* lock. The upstream `skills` CLI keeps two
|
|
* locks: a project lock at `<cwd>/skills-lock.json` and a global lock under
|
|
* `$HOME` (see lockPathForScope).
|
|
*
|
|
* Precedence is CWD-containment FIRST, then HOME — because the common
|
|
* project-local case is *also* under `$HOME` (e.g. `~/work/proj/.claude/skills`,
|
|
* or `--dir .claude/skills` run from `~/work/proj`). Checking HOME first would
|
|
* misclassify every such project install as global, reading the wrong lock and
|
|
* (worse) letting `skills update` prune with `-g`. So:
|
|
* - `dir` under `cwd` → project (even when that's also under $HOME)
|
|
* - else `dir` under $HOME → global (a real `~/.claude/skills`-style install)
|
|
* - else → project (safe default — never prune globally for an unknown path)
|
|
*
|
|
* Each base is normalised with a trailing separator before the prefix test so a
|
|
* sibling like `/home/user2` doesn't false-match `/home/user`.
|
|
*/
|
|
function scopeForDir(dir: string, home: string, cwd: string): "project" | "global" {
|
|
const norm = (p: string): string => {
|
|
const r = resolve(p);
|
|
return r.endsWith(sep) ? r : r + sep;
|
|
};
|
|
const d = norm(dir);
|
|
if (d.startsWith(norm(cwd))) return "project";
|
|
if (d.startsWith(norm(home))) return "global";
|
|
return "project";
|
|
}
|
|
|
|
/**
|
|
* Find the first skill root that actually contains HyperFrames skills. A
|
|
* `--dir` override (if given) is treated as a `.../skills` directory directly;
|
|
* its scope is inferred (see scopeForDir) so removed-detection reads the right
|
|
* lock. Otherwise scan global ($HOME) then project (cwd), auto-discovering hosts.
|
|
*
|
|
* Global is checked FIRST to match how agents actually load skills: Claude Code
|
|
* (and most others) give the personal/global scope priority over the project
|
|
* scope, and HyperFrames now installs globally. Checking global-first means
|
|
* `check` reports on the copy the agent will really use — not a stale project
|
|
* copy that a newer global install silently overrides.
|
|
*/
|
|
function locateInstall(
|
|
skillNames: string[],
|
|
opts: { dir?: string; cwd?: string; home?: string } = {},
|
|
): SkillRoot | null {
|
|
if (opts.dir) {
|
|
return existsSync(opts.dir)
|
|
? {
|
|
dir: opts.dir,
|
|
agent: agentFromDir(opts.dir),
|
|
scope: scopeForDir(opts.dir, opts.home ?? homedir(), opts.cwd ?? process.cwd()),
|
|
}
|
|
: null;
|
|
}
|
|
const roots = [
|
|
...discoverSkillRoots(opts.home ?? homedir(), "global"),
|
|
...discoverSkillRoots(opts.cwd ?? process.cwd(), "project"),
|
|
];
|
|
for (const root of roots) {
|
|
if (skillNames.some((n) => existsSync(join(root.dir, n, "SKILL.md")))) return root;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Names from `skillNames` that are present (their SKILL.md exists) in the
|
|
* located install. Local-only — no manifest fetch — so callers can verify the
|
|
* presence half of an install guarantee even when GitHub is unreachable.
|
|
*/
|
|
export function presentSkills(
|
|
skillNames: readonly string[],
|
|
opts: { dir?: string; cwd?: string; home?: string } = {},
|
|
): string[] {
|
|
const root = locateInstall([...skillNames], opts);
|
|
if (!root) return [];
|
|
return skillNames.filter((name) => existsSync(join(root.dir, name, "SKILL.md")));
|
|
}
|
|
|
|
/** Hash every manifest skill that is installed under `root`. */
|
|
function hashInstalled(root: SkillRoot, skillNames: string[]): Record<string, SkillEntry> {
|
|
const out: Record<string, SkillEntry> = {};
|
|
for (const name of skillNames) {
|
|
const skillDir = join(root.dir, name);
|
|
if (existsSync(join(skillDir, "SKILL.md"))) out[name] = hashSkillBundle(skillDir);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ── Diff ─────────────────────────────────────────────────────────────────────
|
|
|
|
export function diffSkills(
|
|
installed: Record<string, SkillEntry>,
|
|
latest: SkillsManifest,
|
|
): SkillsDiff {
|
|
// Report only on skills the manifest knows about. A skill on disk that isn't
|
|
// in the manifest is handled separately (see detectRemoved): we can only call
|
|
// one "ours but removed" via the lock's source attribution, never the bare
|
|
// directory name — `.../skills` is shared across sources.
|
|
const skills: SkillDiff[] = [];
|
|
const summary = { current: 0, outdated: 0, missing: 0, coreMissing: 0 };
|
|
|
|
for (const name of Object.keys(latest.skills).sort()) {
|
|
const latestEntry = latest.skills[name]!;
|
|
const installedEntry = installed[name];
|
|
let status: SkillStatus;
|
|
if (!installedEntry) status = "missing";
|
|
else if (installedEntry.hash === latestEntry.hash) status = "current";
|
|
else status = "outdated";
|
|
|
|
if (status === "current") summary.current++;
|
|
else if (status === "outdated") summary.outdated++;
|
|
else {
|
|
summary.missing++;
|
|
if (isCoreSkill(name)) summary.coreMissing++;
|
|
}
|
|
|
|
skills.push({
|
|
name,
|
|
status,
|
|
installedHash: installedEntry?.hash,
|
|
latestHash: latestEntry.hash,
|
|
});
|
|
}
|
|
|
|
return {
|
|
// "Update available" means the install is stale, not merely partial:
|
|
// anything installed-but-outdated, or a missing CORE skill (the entry
|
|
// router + shared domain skills every workflow needs). A missing
|
|
// on-demand skill is NOT an update — it installs when its workflow is
|
|
// triggered (`hyperframes skills update <name>`). Counting it here is what
|
|
// used to make `init` re-pull the full skill set onto machines that
|
|
// deliberately installed a subset.
|
|
updateAvailable: summary.outdated > 0 || summary.coreMissing > 0,
|
|
summary,
|
|
skills,
|
|
};
|
|
}
|
|
|
|
// ── Removed-upstream (orphaned) skills ────────────────────────────────────────
|
|
//
|
|
// `skills add` / `init` / `hyperframes skills update` only ever add or refresh —
|
|
// none of them delete a skill that was renamed or dropped upstream (e.g.
|
|
// graphic-overlays → talking-head-recut), so a stale bundle lingers forever and
|
|
// the manifest-only diff above can't see it. We surface these by cross-checking
|
|
// the vercel-labs/skills lock: a skill the lock attributes to OUR manifest
|
|
// `source` that the manifest no longer lists is "removed". Attribution is the
|
|
// whole point — `.../skills` is shared across sources, so we never infer "ours"
|
|
// from a directory name alone (that would flag every other source's skills too).
|
|
|
|
interface LockEntry {
|
|
source?: string;
|
|
sourceUrl?: string;
|
|
}
|
|
|
|
/** The slice of the vercel-labs/skills lock file we read. */
|
|
export interface SkillLock {
|
|
skills?: Record<string, LockEntry>;
|
|
}
|
|
|
|
/** Normalise a slug or git clone URL to a bare lowercase `owner/repo`. */
|
|
function repoSlug(s: string | undefined): string {
|
|
return (s ?? "")
|
|
.replace(/^git\+/, "")
|
|
.replace(/^https?:\/\/github\.com\//, "")
|
|
.replace(/\.git$/, "")
|
|
.toLowerCase();
|
|
}
|
|
|
|
/** Skill names the lock attributes to `source` (matched by slug or clone URL). */
|
|
export function skillsAttributedToSource(lock: SkillLock | null, source: string): string[] {
|
|
const want = repoSlug(source);
|
|
if (!want || !lock?.skills) return [];
|
|
return Object.entries(lock.skills)
|
|
.filter(([, e]) => repoSlug(e.source) === want || repoSlug(e.sourceUrl) === want)
|
|
.map(([name]) => name);
|
|
}
|
|
|
|
// Removed-detection reads the vercel-labs/skills lock, whose on-disk paths live
|
|
// in *their* repo, not ours — so if upstream moves the lock, our cross-reference
|
|
// silently finds nothing and `detectRemoved` no-ops without a peep. Pin the
|
|
// upstream version these paths were verified against so a future bump is a
|
|
// deliberate, reviewable edit (re-check src/skill-lock.ts `getSkillLockPath`
|
|
// and src/local-lock.ts `getLocalLockPath` when bumping):
|
|
// - global: $XDG_STATE_HOME/skills/.skill-lock.json else ~/.agents/.skill-lock.json
|
|
// - project: <cwd>/skills-lock.json
|
|
// https://github.com/vercel-labs/skills/blob/v1.5.13/src/skill-lock.ts (global)
|
|
// https://github.com/vercel-labs/skills/blob/v1.5.13/src/local-lock.ts (project)
|
|
export const SKILLS_CLI_LOCK_PATHS_VERIFIED_AT = "vercel-labs/skills@v1.5.13";
|
|
|
|
/** Locate the vercel-labs/skills lock for a scope (paths pinned to the version above). */
|
|
function lockPathForScope(
|
|
scope: "project" | "global",
|
|
opts: { cwd?: string; home?: string },
|
|
): string {
|
|
if (scope === "project") return join(opts.cwd ?? process.cwd(), "skills-lock.json");
|
|
const xdgStateHome = process.env.XDG_STATE_HOME;
|
|
if (xdgStateHome) return join(xdgStateHome, "skills", ".skill-lock.json");
|
|
return join(opts.home ?? homedir(), ".agents", ".skill-lock.json");
|
|
}
|
|
|
|
function readSkillLock(path: string): SkillLock | null {
|
|
try {
|
|
return JSON.parse(readFileSync(path, "utf8")) as SkillLock;
|
|
} catch {
|
|
// No lock (or unreadable / not JSON) → we can't attribute, so report none.
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The skill names the upstream lock attributes to HyperFrames, for a scope.
|
|
* The mirror MUST scope by this — never by listing `~/.claude/skills`, which is
|
|
* shared across sources, so a directory listing would fan a user's gstack /
|
|
* personal / company skills out to every agent. Same source-attribution the
|
|
* prune uses. Empty when the lock is absent (we can't attribute → mirror none).
|
|
*/
|
|
export function hyperframesSkillNames(opts: {
|
|
scope: "project" | "global";
|
|
cwd?: string;
|
|
home?: string;
|
|
}): string[] {
|
|
const lockPath = lockPathForScope(opts.scope, { cwd: opts.cwd, home: opts.home });
|
|
return skillsAttributedToSource(readSkillLock(lockPath), DEFAULT_REPO_SLUG);
|
|
}
|
|
|
|
interface RemovedResult {
|
|
removed: SkillDiff[];
|
|
/** The lock was absent at the expected path — removed-detection silently no-ops. */
|
|
lockMissing: boolean;
|
|
}
|
|
|
|
/** Skills the lock attributes to our source that the manifest no longer ships. */
|
|
function detectRemoved(
|
|
root: SkillRoot,
|
|
latest: SkillsManifest,
|
|
opts: { cwd?: string; home?: string },
|
|
): RemovedResult {
|
|
const lock = readSkillLock(lockPathForScope(root.scope, opts));
|
|
const removed = skillsAttributedToSource(lock, latest.source)
|
|
.filter((name) => !(name in latest.skills))
|
|
.sort()
|
|
.map((name) => ({ name, status: "removed" as const }));
|
|
return { removed, lockMissing: lock === null };
|
|
}
|
|
|
|
/**
|
|
* Remove `names` from the vercel-labs/skills lock at `scope`, writing the file
|
|
* back if anything changed. Self-heals the half of removed-upstream detection
|
|
* that `skills remove` can't: upstream's `remove` command scans ON-DISK skill
|
|
* directories to decide what's installed (see vercel-labs/skills'
|
|
* `removeCommand`), so a lock entry for a skill retired before it ever shipped
|
|
* a bundle to this machine has no on-disk dir to match. That makes `skills
|
|
* remove <name> -g --yes` a silent no-op — it prints "No matching skills found
|
|
* for: …" and exits 0 WITHOUT touching the lock. Left alone, `detectRemoved`
|
|
* re-flags the same lock entry as "removed" on every future run, forever.
|
|
*
|
|
* Reuses the pinned lock path (see SKILLS_CLI_LOCK_PATHS_VERIFIED_AT above —
|
|
* re-check that comment before bumping the upstream version this is pinned
|
|
* against) so this writes to exactly where the upstream CLI itself reads and
|
|
* writes the lock.
|
|
*
|
|
* Idempotent by construction: only entries still present in the lock are ever
|
|
* touched, so calling this again with the same names — after the upstream
|
|
* `skills remove` no-op reported above has already run once — finds nothing
|
|
* left and returns `[]`.
|
|
*/
|
|
export function pruneOrphanedLockEntries(
|
|
names: readonly string[],
|
|
scope: "project" | "global",
|
|
opts: { cwd?: string; home?: string } = {},
|
|
): string[] {
|
|
const path = lockPathForScope(scope, opts);
|
|
const lock = readSkillLock(path);
|
|
if (!lock?.skills) return [];
|
|
const pruned = names.filter((name) => name in lock.skills!);
|
|
if (pruned.length === 0) return [];
|
|
for (const name of pruned) delete lock.skills[name];
|
|
// Atomic write (temp file + rename, same pattern as telemetry/autoUpdate.ts
|
|
// and utils/download.ts) so a crash mid-write can never leave a truncated
|
|
// lock behind. `path` is guaranteed to exist here (readSkillLock already
|
|
// returned a non-null lock), so preserving its mode on the temp file before
|
|
// the rename is safe. No trailing newline: matches the upstream
|
|
// vercel-labs/skills lock's on-disk shape, so a prune stays a minimal diff.
|
|
const mode = statSync(path).mode & 0o777;
|
|
const tmp = `${path}.tmp`;
|
|
writeFileSync(tmp, JSON.stringify(lock, null, 2), { mode });
|
|
renameSync(tmp, path);
|
|
return pruned;
|
|
}
|
|
|
|
// ── Resolving the "latest" manifest ──────────────────────────────────────────
|
|
|
|
/** Walk up from `cwd` to find a repo checkout that ships the manifest. */
|
|
function findRepoManifest(cwd = process.cwd()): string | null {
|
|
let dir = cwd;
|
|
// Bounded climb (deep monorepos / nested worktrees) — stops early at the FS root.
|
|
for (let i = 0; i < 16; i++) {
|
|
const p = join(dir, MANIFEST_FILE);
|
|
if (existsSync(p)) return p;
|
|
const parent = join(dir, "..");
|
|
if (parent === dir) break;
|
|
dir = parent;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Narrow an untrusted JSON payload to a SkillsManifest, or throw a clear error.
|
|
* Guards against a CDN serving an error page (or a malformed manifest) as 200 —
|
|
* without this, a bad shape surfaces later as a cryptic crash in diffSkills.
|
|
*/
|
|
function asSkillsManifest(data: unknown, sourceLabel: string): SkillsManifest {
|
|
const m = data as Partial<SkillsManifest> | null;
|
|
if (!m || typeof m !== "object" || typeof m.skills !== "object" || m.skills === null) {
|
|
throw new Error(`Malformed skills manifest from ${sourceLabel}`);
|
|
}
|
|
return m as SkillsManifest;
|
|
}
|
|
|
|
async function fetchManifest(url: string): Promise<SkillsManifest> {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
try {
|
|
const res = await fetch(url, { signal: controller.signal, headers: { Connection: "close" } });
|
|
if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`);
|
|
return asSkillsManifest(await res.json(), url);
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve main's live HEAD sha via `git ls-remote`. GitHub's branch-raw CDN
|
|
* (raw.githubusercontent.com/<owner>/<repo>/main/...) can serve stale content
|
|
* for minutes after a push; a SHA-pinned raw URL is immediately consistent.
|
|
* Returns null when git/network is unavailable so callers fall back to main.
|
|
*/
|
|
async function remoteHeadSha(repoSlug: string): Promise<string | null> {
|
|
try {
|
|
const { stdout } = await execFileAsync(
|
|
"git",
|
|
["ls-remote", `https://github.com/${repoSlug}.git`, "refs/heads/main"],
|
|
{ timeout: FETCH_TIMEOUT_MS, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } },
|
|
);
|
|
const sha = stdout.split(/\s+/)[0]?.trim() ?? "";
|
|
return /^[0-9a-f]{40}$/.test(sha) ? sha : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Read a manifest from a local path — a manifest file or a repo root. */
|
|
function resolveLocalManifest(source: string): SkillsManifest {
|
|
const direct = source.endsWith(".json") ? source : join(source, MANIFEST_FILE);
|
|
if (existsSync(direct)) return JSON.parse(readFileSync(direct, "utf8")) as SkillsManifest;
|
|
// Fall back to computing from a skills/ tree on disk.
|
|
const skillsRoot = source.endsWith("skills") ? source : join(source, "skills");
|
|
if (existsSync(skillsRoot)) return buildManifest(skillsRoot, { source: skillsRoot });
|
|
throw new Error(`No skills manifest found at: ${source}`);
|
|
}
|
|
|
|
/**
|
|
* Fetch the manifest from GitHub. A full URL is fetched directly; an
|
|
* `owner/repo` slug (or the default repo) is SHA-pinned via `git ls-remote` to
|
|
* dodge raw-CDN lag, falling back to the branch URL when git is unavailable.
|
|
*/
|
|
async function fetchRemoteManifest(source?: string): Promise<SkillsManifest> {
|
|
if (source?.startsWith("http")) return fetchManifest(source);
|
|
|
|
const repoSlug = source ?? DEFAULT_REPO_SLUG;
|
|
const sha = await remoteHeadSha(repoSlug);
|
|
if (sha) {
|
|
try {
|
|
return await fetchManifest(
|
|
`https://raw.githubusercontent.com/${repoSlug}/${sha}/${MANIFEST_FILE}`,
|
|
);
|
|
} catch {
|
|
/* fall through to the branch URL */
|
|
}
|
|
}
|
|
return fetchManifest(`https://raw.githubusercontent.com/${repoSlug}/main/${MANIFEST_FILE}`);
|
|
}
|
|
|
|
/**
|
|
* Resolve the latest manifest. `source` may be:
|
|
* - undefined → in-repo manifest if present (dev / CI), else fetch from GitHub
|
|
* - a local path to a manifest file or a repo root containing `skills/`
|
|
* - an `owner/repo` slug or full URL → fetched from GitHub
|
|
*
|
|
* `canonical: true` skips the in-repo shortcut (the `!source` branch below)
|
|
* even when one is found, and always resolves over the network instead. Use
|
|
* it for any decision that must match what `skills add` actually installs
|
|
* from — the canonical published repo — never a local checkout's manifest,
|
|
* which can be stale (e.g. still listing a skill that was retired/renamed
|
|
* upstream since that checkout's last pull). An explicit local `source`
|
|
* override is a deliberate caller choice and still wins regardless of
|
|
* `canonical`.
|
|
*/
|
|
async function resolveLatestManifest(
|
|
source?: string,
|
|
cwd = process.cwd(),
|
|
opts: { canonical?: boolean } = {},
|
|
): Promise<SkillsManifest> {
|
|
// A local path is a relative one (./ ../) or an absolute one — isAbsolute
|
|
// covers POSIX `/…` and Windows `C:\…` / `\…` on their respective platforms.
|
|
if (source && (source.startsWith(".") || isAbsolute(source))) {
|
|
return resolveLocalManifest(source);
|
|
}
|
|
if (!source && !opts.canonical) {
|
|
const repoManifest = findRepoManifest(cwd);
|
|
if (repoManifest) return JSON.parse(readFileSync(repoManifest, "utf8")) as SkillsManifest;
|
|
}
|
|
return fetchRemoteManifest(source);
|
|
}
|
|
|
|
/**
|
|
* End-to-end check: locate the install, hash it, diff against the latest
|
|
* manifest. Pure-ish (network only via `resolveLatestManifest`).
|
|
*/
|
|
export async function checkSkills(
|
|
opts: {
|
|
dir?: string;
|
|
source?: string;
|
|
cwd?: string;
|
|
home?: string;
|
|
/** See resolveLatestManifest — bypass the in-repo manifest shortcut. */
|
|
canonical?: boolean;
|
|
} = {},
|
|
): Promise<SkillsCheckResult> {
|
|
const latest = await resolveLatestManifest(opts.source, opts.cwd, { canonical: opts.canonical });
|
|
const skillNames = Object.keys(latest.skills);
|
|
const root = locateInstall(skillNames, { dir: opts.dir, cwd: opts.cwd, home: opts.home });
|
|
const installed = root ? hashInstalled(root, skillNames) : {};
|
|
const diff = diffSkills(installed, latest);
|
|
const removedResult = root
|
|
? detectRemoved(root, latest, { cwd: opts.cwd, home: opts.home })
|
|
: { removed: [], lockMissing: false };
|
|
const { removed, lockMissing } = removedResult;
|
|
return {
|
|
location: root?.dir ?? null,
|
|
agent: root?.agent ?? null,
|
|
scope: root?.scope ?? null,
|
|
// Removed skills also mean the install isn't reconciled with the manifest —
|
|
// `skills update` now prunes them, so they count toward "update available".
|
|
updateAvailable: diff.updateAvailable || removed.length > 0,
|
|
summary: { ...diff.summary, removed: removed.length },
|
|
skills: [...diff.skills, ...removed],
|
|
lockMissing,
|
|
};
|
|
}
|