From cbc16d06930ebbb9354b732c9eeb0ced1a7ef16c Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 30 Jun 2026 19:28:10 -0700 Subject: [PATCH 1/2] fix(skills): make hyperframes package version resolution non-fatal and overridable Global skill installs (e.g. ~/.claude/skills) have no hyperframes package.json in the loader's ancestor chain, so readBundledHyperframesVersion returns null and hyperframesPackageSpec threw. Because the spec is passed eagerly as an argument to importPackagesOrBootstrap, it threw even when @hyperframes/producer was already installed and no bootstrap was needed. Resolution order is now: HYPERFRAMES_SKILL_PKG_VERSION env override first, then the bundled/in-repo version, then a non-throwing @latest fallback that warns to stderr. @latest satisfies the pinned-spec guard so bootstrap still installs, and already-installed packages import fine; the eager argument is now harmless. In-repo resolution is unchanged (same pinned version). Applied to both package-loader.mjs copies (animation + creative) and documented the env var in animation-map.mjs and contrast-report.mjs usage. Adds a focused test covering override wins, in-repo pin, and unresolvable @latest fallback. --- skills-manifest.json | 6 +- .../scripts/animation-map.mjs | 5 ++ .../scripts/package-loader.mjs | 27 +++++--- .../scripts/package-loader.test.mjs | 62 +++++++++++++++++++ .../scripts/contrast-report.mjs | 5 ++ .../scripts/package-loader.mjs | 27 +++++--- 6 files changed, 111 insertions(+), 21 deletions(-) create mode 100644 skills/hyperframes-animation/scripts/package-loader.test.mjs diff --git a/skills-manifest.json b/skills-manifest.json index fcd78fcbb6..6b5ac7cdcf 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -18,8 +18,8 @@ "files": 1 }, "hyperframes-animation": { - "hash": "57458f4308708e21", - "files": 115 + "hash": "1e7fea875867dbbd", + "files": 116 }, "hyperframes-cli": { "hash": "9b36a367a0e3a332", @@ -30,7 +30,7 @@ "files": 13 }, "hyperframes-creative": { - "hash": "bb248c1bc5cc28b5", + "hash": "2e36b6d88c5540ac", "files": 67 }, "hyperframes-media": { diff --git a/skills/hyperframes-animation/scripts/animation-map.mjs b/skills/hyperframes-animation/scripts/animation-map.mjs index 95771ab795..f95bb09331 100644 --- a/skills/hyperframes-animation/scripts/animation-map.mjs +++ b/skills/hyperframes-animation/scripts/animation-map.mjs @@ -8,6 +8,11 @@ // Usage: // node skills/hyperframes-animation/scripts/animation-map.mjs \ // [--frames N] [--out ] [--min-duration S] [--width W] [--height H] [--fps N] +// +// Env: +// HYPERFRAMES_SKILL_PKG_VERSION — pin the @hyperframes/producer version used +// when bootstrapping (global skill installs cannot infer it; falls back to +// @latest with a warning otherwise). import { mkdir, writeFile } from "node:fs/promises"; import { resolve, join } from "node:path"; diff --git a/skills/hyperframes-animation/scripts/package-loader.mjs b/skills/hyperframes-animation/scripts/package-loader.mjs index d3e388225a..063bf4fe0c 100644 --- a/skills/hyperframes-animation/scripts/package-loader.mjs +++ b/skills/hyperframes-animation/scripts/package-loader.mjs @@ -7,6 +7,7 @@ import { createInterface } from "node:readline/promises"; import { fileURLToPath, pathToFileURL } from "node:url"; const HERE = dirname(fileURLToPath(import.meta.url)); +const VERSION_OVERRIDE_ENV = "HYPERFRAMES_SKILL_PKG_VERSION"; const BOOTSTRAP_ENV = "HYPERFRAMES_SKILL_DEPS_BOOTSTRAPPED"; const BOOTSTRAP_CONFIRM_ENV = "HYPERFRAMES_SKILL_BOOTSTRAP_DEPS"; const NODE_MODULES_ENV = "HYPERFRAMES_SKILL_NODE_MODULES"; @@ -46,16 +47,24 @@ export async function importPackagesOrBootstrap(packageNames, options = {}) { } export function hyperframesPackageSpec(packageName) { + const override = process.env[VERSION_OVERRIDE_ENV]?.trim(); + if (override) return `${packageName}@${override}`; + const version = readBundledHyperframesVersion(); - if (!version) { - throw new Error( - [ - `Could not determine the bundled HyperFrames version for ${packageName}.`, - "Install the package yourself or pass a pinned options.npmPackages entry.", - ].join("\n"), - ); - } - return `${packageName}@${version}`; + if (version) return `${packageName}@${version}`; + + // Global skill installs (e.g. ~/.claude/skills) have no hyperframes package.json + // in their ancestor chain, so the bundled version is unknowable. Fall back to + // @latest instead of throwing: already-installed packages still import, and a + // bootstrap install can still proceed (@latest satisfies the pinned-spec guard). + process.stderr.write( + [ + `hyperframes: could not determine the bundled version for ${packageName}; using @latest.`, + `Set ${VERSION_OVERRIDE_ENV}= to pin it.`, + "", + ].join("\n"), + ); + return `${packageName}@latest`; } function resolvePackageEntry(packageName) { diff --git a/skills/hyperframes-animation/scripts/package-loader.test.mjs b/skills/hyperframes-animation/scripts/package-loader.test.mjs new file mode 100644 index 0000000000..7b36a3ceb3 --- /dev/null +++ b/skills/hyperframes-animation/scripts/package-loader.test.mjs @@ -0,0 +1,62 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { copyFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ENV = "HYPERFRAMES_SKILL_PKG_VERSION"; + +// (a) env override wins — no ancestor lookup, exact version echoed back. +test("hyperframesPackageSpec: env override wins", async () => { + const prev = process.env[ENV]; + process.env[ENV] = "9.9.9"; + try { + const { hyperframesPackageSpec } = await import("./package-loader.mjs"); + assert.equal(hyperframesPackageSpec("@hyperframes/producer"), "@hyperframes/producer@9.9.9"); + } finally { + if (prev === undefined) delete process.env[ENV]; + else process.env[ENV] = prev; + } +}); + +// (b) resolvable version (in-repo) pins the bundled hyperframes/@hyperframes/cli version. +test("hyperframesPackageSpec: resolvable in-repo version pins it", async () => { + const prev = process.env[ENV]; + delete process.env[ENV]; + try { + const { hyperframesPackageSpec } = await import("./package-loader.mjs"); + const spec = hyperframesPackageSpec("@hyperframes/producer"); + assert.match(spec, /^@hyperframes\/producer@\d+\.\d+\.\d+/); + } finally { + if (prev !== undefined) process.env[ENV] = prev; + } +}); + +// (c) unresolvable + no override -> @latest fallback, no throw (global-install case). +// Copy the loader into an isolated temp dir whose ancestor chain has no hyperframes +// package.json, and run node from there so cwd cannot resolve one either. +test("hyperframesPackageSpec: unresolvable falls back to @latest without throwing", () => { + const dir = mkdtempSync(join(tmpdir(), "hf-pkgloader-")); + try { + copyFileSync(join(HERE, "package-loader.mjs"), join(dir, "package-loader.mjs")); + const probe = join(dir, "probe.mjs"); + writeFileSync( + probe, + [ + 'import { hyperframesPackageSpec } from "./package-loader.mjs";', + 'process.stdout.write(hyperframesPackageSpec("@hyperframes/producer"));', + "", + ].join("\n"), + ); + const res = spawnSync(process.execPath, [probe], { cwd: dir, encoding: "utf8" }); + assert.equal(res.status, 0, res.stderr); + assert.equal(res.stdout.trim(), "@hyperframes/producer@latest"); + assert.match(res.stderr, /using @latest/); + assert.match(res.stderr, new RegExp(ENV)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/skills/hyperframes-creative/scripts/contrast-report.mjs b/skills/hyperframes-creative/scripts/contrast-report.mjs index cd18eaeb7e..ee9b7835b8 100644 --- a/skills/hyperframes-creative/scripts/contrast-report.mjs +++ b/skills/hyperframes-creative/scripts/contrast-report.mjs @@ -12,6 +12,11 @@ // node skills/hyperframes-creative/scripts/contrast-report.mjs \ // [--samples N] [--out ] [--width W] [--height H] [--fps N] // +// Env: +// HYPERFRAMES_SKILL_PKG_VERSION — pin the @hyperframes/producer version used +// when bootstrapping (global skill installs cannot infer it; falls back to +// @latest with a warning otherwise). +// // The composition directory must contain an index.html. Raw authoring HTML // works — the producer's file server auto-injects the runtime at serve time. // Exits 1 if any text element fails WCAG AA. diff --git a/skills/hyperframes-creative/scripts/package-loader.mjs b/skills/hyperframes-creative/scripts/package-loader.mjs index d3e388225a..063bf4fe0c 100644 --- a/skills/hyperframes-creative/scripts/package-loader.mjs +++ b/skills/hyperframes-creative/scripts/package-loader.mjs @@ -7,6 +7,7 @@ import { createInterface } from "node:readline/promises"; import { fileURLToPath, pathToFileURL } from "node:url"; const HERE = dirname(fileURLToPath(import.meta.url)); +const VERSION_OVERRIDE_ENV = "HYPERFRAMES_SKILL_PKG_VERSION"; const BOOTSTRAP_ENV = "HYPERFRAMES_SKILL_DEPS_BOOTSTRAPPED"; const BOOTSTRAP_CONFIRM_ENV = "HYPERFRAMES_SKILL_BOOTSTRAP_DEPS"; const NODE_MODULES_ENV = "HYPERFRAMES_SKILL_NODE_MODULES"; @@ -46,16 +47,24 @@ export async function importPackagesOrBootstrap(packageNames, options = {}) { } export function hyperframesPackageSpec(packageName) { + const override = process.env[VERSION_OVERRIDE_ENV]?.trim(); + if (override) return `${packageName}@${override}`; + const version = readBundledHyperframesVersion(); - if (!version) { - throw new Error( - [ - `Could not determine the bundled HyperFrames version for ${packageName}.`, - "Install the package yourself or pass a pinned options.npmPackages entry.", - ].join("\n"), - ); - } - return `${packageName}@${version}`; + if (version) return `${packageName}@${version}`; + + // Global skill installs (e.g. ~/.claude/skills) have no hyperframes package.json + // in their ancestor chain, so the bundled version is unknowable. Fall back to + // @latest instead of throwing: already-installed packages still import, and a + // bootstrap install can still proceed (@latest satisfies the pinned-spec guard). + process.stderr.write( + [ + `hyperframes: could not determine the bundled version for ${packageName}; using @latest.`, + `Set ${VERSION_OVERRIDE_ENV}= to pin it.`, + "", + ].join("\n"), + ); + return `${packageName}@latest`; } function resolvePackageEntry(packageName) { From 71c398c502741230a08a8f667d1a71c279fdaf68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 1 Jul 2026 02:55:42 +0000 Subject: [PATCH 2/2] test(skills): cover creative package loader fallback --- skills-manifest.json | 6 +- skills/hyperframes-animation/SKILL.md | 2 + skills/hyperframes-creative/SKILL.md | 2 + .../scripts/package-loader.test.mjs | 62 +++++++++++++++++++ 4 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 skills/hyperframes-creative/scripts/package-loader.test.mjs diff --git a/skills-manifest.json b/skills-manifest.json index 6b5ac7cdcf..cf3d5ba607 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -18,7 +18,7 @@ "files": 1 }, "hyperframes-animation": { - "hash": "1e7fea875867dbbd", + "hash": "f789d7f95d9ad2fe", "files": 116 }, "hyperframes-cli": { @@ -30,8 +30,8 @@ "files": 13 }, "hyperframes-creative": { - "hash": "2e36b6d88c5540ac", - "files": 67 + "hash": "8573b34712e14ab1", + "files": 68 }, "hyperframes-media": { "hash": "096b2ab0a43b05dd", diff --git a/skills/hyperframes-animation/SKILL.md b/skills/hyperframes-animation/SKILL.md index 416c7c10a4..8e7c03250d 100644 --- a/skills/hyperframes-animation/SKILL.md +++ b/skills/hyperframes-animation/SKILL.md @@ -75,6 +75,8 @@ node skills/hyperframes-animation/scripts/animation-map.mjs \ Reads every GSAP timeline registered on `window.__timelines`, enumerates tweens, samples bboxes, computes flags, outputs `animation-map.json`. Use it to audit choreography (dead zones, stagger consistency, lifecycle warnings) after authoring. +`animation-map.mjs` resolves helper packages from the current project first, then can bootstrap the bundled HyperFrames package version. Set `HYPERFRAMES_SKILL_PKG_VERSION=` only when running the skill outside the bundled CLI/skill install and you need to pin that bootstrap version explicitly. + ## See Also - `hyperframes-core` — composition structure, data attributes, sub-compositions, deterministic render contract diff --git a/skills/hyperframes-creative/SKILL.md b/skills/hyperframes-creative/SKILL.md index e249645944..e76ff5e78d 100644 --- a/skills/hyperframes-creative/SKILL.md +++ b/skills/hyperframes-creative/SKILL.md @@ -52,6 +52,8 @@ For motion patterns, scene blueprints, transitions, and CSS marker effects, use - `scripts/extract-audio-data.py` — pre-extract audio bands for audio-reactive compositions. - `scripts/package-loader.mjs` — support script for bundled creative tooling. +`contrast-report.mjs` resolves helper packages from the current project first, then can bootstrap the bundled HyperFrames package version. Set `HYPERFRAMES_SKILL_PKG_VERSION=` only when running the skill outside the bundled CLI/skill install and you need to pin that bootstrap version explicitly. + Run from the repo root with explicit paths, for example: ```bash diff --git a/skills/hyperframes-creative/scripts/package-loader.test.mjs b/skills/hyperframes-creative/scripts/package-loader.test.mjs new file mode 100644 index 0000000000..7b36a3ceb3 --- /dev/null +++ b/skills/hyperframes-creative/scripts/package-loader.test.mjs @@ -0,0 +1,62 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { copyFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ENV = "HYPERFRAMES_SKILL_PKG_VERSION"; + +// (a) env override wins — no ancestor lookup, exact version echoed back. +test("hyperframesPackageSpec: env override wins", async () => { + const prev = process.env[ENV]; + process.env[ENV] = "9.9.9"; + try { + const { hyperframesPackageSpec } = await import("./package-loader.mjs"); + assert.equal(hyperframesPackageSpec("@hyperframes/producer"), "@hyperframes/producer@9.9.9"); + } finally { + if (prev === undefined) delete process.env[ENV]; + else process.env[ENV] = prev; + } +}); + +// (b) resolvable version (in-repo) pins the bundled hyperframes/@hyperframes/cli version. +test("hyperframesPackageSpec: resolvable in-repo version pins it", async () => { + const prev = process.env[ENV]; + delete process.env[ENV]; + try { + const { hyperframesPackageSpec } = await import("./package-loader.mjs"); + const spec = hyperframesPackageSpec("@hyperframes/producer"); + assert.match(spec, /^@hyperframes\/producer@\d+\.\d+\.\d+/); + } finally { + if (prev !== undefined) process.env[ENV] = prev; + } +}); + +// (c) unresolvable + no override -> @latest fallback, no throw (global-install case). +// Copy the loader into an isolated temp dir whose ancestor chain has no hyperframes +// package.json, and run node from there so cwd cannot resolve one either. +test("hyperframesPackageSpec: unresolvable falls back to @latest without throwing", () => { + const dir = mkdtempSync(join(tmpdir(), "hf-pkgloader-")); + try { + copyFileSync(join(HERE, "package-loader.mjs"), join(dir, "package-loader.mjs")); + const probe = join(dir, "probe.mjs"); + writeFileSync( + probe, + [ + 'import { hyperframesPackageSpec } from "./package-loader.mjs";', + 'process.stdout.write(hyperframesPackageSpec("@hyperframes/producer"));', + "", + ].join("\n"), + ); + const res = spawnSync(process.execPath, [probe], { cwd: dir, encoding: "utf8" }); + assert.equal(res.status, 0, res.stderr); + assert.equal(res.stdout.trim(), "@hyperframes/producer@latest"); + assert.match(res.stderr, /using @latest/); + assert.match(res.stderr, new RegExp(ENV)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +});