Skip to content

Commit b2acef5

Browse files
Vitozhu04claude
andauthored
fix(cli): no auto-update across 0.x minors; install skills at the CLI's release tag (#7013)
* fix(cli): no auto-update across 0.x minors; install skills at the CLI's release tag - autoUpdate: before 1.0 a minor bump counts as a major one, so 0.8.x -> 0.9.x is a notice, not a silent background install. From 1.0 on, the major alone. - skills: `skills update` / bare `skills` / `init` install from heygen-com/hyperframes#v<VERSION>, and `skills check` plus the background freshness nudge read the manifest at the same tag. A build the repo has no tag for (dev build) falls back to main, as before. - skills: run the upstream installer as `npx [email protected]` instead of an unpinned `npx skills`. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(cli): keep the comment share of the touched files from rising Comment ratchet: trim the comments added in the previous commit. No code change. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(cli): pass the CLI version into the skills check instead of importing it scripts/clawhub-unlist-retired.mjs loads skillsManifest.ts from source under plain node, which cannot resolve a relative `../version.js` import. Callers now pass `cliVersion` (and skillsSourceRef takes it as an argument), so the module keeps importing only node builtins. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(cli): read skills at the CLI's tag without a probe; pin [email protected] - A release build reads the manifest at its own tag directly (publish pushes the tag before npm publish). Only a 404 for that manifest falls back to main; a timeout, missing git or proxy error now surfaces instead of quietly comparing against main, so the background nudge can't cache a false "outdated" count. - checkSkills returns the ref it read, and skills update installs from that same ref, so check and install can't disagree within one run. - Pin [email protected] (it parses `#<ref>` too) so the pin matches the version agentDirs.generated.ts and the lock-path notes were checked against. Co-Authored-By: Claude Opus 5.5 <[email protected]> * refactor(cli): split the tag and main manifest fetches Fallow flagged fetchRemoteManifest's CRAP score (31.6 > 30). The tag fetch (404 is the only fallback to main) and the SHA-pinned main fetch are now their own functions. No behaviour change. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(cli): never auto-upgrade a Homebrew install `brew upgrade hyperframes` installs whatever formula the tap holds, so a background run scheduled for an allowed patch could still cross a 0.x minor. Homebrew installs now only get the update notice naming `brew upgrade`. A new test drives the scheduler through the real installer detection and installer script: no spawn on Homebrew, a versioned npm install for a patch. Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
1 parent 35753f3 commit b2acef5

12 files changed

Lines changed: 445 additions & 60 deletions

‎CLAUDE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ npx skills add heygen-com/hyperframes --skill <name> # a named match installs r
1616

1717
> **Warning:** Never run `npx skills add ... --all` from inside a HyperFrames checkout: it selects the OpenClaw target, whose `skills/` directory is the repo's own `skills/`, and removes each existing `skills/<name>` destination before linking. A fresh clone restores tracked source, but uncommitted work under `skills/` does not recover. Run it from an empty directory or from your own project root. This happens regardless of `metadata.internal`.
1818
19-
`skills add` resolves the skills.sh registry blob, which can lag `main` by hours, so a freshly added skill may be a little behind. `npx hyperframes skills update` installs from the current `main`; prefer it when freshness matters.
19+
`skills add` resolves the skills.sh registry blob, which can lag `main` by hours, so a freshly added skill may be a little behind. `npx hyperframes skills update` installs the skills from the release tag that matches the CLI version, so they match the CLI that runs them.
2020

2121
Make any video by asking for it; the `/hyperframes` skill carries the render contract and the references (format, runtimes, motion, look, captions, film, overlays, storyboards). The skills carry knowledge, not process. The README skills section mirrors this list; keep them in sync (see "Skill catalog maintenance" below).
2222

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ npx skills add heygen-com/hyperframes
6666

