Files
hyperframes/packages/cli/src/utils/skillsManifest.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

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,
};
}