feat(cli): add skills version check, update, and freshness manifest (#1738)

* feat(cli): add skills version check, update, and freshness manifest

Give the HyperFrames skill bundle a content fingerprint so agents and
users can tell whether installed skills are the latest version, on any
platform that can run the CLI.

- skills-manifest.json (repo root): per-skill sha256 over the whole skill
  directory; minimal {source, skills}, no version/timestamp so it is fully
  deterministic. Generated by scripts/gen-skills-manifest.ts.
- `hyperframes skills check` [--json]: compares installed skills to the
  manifest; exits non-zero when something is outdated (agent/CI gate).
- `hyperframes skills update`: thin wrapper over `npx skills update`.
- Passive nudge on render/lint/validate when skills are stale (24h cache,
  same opt-out as the CLI self-update notice).
- "latest" resolved via `git ls-remote` + SHA-pinned raw URL to dodge
  GitHub raw-CDN lag, falling back to the main branch URL.
- CI job + lefthook hook keep skills-manifest.json in sync with skills/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): add execFile to child_process mock in skills test

skills.test.ts mocks node:child_process but only declared execFileSync
and spawn. Loading skills.js transitively loads skillsManifest.ts, which
runs promisify(execFile) at module load, so vitest threw on the missing
execFile named export. Add a bare stub — these tests never invoke it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(cli): init installs all skills; skills update pulls the full set

Make `hyperframes init` the single place skills are pulled in full, and
make "update" mean "get everything" rather than "refresh what's there".

- init now always installs/refreshes ALL skills (incl. ones not yet
  present) instead of prompting "Install AI coding skills?" — opt out
  with `init --skip-skills`. Both the interactive and non-interactive
  paths pass `--all --yes` so the complete set is fetched.
- `hyperframes skills update` switches from `npx skills update` (which
  only refreshes already-installed skills) to `skills add --all`, so it
  installs missing skills too — the same install step init runs.
- SKILL.md documents init-installs-all and the new update semantics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(cli): skills check treats missing skills as needing an update

The full skill set is now the goal (init and `skills update` both pull
all, including ones not installed), so a partial install is no longer
"a choice" — it's something to fix.

- diffSkills: updateAvailable is now true when anything is outdated OR
  missing (local-only still doesn't count). So `skills check` exits
  non-zero — and renders "Update:" instead of "up to date" — whenever a
  skill is missing, not just when one is stale.
- The passive render/lint/validate nudge follows suit: it now counts
  missing alongside outdated ("N skills out of date or missing"),
  tracked via a new skillsMissingCount cache field.
- SKILL.md documents the stricter check.

Note: platforms that intentionally vendor only a subset of skills (e.g.
a Codex snapshot) will now see check report non-zero.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): install/update skills straight from the GitHub repo

`skills add owner/repo` can resolve through the skills.sh registry, which
lags behind the repo — so `update` could install a stale version while
`check` (which resolves latest directly from GitHub) keeps reporting
"outdated", an endless loop.

Switch the install source to the full GitHub URL
(https://github.com/heygen-com/hyperframes), which makes `skills add`
git-clone the repo directly at latest main, bypassing the registry. This
covers `hyperframes skills`, `hyperframes skills update`, and `init`'s
skill install — all of which go through SOURCES. Now install/update and
check agree on what "latest" means.

The init "install skills" hint now points at `npx hyperframes skills
update` so the manual path uses the same GitHub-direct fetch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(cli): init checks skills against GitHub, installs only when stale

`hyperframes init` now runs the skills version check first and only
(re)installs when something is outdated or missing — instead of
unconditionally re-pulling every time. Re-running init on an
already-current project is now a no-op ("skills are already up to date").

- New ensureSkillsCurrent() helper, shared by both the interactive and
  non-interactive init paths (no duplicated install logic).
- The check resolves "latest" straight from GitHub (same source the
  install uses); best-effort — if it can't reach GitHub it installs anyway.
- SKILL.md updated to describe the check-then-install behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(cli): address skills manifest review feedback

From the PR review (points 1, 2, 4, 5):

1. Remove the `local-only` skill status. checkSkills only ever hashes
   manifest-listed skills, so a local-only status could never appear in
   the end-to-end output — and making it appear would wrongly flag
   unrelated skills (the `.../skills` dir is shared across sources).
   diffSkills now reports only on manifest skills; skills on disk that
   aren't in the manifest are ignored.
2. Drop the redundant per-directory sort in listFilesSorted — the single
   final out.sort() is what guarantees a deterministic hash (verified:
   manifest unchanged).
4. resolveLatestManifest local-path detection now uses path.isAbsolute,
   so Windows absolute paths (C:\...) are treated as local instead of
   falling through to a remote fetch.
5. fetchManifest validates the response shape (asSkillsManifest) instead
   of a blind `as` cast, so a CDN error page served as 200 fails with a
   clear error rather than a cryptic crash later in diffSkills.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): strict skills update + auto-discover any agent host

Address PR review (Magi blocker + James/Rames robustness):

- Blocker (Magi): `skills update` is the documented recovery path for
  `skills check || skills update`, but it delegated to installAllSkills()
  which swallowed missing-npx and failed `skills add` as "skipped",
  exiting 0 even when nothing changed. Add a strict mode that throws on
  failure; update sets a non-zero exit (init stays best-effort). New tests
  simulate a non-zero `skills add` (exit 1) and the success path.

- Robustness (James/Rames #2): the upstream `skills` CLI installs into
  ~72 agent conventions; a hard-coded list (4, or even 11) can't track
  that. Replace defaultSkillRoots with discoverSkillRoots — it scans cwd +
  $HOME for any `<host>/skills/<manifest-skill>/SKILL.md` (plus the XDG
  `.config/<host>/skills`), so detection is structural and future-proof,
  no closed list. agentFromDir infers the host from the path.

- Tests (Rames #3): temp-fixture detection tests for every convention ×
  {project, global}, scope priority, claude-code preference, the
  no-install case, the --dir override, and an unknown/new host (proving
  the no-closed-list property).

- Docs (Rames #4/#5): SKILL.md notes init's best-effort GitHub round-trip;
  findRepoManifest climbs 16 levels (was 8) for deep monorepos.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): resolve CodeQL file-system race + de-flake Windows npx test

Two CI fixes:

- CodeQL (high, js/file-system-race) at gen-skills-manifest.ts: the
  existsSync(outPath) precheck followed by writeFileSync(outPath) is a
  check-then-write race. Read the committed manifest directly in a
  try/catch instead (missing/unreadable ⇒ "no committed manifest"), so
  there's no precheck to race against. Behavior is unchanged.

- Windows Tests: npxCommand.test.ts's real `npx --version` smoke test
  cold-starts slower than vitest's 5s default on Windows runners and
  timed out. Give the test 60s headroom (and a 30s exec timeout). Kept
  as a real execution check — mocking would reduce it to a tautology.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): repair garbled npx smoke-test timeout comment

The explanatory comment for the 60s timeout was scrambled across the
callback/timeout arguments, failing oxfmt --check (and thus preflight,
which in turn skipped preview-parity and failed the regression gate).
Move it above the it() call so it no longer sits between call arguments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
WaterrrForever
2026-06-26 22:52:42 +08:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 0c1e236dcd
commit d70ee134cc
16 changed files with 1260 additions and 55 deletions
+5 -2
View File
@@ -14,13 +14,16 @@ describe("buildNpxCommand", () => {
});
});
// Real npx cold-start on Windows CI routinely exceeds vitest's 5s default,
// making this smoke test flaky. Give it generous headroom (it still asserts
// a real version string, so it isn't reduced to a tautology by mocking).
it("executes the host npx version check through the resolved command", () => {
const npx = buildNpxCommand(["--version"]);
const version = execFileSync(npx.command, npx.args, {
encoding: "utf8",
timeout: 10_000,
timeout: 30_000,
}).trim();
expect(version).toMatch(/^\d+\.\d+\.\d+/);
});
}, 60_000);
});
@@ -0,0 +1,257 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
hashSkillBundle,
buildManifest,
checkSkills,
diffSkills,
type SkillsManifest,
type SkillEntry,
} from "./skillsManifest.js";
let root: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "skills-manifest-"));
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
function writeSkill(name: string, files: Record<string, string>): string {
const dir = join(root, name);
for (const [rel, content] of Object.entries(files)) {
const p = join(dir, rel);
mkdirSync(join(p, ".."), { recursive: true });
writeFileSync(p, content);
}
return dir;
}
describe("hashSkillBundle", () => {
it("is deterministic for identical content", () => {
const a = writeSkill("a", { "SKILL.md": "hello", "references/x.md": "x" });
const b = writeSkill("b", { "SKILL.md": "hello", "references/x.md": "x" });
expect(hashSkillBundle(a).hash).toBe(hashSkillBundle(b).hash);
});
it("changes when any file's content changes", () => {
const dir = writeSkill("a", { "SKILL.md": "hello", "references/x.md": "x" });
const before = hashSkillBundle(dir).hash;
writeFileSync(join(dir, "references/x.md"), "CHANGED");
expect(hashSkillBundle(dir).hash).not.toBe(before);
});
it("counts every file in the bundle, not just SKILL.md", () => {
const dir = writeSkill("a", {
"SKILL.md": "hello",
"references/x.md": "x",
"scripts/y.mjs": "export const y = 1;",
});
expect(hashSkillBundle(dir).files).toBe(3);
});
it("normalises CRLF so a Windows checkout is not flagged as different", () => {
const lf = writeSkill("lf", { "SKILL.md": "line1\nline2\n" });
const crlf = writeSkill("crlf", { "SKILL.md": "line1\r\nline2\r\n" });
expect(hashSkillBundle(lf).hash).toBe(hashSkillBundle(crlf).hash);
});
});
describe("buildManifest", () => {
it("includes only directories that contain a SKILL.md", () => {
writeSkill("real", { "SKILL.md": "x" });
writeSkill("not-a-skill", { "README.md": "x" });
const m = buildManifest(root, { source: "test" });
expect(Object.keys(m.skills)).toEqual(["real"]);
});
});
describe("diffSkills", () => {
const latest: SkillsManifest = {
source: "test",
skills: {
keep: { hash: "h1", files: 1 },
changed: { hash: "h2", files: 1 },
gone: { hash: "h3", files: 1 },
},
};
it("classifies current / outdated / missing and ignores skills not in the manifest", () => {
const installed: Record<string, SkillEntry> = {
keep: { hash: "h1", files: 1 }, // current
changed: { hash: "DIFFERENT", files: 1 }, // outdated
// gone: not installed → missing
extra: { hash: "hx", files: 1 }, // not in the manifest → ignored
};
const diff = diffSkills(installed, latest);
const byName = Object.fromEntries(diff.skills.map((s) => [s.name, s.status]));
expect(byName).toEqual({
keep: "current",
changed: "outdated",
gone: "missing",
});
expect(diff.summary).toEqual({ current: 1, outdated: 1, missing: 1 });
});
it("flags updateAvailable when a skill is outdated OR missing", () => {
// The full set is the goal, so missing skills now count too.
const missingOnly = diffSkills({ keep: { hash: "h1", files: 1 } }, latest);
expect(missingOnly.updateAvailable).toBe(true);
const hasOutdated = diffSkills({ changed: { hash: "X", files: 1 } }, latest);
expect(hasOutdated.updateAvailable).toBe(true);
// Everything present and current → no update.
const allCurrent = diffSkills(
{
keep: { hash: "h1", files: 1 },
changed: { hash: "h2", files: 1 },
gone: { hash: "h3", files: 1 },
},
latest,
);
expect(allCurrent.updateAvailable).toBe(false);
// A skill installed but not in the manifest is ignored — doesn't trigger one.
const withExtra = diffSkills(
{
keep: { hash: "h1", files: 1 },
changed: { hash: "h2", files: 1 },
gone: { hash: "h3", files: 1 },
extra: { hash: "hx", files: 1 },
},
latest,
);
expect(withExtra.updateAvailable).toBe(false);
});
});
describe("checkSkills install detection", () => {
// A spread of agent-host conventions across the upstream `skills` universe,
// including the XDG-nested OpenCode layout. Detection is structural
// (auto-discovered), so this list is illustrative, not exhaustive.
const CASES: ReadonlyArray<[string, string]> = [
[".claude/skills", "claude-code"],
[".agents/skills", "agents"],
[".codex/skills", "codex"],
[".cursor/skills", "cursor"],
[".config/opencode/skills", "opencode"],
[".factory/skills", "factory"],
[".slate/skills", "slate"],
[".kiro/skills", "kiro"],
[".hermes/skills", "hermes"],
[".gbrain/skills", "gbrain"],
[".openclaw/skills", "openclaw"],
];
function writeManifest(dir: string): string {
const p = join(dir, "manifest.json");
writeFileSync(
p,
JSON.stringify({
source: "test",
skills: { alpha: { hash: "x", files: 1 }, beta: { hash: "y", files: 1 } },
}),
);
return p;
}
function installSkill(skillsDir: string, name: string): void {
mkdirSync(join(skillsDir, name), { recursive: true });
writeFileSync(join(skillsDir, name, "SKILL.md"), `# ${name}`);
}
it.each(CASES)("locates skills under %s in the project scope", async (rel, agent) => {
const project = join(root, "project");
const home = join(root, "home");
mkdirSync(project, { recursive: true });
mkdirSync(home, { recursive: true });
const source = writeManifest(root);
installSkill(join(project, rel), "alpha");
const res = await checkSkills({ source, cwd: project, home });
expect(res.location).toBe(join(project, rel));
expect(res.agent).toBe(agent);
});
it.each(CASES)("locates skills under %s in the global scope", async (rel, agent) => {
const project = join(root, "project");
const home = join(root, "home");
mkdirSync(project, { recursive: true });
mkdirSync(home, { recursive: true });
const source = writeManifest(root);
installSkill(join(home, rel), "alpha");
const res = await checkSkills({ source, cwd: project, home });
expect(res.location).toBe(join(home, rel));
expect(res.agent).toBe(agent);
});
it("prefers project scope over global, regardless of convention order", async () => {
const project = join(root, "project");
const home = join(root, "home");
mkdirSync(project, { recursive: true });
mkdirSync(home, { recursive: true });
const source = writeManifest(root);
installSkill(join(home, ".claude/skills"), "alpha"); // global, higher-priority host
installSkill(join(project, ".hermes/skills"), "alpha"); // project, lower-priority host
const res = await checkSkills({ source, cwd: project, home });
expect(res.location).toBe(join(project, ".hermes/skills"));
expect(res.agent).toBe("hermes");
});
it("reports no location and an available update when nothing is installed", async () => {
const project = join(root, "project");
const home = join(root, "home");
mkdirSync(project, { recursive: true });
mkdirSync(home, { recursive: true });
const source = writeManifest(root);
const res = await checkSkills({ source, cwd: project, home });
expect(res.location).toBeNull();
expect(res.summary.missing).toBe(2);
expect(res.updateAvailable).toBe(true);
});
it("honors the --dir override and infers the agent from the path", async () => {
const dir = join(root, "home", ".kiro/skills");
installSkill(dir, "alpha");
const source = writeManifest(root);
const res = await checkSkills({ source, dir });
expect(res.location).toBe(dir);
expect(res.agent).toBe("kiro");
});
it("auto-discovers an unknown/new agent host (no closed list)", async () => {
const project = join(root, "project");
const home = join(root, "home");
mkdirSync(project, { recursive: true });
mkdirSync(home, { recursive: true });
const source = writeManifest(root);
// A host this CLI has never heard of — structural discovery still finds it.
installSkill(join(home, ".some-future-agent/skills"), "alpha");
const res = await checkSkills({ source, cwd: project, home });
expect(res.location).toBe(join(home, ".some-future-agent/skills"));
expect(res.agent).toBe("some-future-agent");
});
it("prefers claude-code when multiple hosts in the same scope have skills", async () => {
const project = join(root, "project");
const home = join(root, "home");
mkdirSync(project, { recursive: true });
mkdirSync(home, { recursive: true });
const source = writeManifest(root);
installSkill(join(home, ".factory/skills"), "alpha");
installSkill(join(home, ".claude/skills"), "alpha");
const res = await checkSkills({ source, cwd: project, home });
expect(res.location).toBe(join(home, ".claude/skills"));
expect(res.agent).toBe("claude-code");
});
});
+407
View File
@@ -0,0 +1,407 @@
// 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, statSync } from "node:fs";
import { homedir } from "node:os";
import { isAbsolute, join, relative, 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>;
}
export type SkillStatus = "current" | "outdated" | "missing";
export interface SkillDiff {
name: string;
status: SkillStatus;
installedHash?: string;
latestHash?: string;
}
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;
updateAvailable: boolean;
summary: { current: number; outdated: number; missing: number };
skills: SkillDiff[];
}
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;
// ── 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);
});
}
/**
* Find the first skill root that actually contains HyperFrames skills. A
* `--dir` override (if given) is treated as a `.../skills` directory directly.
* Otherwise scan project (cwd) then global ($HOME), auto-discovering hosts.
*/
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: "project" }
: null;
}
const roots = [
...discoverSkillRoots(opts.cwd ?? process.cwd(), "project"),
...discoverSkillRoots(opts.home ?? homedir(), "global"),
];
for (const root of roots) {
if (skillNames.some((n) => existsSync(join(root.dir, n, "SKILL.md")))) return root;
}
return null;
}
/** 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,
): Omit<SkillsCheckResult, "location" | "agent"> {
// Report only on skills the manifest knows about. A skill on disk that isn't
// in the manifest isn't necessarily ours — `.../skills` is shared across
// sources — so it's not something we can meaningfully diff, and is ignored.
const skills: SkillDiff[] = [];
const summary = { current: 0, outdated: 0, missing: 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++;
skills.push({
name,
status,
installedHash: installedEntry?.hash,
latestHash: latestEntry.hash,
});
}
return {
// The full skill set is the goal — `init` and `skills update` both pull the
// complete set, so anything outdated OR missing means an update is available.
updateAvailable: summary.outdated > 0 || summary.missing > 0,
summary,
skills,
};
}
// ── 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
*/
async function resolveLatestManifest(
source?: string,
cwd = process.cwd(),
): 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) {
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 } = {},
): Promise<SkillsCheckResult> {
const latest = await resolveLatestManifest(opts.source, opts.cwd);
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);
return { location: root?.dir ?? null, agent: root?.agent ?? null, ...diff };
}
@@ -0,0 +1,87 @@
// 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;
}
/** 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,
};
}
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> {
const result = await checkSkills();
// 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;
config.skillsMissingCount = result.summary.missing;
writeConfig(config);
}
return {
updateAvailable: result.updateAvailable,
outdated: result.summary.outdated,
missing: result.summary.missing,
};
}
/**
* 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 or missing. */
function skillsNoticeText(meta: SkillsUpdateMeta): string | null {
const total = meta.outdated + meta.missing;
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);
}
+14 -4
View File
@@ -102,15 +102,25 @@ export function withMeta<T extends object>(data: T): T & { _meta: UpdateMeta } {
return { ...data, _meta: getUpdateMeta() };
}
/**
* True when update / freshness notices should stay silent — CI, non-TTY, dev
* mode, or the HYPERFRAMES_NO_UPDATE_CHECK opt-out. Shared with the skills
* freshness notice so both honour the same gating.
*/
export function updateNoticesSuppressed(): boolean {
if (isDevMode()) return true;
if (process.env["CI"] === "true" || process.env["CI"] === "1") return true;
if (!process.stderr.isTTY) return true;
if (process.env["HYPERFRAMES_NO_UPDATE_CHECK"] === "1") return true;
return false;
}
/**
* Print update notice to stderr if a newer version is available.
* Skipped in CI, non-TTY, dev mode, or when HYPERFRAMES_NO_UPDATE_CHECK is set.
*/
export function printUpdateNotice(): void {
if (isDevMode()) return;
if (process.env["CI"] === "true" || process.env["CI"] === "1") return;
if (!process.stderr.isTTY) return;
if (process.env["HYPERFRAMES_NO_UPDATE_CHECK"] === "1") return;
if (updateNoticesSuppressed()) return;
const meta = getUpdateMeta();
if (!meta.updateAvailable || !meta.latestVersion) return;