6767
> The picker opens with nothing pre-selected — the **Core Skills** group is all you need; `/slideshow`, `/remotion-to-hyperframes`, `/reverse-any-video`, `/style-sampling` and `/figma` install on demand. Agents and non-interactive runs should use `npx hyperframes skills update` instead — it installs exactly the core set, whereas `skills add --all` installs all 9 published skills. The six repo-internal skills under `.claude/skills` / `.agents/skills` are excluded by default. For the full published set use `npx hyperframes skills`.
6868
>
69-
> `skills add` resolves the skills.sh registry blob, which can lag `main` by hours. `npx hyperframes skills update` installs from the current `main`, so reach for it when you need the newest copy of a skill.
69+
> `skills add` resolves the skills.sh registry blob, which can lag `main` by hours. `npx hyperframes skills update` installs the skills from the release tag that matches your CLI version, so they match the CLI that runs them.
7070
7171
Run project-local installs from your video project's directory. From a HyperFrames
7272
checkout, use `npx hyperframes skills update` for a global install instead: a

‎docs/packages/cli.mdx‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,10 +107,12 @@ their final line, on stderr.
107107
`2` means the command line itself is wrong: an unknown command or flag, or a
108108
missing or invalid value.
109109

110-
**Version notices.** An owned install (npm, bun, pnpm, brew) updates itself
110+
**Version notices.** An owned install (npm, bun, pnpm) updates itself
111111
quietly in the background and prints one line on the next run:
112-
`hyperframes auto-updated to v0.1.5`. It never crosses a major version. Anywhere
113-
it cannot install — `npx`, an unknown installer — you get a notice instead:
112+
`hyperframes auto-updated to v0.1.5`. It never crosses a major version (before
113+
1.0, a minor version: 0.8 to 0.9 is a notice, not an install). Anywhere
114+
it cannot install — Homebrew (run `brew upgrade hyperframes` yourself), `npx`, an
115+
unknown installer — you get a notice instead:
114116

