mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
co-authored by
Claude Opus 4.8
parent
0c1e236dcd
commit
d70ee134cc
@@ -61,6 +61,7 @@ jobs:
|
||||
- ".github/workflows/ci.yml"
|
||||
skills:
|
||||
- "skills/**"
|
||||
- "skills-manifest.json"
|
||||
- "package.json"
|
||||
- ".github/workflows/ci.yml"
|
||||
|
||||
@@ -263,6 +264,23 @@ jobs:
|
||||
printf ' * %s\n' "${SKILLS_TESTS[@]}"
|
||||
node --test "${SKILLS_TESTS[@]}"
|
||||
|
||||
# Guards that skills-manifest.json (the published freshness fingerprint read
|
||||
# by `hyperframes skills check`) was regenerated when a skill changed. Runs
|
||||
# `gen:skills-manifest --check`, which compares per-skill content hashes; the
|
||||
# manifest carries no version/timestamp, so it only fails on real content
|
||||
# drift. bun runs the TS script directly, no install needed.
|
||||
skills-manifest:
|
||||
name: "Skills: manifest in sync"
|
||||
needs: changes
|
||||
if: needs.changes.outputs.skills == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 3
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
- name: Verify skills-manifest.json matches skills/
|
||||
run: bun packages/cli/scripts/gen-skills-manifest.ts --check
|
||||
|
||||
cli-npx-shim:
|
||||
name: "CLI: npx shim (${{ matrix.os }})"
|
||||
needs: changes
|
||||
|
||||
@@ -10,6 +10,13 @@ pre-commit:
|
||||
# Replaces --check which only reports — that left unformatted files in
|
||||
# commits when the hook ran after the amend snapshot was taken.
|
||||
run: bunx oxfmt --no-error-on-unmatched-pattern {staged_files} && git add {staged_files}
|
||||
skills-manifest:
|
||||
glob: "skills/**"
|
||||
# Regenerate the freshness fingerprint when a skill changes, then re-stage
|
||||
# it so skills-manifest.json (repo root) never drifts from skills/ (CI
|
||||
# enforces the same via the "Skills: manifest in sync" job). Churn-free:
|
||||
# the generator rewrites only when a content hash actually changed.
|
||||
run: bun packages/cli/scripts/gen-skills-manifest.ts && git add skills-manifest.json
|
||||
typecheck:
|
||||
glob: "*.{ts,tsx}"
|
||||
run: cd packages/core && bunx tsc --noEmit && cd ../studio && bunx tsc --noEmit
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
"build:runtime": "tsx scripts/build-runtime.ts",
|
||||
"build:beat-analyzer": "node scripts/build-beat-analyzer.mjs",
|
||||
"build:copy": "node scripts/build-copy.mjs",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"gen:skills-manifest": "tsx scripts/gen-skills-manifest.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.8.0",
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// Generate (or verify) skills-manifest.json (repo root) — the published
|
||||
// "latest" fingerprint of the HyperFrames skill bundle.
|
||||
//
|
||||
// bun run --cwd packages/cli gen:skills-manifest # write/update
|
||||
// bun run --cwd packages/cli gen:skills-manifest --check # verify only (CI)
|
||||
//
|
||||
// The manifest is just per-skill content hashes (no version / timestamp), so it
|
||||
// is fully deterministic: same skill content ⇒ byte-identical manifest. `--check`
|
||||
// exits non-zero when the committed manifest doesn't match current skill content.
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { buildManifest, MANIFEST_FILE, type SkillsManifest } from "../src/utils/skillsManifest.js";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = join(here, "..", "..", ".."); // packages/cli/scripts → repo root
|
||||
const skillsRoot = join(repoRoot, "skills");
|
||||
const outPath = join(repoRoot, MANIFEST_FILE);
|
||||
const isCheck = process.argv.includes("--check");
|
||||
|
||||
/** Stable signature of the content hashes (order-independent). */
|
||||
function signature(skills: SkillsManifest["skills"]): string {
|
||||
return Object.keys(skills)
|
||||
.sort()
|
||||
.map((name) => `${name}:${skills[name]!.hash}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function driftLine(name: string, oldHash?: string, newHash?: string): string | null {
|
||||
if (oldHash === newHash) return null;
|
||||
if (!oldHash) return ` + ${name} (new)`;
|
||||
if (!newHash) return ` - ${name} (removed)`;
|
||||
return ` ~ ${name} (${oldHash} → ${newHash})`;
|
||||
}
|
||||
|
||||
function hashOf(skills: SkillsManifest["skills"], name: string): string | undefined {
|
||||
return skills[name]?.hash;
|
||||
}
|
||||
|
||||
function reportDrift(fresh: SkillsManifest, committed: SkillsManifest | null): void {
|
||||
const oldSkills = committed === null ? {} : committed.skills;
|
||||
const names = [...new Set([...Object.keys(fresh.skills), ...Object.keys(oldSkills)])].sort();
|
||||
for (const name of names) {
|
||||
const line = driftLine(name, hashOf(oldSkills, name), hashOf(fresh.skills, name));
|
||||
if (line) console.log(line);
|
||||
}
|
||||
}
|
||||
|
||||
const fresh = buildManifest(skillsRoot, { source: "heygen-com/hyperframes" });
|
||||
|
||||
// Read the committed manifest directly (no existsSync precheck) so there's no
|
||||
// check-then-write race on outPath — a missing or unreadable file just means
|
||||
// "no committed manifest yet", and we write a fresh one below.
|
||||
let committed: SkillsManifest | null = null;
|
||||
try {
|
||||
committed = JSON.parse(readFileSync(outPath, "utf8")) as SkillsManifest;
|
||||
} catch {
|
||||
committed = null;
|
||||
}
|
||||
|
||||
const inSync = committed !== null && signature(committed.skills) === signature(fresh.skills);
|
||||
const count = Object.keys(fresh.skills).length;
|
||||
|
||||
if (isCheck) {
|
||||
if (inSync) {
|
||||
console.log(`✓ ${MANIFEST_FILE} is in sync (${count} skills)`);
|
||||
process.exit(0);
|
||||
}
|
||||
console.error(`✗ ${MANIFEST_FILE} is out of date — a skill changed without regenerating it.`);
|
||||
reportDrift(fresh, committed);
|
||||
console.error(
|
||||
`\nRun: bun run --cwd packages/cli gen:skills-manifest (then commit ${MANIFEST_FILE})`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Write mode — churn-free: only rewrite when a content hash actually changed.
|
||||
if (inSync) {
|
||||
console.log(`${MANIFEST_FILE} already in sync — no change (${count} skills)`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
writeFileSync(outPath, JSON.stringify(fresh, null, 2) + "\n");
|
||||
console.log(`Wrote ${outPath} (${count} skills)`);
|
||||
reportDrift(fresh, committed);
|
||||
+12
-1
@@ -194,6 +194,7 @@ let _trackCommandResult:
|
||||
| ((props: { command: string; success: boolean; exitCode: number; durationMs: number }) => void)
|
||||
| undefined;
|
||||
let _printUpdateNotice: (() => void) | undefined;
|
||||
let _printSkillsUpdateNotice: (() => void) | undefined;
|
||||
|
||||
// `events` is a telemetry-internal beacon: it self-tracks + self-flushes, so it
|
||||
// skips the per-command wrapper (no duplicate cli_command, no first-run notice
|
||||
@@ -226,6 +227,13 @@ if (!isHelp && !hasJsonFlag && command !== "upgrade" && command !== "events") {
|
||||
auto?.scheduleBackgroundInstall(result.latest, result.current);
|
||||
}
|
||||
});
|
||||
|
||||
// Skills freshness nudge — same gating as the CLI self-update notice. The
|
||||
// check is cached (24h) and best-effort: it never blocks or fails the command.
|
||||
import("./utils/skillsUpdateCheck.js").then(async (mod) => {
|
||||
_printSkillsUpdateNotice = mod.printSkillsUpdateNotice;
|
||||
await mod.checkSkillsForUpdate().catch(() => null);
|
||||
});
|
||||
}
|
||||
|
||||
const commandStart = Date.now();
|
||||
@@ -237,7 +245,10 @@ const commandStart = Date.now();
|
||||
// detaches after first invocation, which is what we want for both.
|
||||
process.once("beforeExit", () => {
|
||||
_flush?.().catch(() => {});
|
||||
if (!hasJsonFlag) _printUpdateNotice?.();
|
||||
if (!hasJsonFlag) {
|
||||
_printUpdateNotice?.();
|
||||
_printSkillsUpdateNotice?.();
|
||||
}
|
||||
});
|
||||
|
||||
// Sync-only: exit handlers cannot await promises or drain microtasks.
|
||||
|
||||
@@ -573,6 +573,41 @@ async function scaffoldProject(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the project's AI coding skills are present and current. Checks the
|
||||
* installed skills against the latest published on GitHub and only (re)installs
|
||||
* when something is outdated or missing — so re-running `init` on an already
|
||||
* up-to-date project is a no-op. Best-effort: if the version check can't reach
|
||||
* GitHub, it installs anyway. The install itself (`installAllSkills`) pulls the
|
||||
* full set straight from the GitHub repo.
|
||||
*/
|
||||
async function ensureSkillsCurrent(destDir: string): Promise<void> {
|
||||
const { installAllSkills } = await import("./skills.js");
|
||||
const { checkSkills } = await import("../utils/skillsManifest.js");
|
||||
// --all pulls every skill (incl. ones not yet installed); --yes keeps it
|
||||
// non-interactive. When Claude Code is driving, target its native dir so
|
||||
// skills land in .claude/skills/.
|
||||
const extraArgs = process.env["CLAUDECODE"]
|
||||
? ["--all", "--agent", "claude-code", "--yes"]
|
||||
: ["--all", "--yes"];
|
||||
|
||||
console.log();
|
||||
console.log(c.bold("Checking AI coding skills against GitHub..."));
|
||||
let needsInstall = true;
|
||||
try {
|
||||
const result = await checkSkills({ cwd: destDir });
|
||||
needsInstall = result.updateAvailable;
|
||||
} catch {
|
||||
// Couldn't reach GitHub (offline, rate-limited) — install anyway.
|
||||
}
|
||||
|
||||
if (needsInstall) {
|
||||
await installAllSkills({ cwd: destDir, extraArgs });
|
||||
} else {
|
||||
console.log(c.success("AI coding skills are already up to date."));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exported command
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -802,12 +837,7 @@ export default defineCommand({
|
||||
}
|
||||
|
||||
if (!skipSkills) {
|
||||
const { installAllSkills } = await import("./skills.js");
|
||||
// --yes keeps it non-interactive. When Claude Code is driving
|
||||
// (CLAUDECODE env var), target its native dir so skills land in
|
||||
// .claude/skills/ instead of only .agents/skills/.
|
||||
const args = process.env["CLAUDECODE"] ? ["--agent", "claude-code", "--yes"] : ["--yes"];
|
||||
await installAllSkills({ cwd: destDir, extraArgs: args });
|
||||
await ensureSkillsCurrent(destDir);
|
||||
}
|
||||
|
||||
console.log();
|
||||
@@ -815,7 +845,7 @@ export default defineCommand({
|
||||
console.log();
|
||||
if (skipSkills) {
|
||||
console.log(` ${c.accent("1.")} Install AI coding skills (one-time):`);
|
||||
console.log(` ${c.accent("npx skills add heygen-com/hyperframes --yes")}`);
|
||||
console.log(` ${c.accent("npx hyperframes skills update")}`);
|
||||
} else {
|
||||
console.log(
|
||||
` ${c.accent("1.")} Restart your AI agent (new session) so it loads the skills.`,
|
||||
@@ -1023,20 +1053,10 @@ export default defineCommand({
|
||||
const files = readdirSync(destDir);
|
||||
clack.note(files.map((f) => c.accent(f)).join("\n"), c.success(`Created ${name}/`));
|
||||
|
||||
// Offer to install AI coding skills
|
||||
// Check skills against GitHub and (re)install only if outdated or missing —
|
||||
// init is the one place the full set is pulled. Opt out with --skip-skills.
|
||||
if (!skipSkills) {
|
||||
const installSkills = await clack.confirm({
|
||||
message: "Install AI coding skills? (for Claude Code, Cursor, Codex, etc.)",
|
||||
initialValue: true,
|
||||
});
|
||||
if (clack.isCancel(installSkills)) {
|
||||
clack.cancel("Setup cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
if (installSkills) {
|
||||
const { installAllSkills } = await import("./skills.js");
|
||||
await installAllSkills({ cwd: destDir });
|
||||
}
|
||||
await ensureSkillsCurrent(destDir);
|
||||
}
|
||||
|
||||
// Auto-launch studio preview
|
||||
|
||||
@@ -17,12 +17,17 @@ type ExecCall = {
|
||||
};
|
||||
|
||||
const originalPlatform = process.platform;
|
||||
const state: { execCalls: ExecCall[]; spawnCalls: SpawnCall[] } = {
|
||||
const state: { execCalls: ExecCall[]; spawnCalls: SpawnCall[]; spawnExitCode: number } = {
|
||||
execCalls: [],
|
||||
spawnCalls: [],
|
||||
spawnExitCode: 0,
|
||||
};
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
// `skillsManifest.ts` does `promisify(execFile)` at module load. These tests
|
||||
// never invoke it (no skills-check path runs here), so a bare stub is enough
|
||||
// to satisfy the named import — we deliberately don't spread the real module.
|
||||
execFile: vi.fn(),
|
||||
execFileSync: vi.fn((command: string, args: ReadonlyArray<string>) => {
|
||||
state.execCalls.push({ command, args });
|
||||
return Buffer.from("11.0.0");
|
||||
@@ -31,7 +36,7 @@ vi.mock("node:child_process", () => ({
|
||||
(command: string, args: ReadonlyArray<string>, opts?: { env?: NodeJS.ProcessEnv }) => {
|
||||
state.spawnCalls.push({ command, args, env: opts?.env });
|
||||
const fake = new EventEmitter();
|
||||
setImmediate(() => fake.emit("close", 0, null));
|
||||
setImmediate(() => fake.emit("close", state.spawnExitCode, null));
|
||||
return fake;
|
||||
},
|
||||
),
|
||||
@@ -54,6 +59,7 @@ describe("hyperframes skills", () => {
|
||||
beforeEach(() => {
|
||||
state.execCalls = [];
|
||||
state.spawnCalls = [];
|
||||
state.spawnExitCode = 0;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
@@ -77,13 +83,32 @@ describe("hyperframes skills", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
["linux", "npx", ["--version"], ["skills", "add", "heygen-com/hyperframes", "--all"]],
|
||||
["darwin", "npx", ["--version"], ["skills", "add", "heygen-com/hyperframes", "--all"]],
|
||||
[
|
||||
"linux",
|
||||
"npx",
|
||||
["--version"],
|
||||
["skills", "add", "https://github.com/heygen-com/hyperframes", "--all"],
|
||||
],
|
||||
[
|
||||
"darwin",
|
||||
"npx",
|
||||
["--version"],
|
||||
["skills", "add", "https://github.com/heygen-com/hyperframes", "--all"],
|
||||
],
|
||||
[
|
||||
"win32",
|
||||
"cmd.exe",
|
||||
["/d", "/s", "/c", "npx.cmd", "--version"],
|
||||
["/d", "/s", "/c", "npx.cmd", "skills", "add", "heygen-com/hyperframes", "--all"],
|
||||
[
|
||||
"/d",
|
||||
"/s",
|
||||
"/c",
|
||||
"npx.cmd",
|
||||
"skills",
|
||||
"add",
|
||||
"https://github.com/heygen-com/hyperframes",
|
||||
"--all",
|
||||
],
|
||||
],
|
||||
] as const)(
|
||||
"uses %s-compatible npx command for preflight and skills install",
|
||||
@@ -99,4 +124,46 @@ describe("hyperframes skills", () => {
|
||||
expect(state.spawnCalls[0]?.args).toEqual(expectedInstallArgs);
|
||||
},
|
||||
);
|
||||
|
||||
// The `skills check || skills update` recovery contract requires update to
|
||||
// fail loudly — a swallowed install failure would let the `||` chain pass
|
||||
// while nothing changed.
|
||||
it("skills update exits non-zero when the install fails", async () => {
|
||||
setPlatform("linux");
|
||||
state.spawnExitCode = 1; // simulate `skills add` exiting non-zero
|
||||
|
||||
const prevExit = process.exitCode;
|
||||
process.exitCode = 0;
|
||||
try {
|
||||
const { default: skillsCmd } = await import("./skills.js");
|
||||
const subs = skillsCmd.subCommands as unknown as Record<string, typeof skillsCmd>;
|
||||
const updateCmd = subs.update;
|
||||
expect(updateCmd).toBeDefined();
|
||||
await updateCmd!.run?.({ args: {}, rawArgs: [], cmd: updateCmd } as never);
|
||||
expect(process.exitCode).toBe(1);
|
||||
} finally {
|
||||
process.exitCode = prevExit;
|
||||
}
|
||||
});
|
||||
|
||||
it("skills update exits zero on a successful install", async () => {
|
||||
setPlatform("linux");
|
||||
state.spawnExitCode = 0;
|
||||
|
||||
const prevExit = process.exitCode;
|
||||
process.exitCode = 0;
|
||||
try {
|
||||
const { default: skillsCmd } = await import("./skills.js");
|
||||
const subs = skillsCmd.subCommands as unknown as Record<string, typeof skillsCmd>;
|
||||
const updateCmd = subs.update;
|
||||
expect(updateCmd).toBeDefined();
|
||||
await updateCmd!.run?.({ args: {}, rawArgs: [], cmd: updateCmd } as never);
|
||||
expect(process.exitCode).toBe(0);
|
||||
// pulls the full set straight from GitHub
|
||||
expect(state.spawnCalls[0]?.args).toContain("https://github.com/heygen-com/hyperframes");
|
||||
expect(state.spawnCalls[0]?.args).toContain("--all");
|
||||
} finally {
|
||||
process.exitCode = prevExit;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,16 @@ import { execFileSync, spawn } from "node:child_process";
|
||||
import * as clack from "@clack/prompts";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { buildNpxCommand } from "../utils/npxCommand.js";
|
||||
import { withMeta } from "../utils/updateCheck.js";
|
||||
import { checkSkills, type SkillsCheckResult } from "../utils/skillsManifest.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
|
||||
export const examples: Example[] = [
|
||||
["Install all HyperFrames skills", "hyperframes skills"],
|
||||
["Check whether installed skills are up to date", "hyperframes skills check"],
|
||||
["Check, machine-readable (for agents / CI)", "hyperframes skills check --json"],
|
||||
["Update all skills to the latest (installs any missing)", "hyperframes skills update"],
|
||||
];
|
||||
|
||||
function hasNpx(): boolean {
|
||||
const npx = buildNpxCommand(["--version"]);
|
||||
@@ -14,41 +24,53 @@ function hasNpx(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function runSkillsAdd(
|
||||
repo: string,
|
||||
opts: { cwd?: string; extraArgs?: string[] } = {},
|
||||
): Promise<void> {
|
||||
const npx = buildNpxCommand(["skills", "add", repo, ...(opts.extraArgs ?? ["--all"])]);
|
||||
function spawnNpx(args: string[], opts: { cwd?: string } = {}): Promise<void> {
|
||||
const npx = buildNpxCommand(args);
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(npx.command, npx.args, {
|
||||
stdio: "inherit",
|
||||
timeout: 120_000,
|
||||
cwd: opts.cwd,
|
||||
// GH #316 — the upstream `skills` CLI shells out to `git clone`.
|
||||
// When Git's clone-hook protection is active (shipped on by
|
||||
// default in 2.45.1, reverted in 2.45.2, still present on many
|
||||
// corporate and CI setups), any globally-registered
|
||||
// `git lfs install` post-checkout hook aborts the clone. The
|
||||
// `repo` reaching this function is hardcoded in SOURCES below
|
||||
// — no user input reaches the spawn — so opting out here is safe.
|
||||
// When Git's clone-hook protection is active (shipped on by default in
|
||||
// 2.45.1, reverted in 2.45.2, still present on many corporate and CI
|
||||
// setups), a globally-registered `git lfs install` post-checkout hook
|
||||
// aborts the clone. The args reaching this function are hardcoded — no
|
||||
// user input reaches the spawn — so opting out here is safe.
|
||||
env: { ...process.env, GIT_CLONE_PROTECTION_ACTIVE: "0" },
|
||||
});
|
||||
child.on("close", (code, signal) => {
|
||||
if (code === 0) resolve();
|
||||
else if (signal === "SIGINT" || code === 130) process.exit(0);
|
||||
else reject(new Error(`npx skills add exited with code ${code}`));
|
||||
else reject(new Error(`npx ${args.join(" ")} exited with code ${code}`));
|
||||
});
|
||||
child.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
const SOURCES = [{ name: "HyperFrames", repo: "heygen-com/hyperframes" }];
|
||||
|
||||
export async function installAllSkills(
|
||||
function runSkillsAdd(
|
||||
source: string,
|
||||
opts: { cwd?: string; extraArgs?: string[] } = {},
|
||||
): Promise<void> {
|
||||
return spawnNpx(["skills", "add", source, ...(opts.extraArgs ?? ["--all"])], opts);
|
||||
}
|
||||
|
||||
// Use the full GitHub URL (not the `owner/repo` slug) so `skills add` git-clones
|
||||
// the repo directly at latest `main`, bypassing the skills.sh registry — which
|
||||
// can lag behind the repo. Our freshness check already resolves "latest"
|
||||
// straight from GitHub, so this keeps install/update consistent with check.
|
||||
const SOURCES = [{ name: "HyperFrames", url: "https://github.com/heygen-com/hyperframes" }];
|
||||
|
||||
export async function installAllSkills(
|
||||
opts: { cwd?: string; extraArgs?: string[]; strict?: boolean } = {},
|
||||
): Promise<void> {
|
||||
if (!hasNpx()) {
|
||||
clack.log.error(c.error("npx not found. Install Node.js and retry."));
|
||||
const msg = "npx not found. Install Node.js and retry.";
|
||||
// strict callers (e.g. `skills update`) need a real failure so a recovery
|
||||
// command can't exit 0 having done nothing; best-effort callers (init) just
|
||||
// warn and carry on.
|
||||
if (opts.strict) throw new Error(msg);
|
||||
clack.log.error(c.error(msg));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -57,20 +79,125 @@ export async function installAllSkills(
|
||||
console.log(c.bold(`Installing ${source.name} skills...`));
|
||||
console.log();
|
||||
try {
|
||||
await runSkillsAdd(source.repo, opts);
|
||||
} catch {
|
||||
await runSkillsAdd(source.url, opts);
|
||||
} catch (err) {
|
||||
if (opts.strict) throw err instanceof Error ? err : new Error(String(err));
|
||||
console.log(c.dim(`${source.name} skills skipped`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
// ── check ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function renderCheck(result: SkillsCheckResult): void {
|
||||
const { summary } = result;
|
||||
console.log();
|
||||
console.log(c.bold("hyperframes skills"));
|
||||
console.log();
|
||||
|
||||
if (!result.location) {
|
||||
console.log(` ${c.dim("No HyperFrames skills found in the usual locations.")}`);
|
||||
console.log(` ${c.accent("Install: npx hyperframes skills")}`);
|
||||
console.log();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(` ${c.bold("Location")} ${c.dim(result.location)} ${c.dim(`(${result.agent})`)}`);
|
||||
console.log();
|
||||
|
||||
const parts = [c.success(`✓ ${summary.current} current`)];
|
||||
if (summary.outdated) parts.push(c.warn(`↑ ${summary.outdated} outdated`));
|
||||
if (summary.missing) parts.push(c.dim(`◦ ${summary.missing} not installed`));
|
||||
console.log(` ${parts.join(" ")}`);
|
||||
|
||||
const outdated = result.skills.filter((s) => s.status === "outdated");
|
||||
const missing = result.skills.filter((s) => s.status === "missing");
|
||||
|
||||
if (outdated.length) {
|
||||
console.log();
|
||||
console.log(` ${c.warn("Outdated:")}`);
|
||||
for (const s of outdated) console.log(` ${c.warn("↑")} ${s.name}`);
|
||||
}
|
||||
if (missing.length) {
|
||||
console.log();
|
||||
console.log(` ${c.dim("Not installed:")}`);
|
||||
for (const s of missing) console.log(` ${c.dim("◦ " + s.name)}`);
|
||||
}
|
||||
|
||||
console.log();
|
||||
if (result.updateAvailable) {
|
||||
console.log(` ${c.accent("Update: npx hyperframes skills update")}`);
|
||||
} else {
|
||||
console.log(` ${c.success("◇")} ${c.success("Installed skills are up to date")}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
const checkCommand = defineCommand({
|
||||
meta: { name: "check", description: "Check whether installed skills are the latest version" },
|
||||
args: {
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
dir: { type: "string", description: "Skills directory to check (default: auto-detect)" },
|
||||
source: {
|
||||
type: "string",
|
||||
description: "Where 'latest' comes from: local path, owner/repo, or URL",
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
const result = await checkSkills({
|
||||
dir: args.dir as string | undefined,
|
||||
source: args.source as string | undefined,
|
||||
});
|
||||
|
||||
if (args.json) console.log(JSON.stringify(withMeta(result), null, 2));
|
||||
else renderCheck(result);
|
||||
|
||||
// Exit non-zero when installed skills are stale, so agents and CI can gate:
|
||||
// hyperframes skills check || npx hyperframes skills update
|
||||
if (result.updateAvailable) process.exitCode = 1;
|
||||
},
|
||||
});
|
||||
|
||||
// ── update ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const updateCommand = defineCommand({
|
||||
meta: {
|
||||
name: "skills",
|
||||
description: "Install HyperFrames skills for AI coding tools",
|
||||
name: "update",
|
||||
description: "Update all HyperFrames skills to the latest — installs any not yet present",
|
||||
},
|
||||
args: {},
|
||||
async run() {
|
||||
await installAllSkills();
|
||||
// `skills add --all` re-fetches every skill to the latest AND installs ones
|
||||
// not yet present — so "update" pulls the full set, not just what is already
|
||||
// installed. This is where `init` and the stale-skills nudge both lead.
|
||||
//
|
||||
// strict: this is the documented recovery path for the agent/CI contract
|
||||
// `hyperframes skills check || hyperframes skills update`. If the install
|
||||
// fails (no npx, `skills add` exits non-zero) it must exit non-zero too —
|
||||
// otherwise the `||` chain passes while nothing actually changed.
|
||||
try {
|
||||
await installAllSkills({ extraArgs: ["--all", "--yes"], strict: true });
|
||||
} catch (err) {
|
||||
clack.log.error(c.error(`Update failed: ${(err as Error).message}`));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default defineCommand({
|
||||
meta: {
|
||||
name: "skills",
|
||||
description: "Install, check, and update HyperFrames skills for AI coding tools",
|
||||
},
|
||||
subCommands: {
|
||||
check: checkCommand,
|
||||
update: updateCommand,
|
||||
},
|
||||
args: {},
|
||||
async run({ args }) {
|
||||
// citty runs this parent handler even when a subcommand matches; guard on
|
||||
// the positional so bare `hyperframes skills` installs, while
|
||||
// `hyperframes skills check|update` does not also re-install.
|
||||
if (!args._?.[0]) await installAllSkills();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -55,6 +55,14 @@ export interface HyperframesConfig {
|
||||
/** True after the result has been surfaced once to the user. */
|
||||
reported?: boolean;
|
||||
};
|
||||
/** ISO timestamp of the last `skills check` freshness check (24h cache). */
|
||||
lastSkillsCheck?: string;
|
||||
/** Whether installed skills were stale at the last check. */
|
||||
skillsUpdateAvailable?: boolean;
|
||||
/** How many installed skills were outdated at the last check. */
|
||||
skillsOutdatedCount?: number;
|
||||
/** How many skills were missing (not installed) at the last check. */
|
||||
skillsMissingCount?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: HyperframesConfig = {
|
||||
@@ -96,6 +104,10 @@ export function readConfig(): HyperframesConfig {
|
||||
latestVersion: parsed.latestVersion,
|
||||
pendingUpdate: parsed.pendingUpdate,
|
||||
completedUpdate: parsed.completedUpdate,
|
||||
lastSkillsCheck: parsed.lastSkillsCheck,
|
||||
skillsUpdateAvailable: parsed.skillsUpdateAvailable,
|
||||
skillsOutdatedCount: parsed.skillsOutdatedCount,
|
||||
skillsMissingCount: parsed.skillsMissingCount,
|
||||
};
|
||||
|
||||
cachedConfig = config;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"source": "heygen-com/hyperframes",
|
||||
"skills": {
|
||||
"embedded-captions": {
|
||||
"hash": "d5561ac019e72ed5",
|
||||
"files": 144
|
||||
},
|
||||
"faceless-explainer": {
|
||||
"hash": "d6beeca6029b815a",
|
||||
"files": 17
|
||||
},
|
||||
"general-video": {
|
||||
"hash": "a30225e30ec7b06c",
|
||||
"files": 1
|
||||
},
|
||||
"hyperframes": {
|
||||
"hash": "00ad363cda72268d",
|
||||
"files": 1
|
||||
},
|
||||
"hyperframes-animation": {
|
||||
"hash": "ced7bd16b3cdb376",
|
||||
"files": 108
|
||||
},
|
||||
"hyperframes-cli": {
|
||||
"hash": "ed24d781c2ae462b",
|
||||
"files": 7
|
||||
},
|
||||
"hyperframes-core": {
|
||||
"hash": "6243b6f09c94cce1",
|
||||
"files": 13
|
||||
},
|
||||
"hyperframes-creative": {
|
||||
"hash": "bb248c1bc5cc28b5",
|
||||
"files": 67
|
||||
},
|
||||
"hyperframes-media": {
|
||||
"hash": "ce80c5a15ecaf0fc",
|
||||
"files": 40
|
||||
},
|
||||
"hyperframes-registry": {
|
||||
"hash": "e3b389526834109d",
|
||||
"files": 10
|
||||
},
|
||||
"media-use": {
|
||||
"hash": "f0edb0fd7cd513a5",
|
||||
"files": 19
|
||||
},
|
||||
"motion-graphics": {
|
||||
"hash": "5a256811f730f36e",
|
||||
"files": 23
|
||||
},
|
||||
"music-to-video": {
|
||||
"hash": "abea9101f4322954",
|
||||
"files": 132
|
||||
},
|
||||
"pr-to-video": {
|
||||
"hash": "d7acaeb6281f99ac",
|
||||
"files": 21
|
||||
},
|
||||
"product-launch-video": {
|
||||
"hash": "5669874d3bdd5bd4",
|
||||
"files": 18
|
||||
},
|
||||
"remotion-to-hyperframes": {
|
||||
"hash": "9d959b31fa0fc9d0",
|
||||
"files": 70
|
||||
},
|
||||
"slideshow": {
|
||||
"hash": "ae8d8f3093ef0c04",
|
||||
"files": 2
|
||||
},
|
||||
"talking-head-recut": {
|
||||
"hash": "a3dc251f26be8a3e",
|
||||
"files": 27
|
||||
},
|
||||
"website-to-video": {
|
||||
"hash": "ac39931f9a7da749",
|
||||
"files": 32
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,17 @@ Once you've picked a workflow, check it's actually available to you. If the matc
|
||||
|
||||
After they run it, re-read the workflow's skill and continue.
|
||||
|
||||
## Keeping skills current
|
||||
|
||||
HyperFrames skills are versioned. `npx hyperframes init` checks the installed skills against the latest on GitHub and installs/refreshes the **full** set whenever anything is out of date or missing — so a freshly init'd project always has the complete, latest set (and re-running init on an up-to-date project is a no-op). The check is a quick GitHub round-trip; offline (or rate-limited) it falls back to installing after a short timeout, so init never hard-fails on a network hiccup. Opt out entirely with `init --skip-skills`.
|
||||
|
||||
If a task is behaving unexpectedly, or before a long build, confirm the installed skills are current:
|
||||
|
||||
- **Check:** `npx hyperframes skills check` (add `--json` for a machine-readable verdict; exits non-zero when anything is outdated **or missing**).
|
||||
- **Update:** `npx hyperframes skills update` — pulls the full set to the latest, **installing any not yet present** (same as init's install step).
|
||||
|
||||
The CLI also surfaces a one-line reminder when a `render` / `lint` / `validate` run detects stale skills.
|
||||
|
||||
## Workflow details
|
||||
|
||||
### `/product-launch-video`
|
||||
|
||||
Reference in New Issue
Block a user