115117
```
116118
Update available: 0.1.4 → 0.1.5
@@ -2193,7 +2195,9 @@ Set `HYPERFRAMES_SKIP_SKILLS=1` when something else manages the skills (CI, test
21932195
or an install pinned to one commit). `skills update` and bare `skills` then exit 0
21942196
with a one-line notice and leave the installed skills unchanged.
21952197

2196-
The CLI installs from the current HyperFrames GitHub source and links the global
2198+
The CLI installs the skills from the HyperFrames release tag that matches its own
2199+
version (`v<version>`), and `check` compares against that same tag, so the skills
2200+
match the CLI that runs them; a development build uses `main`. It links the global
21972201
bundles into whichever compatible agents it finds. After scaffolding, `init`
21982202
installs the core set if it is missing. It never refreshes or removes a skill you already have; run `skills update` for that.
21992203

‎packages/cli/src/commands/skills.test.ts‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ describe("hyperframes skills", () => {
210210
const first = state.spawnCalls[0];
211211
expect(first).toBeDefined();
212212
expect(first!.command).toBe("npx");
213-
expect(first!.args).toContain("skills");
213+
expect(first!.args).toContain("skills@1.5.13");
214214
expect(first!.args).toContain("add");
215215
expect(first!.env?.GIT_CLONE_PROTECTION_ACTIVE).toBe("0");
216216
// --full-depth clones the repo; skip LFS so we don't drag in unrelated blobs.
@@ -223,7 +223,7 @@ describe("hyperframes skills", () => {
223223
"npx",
224224
["--version"],
225225
[
226-
"skills",
226+
"skills@1.5.13",
227227
"add",
228228
"https://github.com/heygen-com/hyperframes",
229229
"--skill",
@@ -240,7 +240,7 @@ describe("hyperframes skills", () => {
240240
"npx",
241241
["--version"],
242242
[
243-
"skills",
243+
"skills@1.5.13",
244244
"add",
245245
"https://github.com/heygen-com/hyperframes",
246246
"--skill",
@@ -261,7 +261,7 @@ describe("hyperframes skills", () => {
261261
"/s",
262262
"/c",
263263
"npx.cmd",
264-
"skills",
264+
"skills@1.5.13",
265265
"add",
266266
"https://github.com/heygen-com/hyperframes",
267267
"--skill",
@@ -322,6 +322,25 @@ describe("hyperframes skills", () => {
322322
expect(agentValue).not.toBe("*");
323323
});
324324

325+
// The install uses the ref the check compared against (the CLI's release
326+
// tag), resolved once, through the pinned skills CLI.
327+
it("skills update installs at the ref its check read, through the pinned skills CLI", async () => {
328+
setPlatform("linux");
329+
const { checkSkills } = await import("../utils/skillsManifest.js");
330+
vi.mocked(checkSkills).mockResolvedValueOnce({ ...DEFAULT_CHECK, ref: "v0.9.3" } as never);
331+
332+
await runSkillsUpdate();
333+
334+
expect(await commandExitCode()).toBe(0);
335+
const args = state.spawnCalls[0]?.args ?? [];
336+
expect(args.slice(0, 3)).toEqual([
337+
338+
"add",
339+
"https://github.com/heygen-com/hyperframes#v0.9.3",
340+
]);
341+
expect(args).toContain("--full-depth");
342+
});
343+
325344
it("skills update refreshes an outdated installed workflow (but never expands)", async () => {
326345
setPlatform("linux");
327346
const { checkSkills } = await import("../utils/skillsManifest.js");

‎packages/cli/src/commands/skills.ts‎

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as clack from "../ui/prompts.js";
55
import { c } from "../ui/colors.js";
66
import { diag } from "../ui/diagnostics.js";
77
import { buildNpxCommand } from "../utils/npxCommand.js";
8+
import { VERSION } from "../version.js";
89
import { withMeta } from "../utils/updateCheck.js";
910
import {
1011
checkSkills,
@@ -14,6 +15,7 @@ import {
1415
presentSkills,
1516
RETIRED_SKILLS,
1617
retiredSkillNotice,
18+
cliReleaseTag,
1719
type SkillDiff,
1820
type SkillsCheckResult,
1921
} from "../utils/skillsManifest.js";
@@ -105,7 +107,7 @@ function spawnNpx(args: string[], opts: { cwd?: string } = {}): Promise<void> {
105107
// Why --copy: real files (not the upstream symlink default, which re-serialises
106108
// each SKILL.md's frontmatter) so an installed bundle byte-matches the published
107109
// manifest and `skills check` reads it as current. Why --full-depth: it forces a
108-
// full `git clone` of HEAD; without it even a full-URL `skills add` fetches the
110+
// full `git clone` of the requested ref; without it even a full-URL `skills add` fetches the
109111
// skills.sh registry blob, which lags GitHub main by hours, so a fresh install
110112
// would read as several skills "outdated" (verified: blob → ~9 outdated;
111113
// --full-depth → all current).
@@ -129,13 +131,16 @@ function skillSelectionArgs(selection: SkillSelection): string[] {
129131
return names.flatMap((name) => ["--skill", name]);
130132
}
131133

134+
// Pinned: a bare `npx skills` runs whatever upstream published last.
135+
const SKILLS_CLI = "[email protected]";
136+
132137
function runSkillsAdd(
133138
source: string,
134139
selection: SkillSelection,
135140
opts: { cwd?: string } = {},
136141
): Promise<void> {
137142
return spawnNpx(
138-
["skills", "add", source, ...skillSelectionArgs(selection), ...GLOBAL_INSTALL_ARGS_TAIL],
143+
[SKILLS_CLI, "add", source, ...skillSelectionArgs(selection), ...GLOBAL_INSTALL_ARGS_TAIL],
139144
opts,
140145
);
141146
}
@@ -147,10 +152,9 @@ function runSkillsAdd(
147152
// on the Windows `cmd.exe` spawn path, where arg escaping is fragile.
148153
const PLAIN_SKILL_NAME = /^[a-z0-9][a-z0-9._-]*$/i;
149154

150-
// Use the full GitHub URL (not the `owner/repo` slug) as the clone source. The
151-
// freshness comes from --full-depth (see GLOBAL_INSTALL_ARGS_TAIL), which clones the
152-
// repo at latest `main`; the URL just names what to clone. Our freshness check
153-
// resolves "latest" straight from GitHub too, so install and check agree.
155+
// Use the full GitHub URL (not the `owner/repo` slug) as the clone source.
156+
// --full-depth (see GLOBAL_INSTALL_ARGS_TAIL) clones it at `#<ref>`: the ref the
157+
// check read its manifest at (this CLI's release tag), so check and install agree.
154158
const SOURCES = [{ name: "HyperFrames", url: "https://github.com/heygen-com/hyperframes" }];
155159

156160
// Fan HyperFrames' own skills out to every other installed agent. Scope by the
@@ -242,21 +246,22 @@ function sanitizeSelection(selection: SkillSelection): SkillSelection | null {
242246

243247
async function installSkills(
244248
selection: SkillSelection,
245-
opts: { cwd?: string; strict?: boolean } = {},
249+
opts: { cwd?: string; strict?: boolean; ref: string },
246250
): Promise<void> {
247251
const safeSelection = sanitizeSelection(selection);
248252
if (safeSelection === null) return;
249253

250254
if (!skillsToolingReady(opts.strict ?? false)) return;
251255

256+
const { ref } = opts;
252257
// stderr (via diag): installSkills runs on the `skills update --json` path before
253258
// its JSON envelope is written to stdout — progress here must not corrupt it.
254259
for (const source of SOURCES) {
255260
diag.notice();
256261
diag.notice(c.bold(`Installing ${source.name} skills...`));
257262
diag.notice();
258263
try {
259-
await runSkillsAdd(source.url, safeSelection, opts);
264+
await runSkillsAdd(ref === "main" ? source.url : `${source.url}#${ref}`, safeSelection, opts);
260265
} catch (err) {
261266
if (opts.strict) throw err instanceof Error ? err : new Error(String(err));
262267
// warn, not notice: this is a non-fatal skip after a caught install error.
@@ -341,7 +346,7 @@ export async function updateSkills(
341346
// that no longer exists, and verifyInstalled would strict-throw on a
342347
// "failure" that was never real. Resolving canonically means a retired
343348
// skill simply never appears as a target in the first place.
344-
check = await checkSkills({ cwd: opts.cwd, canonical: true });
349+
check = await checkSkills({ cwd: opts.cwd, canonical: true, cliVersion: VERSION });
345350
} catch (err) {
346351
// A *malformed* canonical manifest (the server was reached, but served a
347352
// bad shape) is otherwise indistinguishable from being offline — both fall
@@ -402,7 +407,7 @@ export async function updateSkills(
402407
};
403408

404409
if (toInstall.length > 0) {
405-
await installSkills(result.installed, { cwd: opts.cwd, strict });
410+
await installSkills(result.installed, { cwd: opts.cwd, strict, ref: check.ref ?? "main" });
406411
verifyInstalled(result.installed, { strict, cwd: opts.cwd });
407412
}
408413
// The install (or the fresh canonical check confirming everything current)
@@ -479,7 +484,11 @@ async function updateSkillsOffline(
479484
);
480485

481486
if (absent.length > 0) {
482-
await installSkills(absent, { cwd: opts.cwd, strict: opts.strict });
487+
await installSkills(absent, {
488+
cwd: opts.cwd,
489+
strict: opts.strict,
490+
ref: cliReleaseTag(VERSION) ?? "main",
491+
});
483492
verifyInstalled(absent, opts);
484493
}
485494
return {
@@ -572,6 +581,7 @@ const checkCommand = defineCommand({
572581
const result = await checkSkills({
573582
dir: args.dir,
574583
source: args.source,
584+
cliVersion: VERSION,
575585
});
576586

577587
if (args.json) console.log(JSON.stringify(withMeta(result), null, 2));
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { afterEach, expect, it, vi } from "vitest";
2+
3+
// Drives scheduleBackgroundInstall through the real installer detection, argv helper and installer
4+
// script; only the entry path, the settings file and spawn itself are stubbed.
5+
const BREW_ENTRY =
6+
"/opt/homebrew/Cellar/hyperframes/0.8.231/libexec/lib/node_modules/hyperframes/dist/cli.js";
7+
const NPM_ENTRY = "/usr/local/lib/node_modules/hyperframes/dist/cli.js";
8+
9+
const originalArgv = [...process.argv];
10+
const originalEnv = { ...process.env };
11+
const originalTTY = process.stderr.isTTY;
12+
const originalWrite = process.stderr.write.bind(process.stderr);
13+
14+
afterEach(() => {
15+
process.argv = [...originalArgv];
16+
process.env = { ...originalEnv };
17+
Object.defineProperty(process.stderr, "isTTY", { value: originalTTY, configurable: true });
18+
process.stderr.write = originalWrite;
19+
vi.resetModules();
20+
vi.doUnmock("../telemetry/config.js");
21+
vi.doUnmock("../version.js");
22+
vi.doUnmock("./env.js");
23+
vi.doUnmock("./attendedTerminal.js");
24+
vi.doUnmock("node:child_process");
25+
vi.doUnmock("node:fs");
26+
});
27+
28+
/** A 0.8.231 install at `entry` whose cached latest is the allowed patch 0.8.232. */
29+
async function patchAvailableAt(entry: string) {
30+
vi.resetModules();
31+
const config: Record<string, unknown> = { latestVersion: "0.8.232" };
32+
vi.doMock("../telemetry/config.js", () => ({
33+
readConfig: () => ({ ...config }),
34+
readConfigFresh: () => ({ ...config }),
35+
writeConfig: (next: Record<string, unknown>) => {
36+
for (const key of Object.keys(config)) delete config[key];
37+
Object.assign(config, next);
38+
return true;
39+
},
40+
}));
41+
vi.doMock("../version.js", async (importOriginal) => ({
42+
...(await importOriginal<typeof import("../version.js")>()),
43+
VERSION: "0.8.231",
44+
}));
45+
vi.doMock("./env.js", () => ({ isDevMode: () => false }));
46+
vi.doMock("./attendedTerminal.js", async (importOriginal) => ({
47+
...(await importOriginal<typeof import("./attendedTerminal.js")>()),
48+
isUnattended: () => false,
49+
}));
50+
const spawn = vi.fn((_bin: string, _args: readonly string[]) => ({ pid: 42, unref: () => {} }));
51+
vi.doMock("node:child_process", async (importOriginal) => ({
52+
...(await importOriginal<typeof import("node:child_process")>()),
53+
spawn,
54+
}));
55+
vi.doMock("node:fs", async (importOriginal) => ({
56+
...(await importOriginal<typeof import("node:fs")>()),
57+
mkdirSync: () => {},
58+
openSync: () => 99,
59+
appendFileSync: () => {},
60+
}));
61+
62+
process.argv = [process.argv[0] ?? "node", entry];
63+
for (const name of ["CI", "HYPERFRAMES_NO_AUTO_INSTALL", "HYPERFRAMES_NO_UPDATE_CHECK"]) {
64+
delete process.env[name];
65+
}
66+
Object.defineProperty(process.stderr, "isTTY", { value: true, configurable: true });
67+
const stderr: string[] = [];
68+
process.stderr.write = ((chunk: unknown) => {
69+
stderr.push(String(chunk));
70+
return true;
71+
}) as typeof process.stderr.write;
72+
73+
const { scheduleBackgroundInstall } = await import("./autoUpdate.js");
74+
const { printUpdateNotice } = await import("./updateCheck.js");
75+
const scheduled = scheduleBackgroundInstall("0.8.232", "0.8.231");
76+
printUpdateNotice();
77+
return { scheduled, spawn, config, notice: stderr.join("") };
78+
}
79+
80+
/** The bin and argv the spawned installer script would execFile. */
81+
function installerArgv(spawn: { mock: { calls: [string, readonly string[]][] } }) {
82+
const script = spawn.mock.calls[0]?.[1][1] ?? "";
83+
const bin = /const BIN = (.*);/.exec(script)?.[1];
84+
const args = /const ARGS = (.*);/.exec(script)?.[1];
85+
return { bin: bin && JSON.parse(bin), args: args && JSON.parse(args) };
86+
}
87+
88+
it("never launches an upgrade on Homebrew, whose upgrade cannot be held to the patch", async () => {
89+
const { scheduled, spawn, config, notice } = await patchAvailableAt(BREW_ENTRY);
90+
91+
expect(scheduled).toBe(false);
92+
expect(spawn).not.toHaveBeenCalled();
93+
expect(config["pendingUpdate"]).toBeUndefined();
94+
expect(notice).toContain("Update available: 0.8.231 → 0.8.232");
95+
expect(notice).toContain("Run: brew upgrade hyperframes");
96+
});
97+
98+
it("still installs an allowed patch on npm at that exact version", async () => {
99+
const { scheduled, spawn, notice } = await patchAvailableAt(NPM_ENTRY);
100+
101+
expect(scheduled).toBe(true);
102+
expect(spawn).toHaveBeenCalledOnce();
103+
expect(installerArgv(spawn)).toEqual({
104+
bin: "npm",
105+
args: ["install", "-g", "[email protected]"],
106+
});
107+
expect(notice).toBe("");
108+
});

‎packages/cli/src/utils/autoUpdate.test.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,42 @@ describe("scheduleBackgroundInstall", () => {
150150
expect(spawnSpy).not.toHaveBeenCalled();
151151
});
152152

153+
// Before 1.0 a minor bump may break (semver), so 0.8.x -> 0.9.x is a "major"
154+
// jump: notify only. Comparing the first number alone auto-installed it.
155+
it("does NOT schedule across a 0.x minor jump", async () => {
156+
const { spawnSpy, config } = setupMocks({
157+
installer: { kind: "npm", command: "npm install -g [email protected]" },
158+
});
159+
const { scheduleBackgroundInstall } = await import("./autoUpdate.js");
160+
161+
expect(scheduleBackgroundInstall("0.9.3", "0.8.231")).toBe(false);
162+
expect(spawnSpy).not.toHaveBeenCalled();
163+
expect(config.pendingUpdate).toBeUndefined();
164+
});
165+
166+
it.each([
167+
["0.8.232", "0.8.231"],
168+
["1.3.0", "1.2.5"],
169+
])("still schedules %s over %s (same breaking line)", async (latest, current) => {
170+
const { spawnSpy } = setupMocks({
171+
installer: { kind: "npm", command: `npm install -g hyperframes@${latest}` },
172+
});
173+
const { scheduleBackgroundInstall } = await import("./autoUpdate.js");
174+
175+
expect(scheduleBackgroundInstall(latest, current)).toBe(true);
176+
expect(spawnSpy).toHaveBeenCalledOnce();
177+
});
178+
179+
it("does NOT schedule across a major jump from 1.0 on", async () => {
180+
const { spawnSpy } = setupMocks({
181+
installer: { kind: "npm", command: "npm install -g [email protected]" },
182+
});
183+
const { scheduleBackgroundInstall } = await import("./autoUpdate.js");
184+
185+
expect(scheduleBackgroundInstall("2.0.0", "1.9.4")).toBe(false);
186+
expect(spawnSpy).not.toHaveBeenCalled();
187+
});
188+
153189
it("skips in dev mode", async () => {
154190
const { spawnSpy } = setupMocks({
155191
installer: { kind: "npm", command: "npm install -g [email protected]" },

0 commit comments

Comments
 (0)