From 49ff19ef8cd054a35a6782d5b0c1d22f3976db88 Mon Sep 17 00:00:00 2001 From: Miao Yang Date: Fri, 26 Jun 2026 17:09:57 +0800 Subject: [PATCH 01/14] 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) --- .github/workflows/ci.yml | 18 + lefthook.yml | 7 + packages/cli/package.json | 3 +- packages/cli/scripts/gen-skills-manifest.ts | 80 ++++ packages/cli/src/cli.ts | 13 +- packages/cli/src/commands/skills.ts | 153 +++++++- packages/cli/src/telemetry/config.ts | 9 + packages/cli/src/utils/skillsManifest.test.ts | 107 ++++++ packages/cli/src/utils/skillsManifest.ts | 357 ++++++++++++++++++ packages/cli/src/utils/skillsUpdateCheck.ts | 79 ++++ packages/cli/src/utils/updateCheck.ts | 18 +- skills-manifest.json | 81 ++++ skills/hyperframes/SKILL.md | 9 + 13 files changed, 913 insertions(+), 21 deletions(-) create mode 100644 packages/cli/scripts/gen-skills-manifest.ts create mode 100644 packages/cli/src/utils/skillsManifest.test.ts create mode 100644 packages/cli/src/utils/skillsManifest.ts create mode 100644 packages/cli/src/utils/skillsUpdateCheck.ts create mode 100644 skills-manifest.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69221d5fd1..c39a70a910 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/lefthook.yml b/lefthook.yml index bf5a08090e..44e676c7ee 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -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 diff --git a/packages/cli/package.json b/packages/cli/package.json index ccc01b0aa5..79cbafc4fd 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", diff --git a/packages/cli/scripts/gen-skills-manifest.ts b/packages/cli/scripts/gen-skills-manifest.ts new file mode 100644 index 0000000000..bdee2e9c54 --- /dev/null +++ b/packages/cli/scripts/gen-skills-manifest.ts @@ -0,0 +1,80 @@ +// 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 { existsSync, 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" }); + +const committed: SkillsManifest | null = existsSync(outPath) + ? (JSON.parse(readFileSync(outPath, "utf8")) as SkillsManifest) + : 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); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 6de445aa02..2316977ec4 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -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. diff --git a/packages/cli/src/commands/skills.ts b/packages/cli/src/commands/skills.ts index 2894058c90..77a046521c 100644 --- a/packages/cli/src/commands/skills.ts +++ b/packages/cli/src/commands/skills.ts @@ -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 installed skills to the latest", "hyperframes skills update"], +]; function hasNpx(): boolean { const npx = buildNpxCommand(["--version"]); @@ -14,34 +24,37 @@ function hasNpx(): boolean { } } -function runSkillsAdd( - repo: string, - opts: { cwd?: string; extraArgs?: string[] } = {}, -): Promise { - const npx = buildNpxCommand(["skills", "add", repo, ...(opts.extraArgs ?? ["--all"])]); +function spawnNpx(args: string[], opts: { cwd?: string } = {}): Promise { + 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); }); } +function runSkillsAdd( + repo: string, + opts: { cwd?: string; extraArgs?: string[] } = {}, +): Promise { + return spawnNpx(["skills", "add", repo, ...(opts.extraArgs ?? ["--all"])], opts); +} + const SOURCES = [{ name: "HyperFrames", repo: "heygen-com/hyperframes" }]; export async function installAllSkills( @@ -64,13 +77,123 @@ export async function installAllSkills( } } +// ── 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: "update", + description: "Update installed HyperFrames skills to the latest version", + }, + args: { + yes: { type: "boolean", description: "Skip prompts (auto-detect scope)", default: false }, + }, + async run({ args }) { + if (!hasNpx()) { + clack.log.error(c.error("npx not found. Install Node.js and retry.")); + return; + } + // The upstream `skills` CLI owns the update mechanism (reads + // skills-lock.json and re-fetches changed skills). We wrap it so agents and + // users reference one tool, and so the passive nudge can point here. + console.log(); + console.log(c.bold("Updating HyperFrames skills...")); + console.log(); + const updateArgs = ["skills", "update"]; + if (args.yes) updateArgs.push("--yes"); + try { + await spawnNpx(updateArgs); + } catch (err) { + clack.log.error(c.error(`Update failed: ${(err as Error).message}`)); + process.exitCode = 1; + } + }, +}); + export default defineCommand({ meta: { name: "skills", - description: "Install HyperFrames skills for AI coding tools", + description: "Install, check, and update HyperFrames skills for AI coding tools", + }, + subCommands: { + check: checkCommand, + update: updateCommand, }, args: {}, - async run() { - await installAllSkills(); + 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(); }, }); diff --git a/packages/cli/src/telemetry/config.ts b/packages/cli/src/telemetry/config.ts index b0a90cb9e7..acb214a0dd 100644 --- a/packages/cli/src/telemetry/config.ts +++ b/packages/cli/src/telemetry/config.ts @@ -55,6 +55,12 @@ 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; } const DEFAULT_CONFIG: HyperframesConfig = { @@ -96,6 +102,9 @@ export function readConfig(): HyperframesConfig { latestVersion: parsed.latestVersion, pendingUpdate: parsed.pendingUpdate, completedUpdate: parsed.completedUpdate, + lastSkillsCheck: parsed.lastSkillsCheck, + skillsUpdateAvailable: parsed.skillsUpdateAvailable, + skillsOutdatedCount: parsed.skillsOutdatedCount, }; cachedConfig = config; diff --git a/packages/cli/src/utils/skillsManifest.test.ts b/packages/cli/src/utils/skillsManifest.test.ts new file mode 100644 index 0000000000..4f4f32dff8 --- /dev/null +++ b/packages/cli/src/utils/skillsManifest.test.ts @@ -0,0 +1,107 @@ +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, + 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 { + 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 / local-only", () => { + const installed: Record = { + keep: { hash: "h1", files: 1 }, // current + changed: { hash: "DIFFERENT", files: 1 }, // outdated + // gone: not installed → missing + extra: { hash: "hx", files: 1 }, // local-only + }; + 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", + extra: "local-only", + }); + expect(diff.summary).toEqual({ current: 1, outdated: 1, missing: 1, localOnly: 1 }); + }); + + it("flags updateAvailable only when an installed skill is outdated", () => { + // Missing-only must NOT trigger updateAvailable (a partial install is a choice). + const missingOnly = diffSkills({ keep: { hash: "h1", files: 1 } }, latest); + expect(missingOnly.updateAvailable).toBe(false); + + const hasOutdated = diffSkills({ changed: { hash: "X", files: 1 } }, latest); + expect(hasOutdated.updateAvailable).toBe(true); + }); +}); diff --git a/packages/cli/src/utils/skillsManifest.ts b/packages/cli/src/utils/skillsManifest.ts new file mode 100644 index 0000000000..a77df39626 --- /dev/null +++ b/packages/cli/src/utils/skillsManifest.ts @@ -0,0 +1,357 @@ +// 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 { 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; +} + +export type SkillStatus = "current" | "outdated" | "missing" | "local-only"; + +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; localOnly: 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).sort()) { + if (name === ".DS_Store") continue; + const p = join(d, name); + if (statSync(p).isDirectory()) walk(p); + else out.push(p); + } + }; + walk(dir); + 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 + * `/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 = {}; + 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"; +} + +/** + * Candidate locations where `npx skills add` may have installed skills, in + * priority order (project before global, Claude Code before others). + */ +function defaultSkillRoots(cwd = process.cwd(), home = homedir()): SkillRoot[] { + const conventions: Array<[string, string]> = [ + [".claude/skills", "claude-code"], + [".agents/skills", "codex"], + [".codex/skills", "codex"], + [".cursor/skills", "cursor"], + ]; + const roots: SkillRoot[] = []; + for (const [rel, agent] of conventions) + roots.push({ dir: join(cwd, rel), agent, scope: "project" }); + for (const [rel, agent] of conventions) + roots.push({ dir: join(home, rel), agent, scope: "global" }); + return roots; +} + +/** + * Find the first skill root that actually contains HyperFrames skills. A + * `--dir` override (if given) is treated as a `.../skills` directory directly. + */ +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; + } + for (const root of defaultSkillRoots(opts.cwd, opts.home)) { + if (!existsSync(root.dir)) continue; + const hasAny = skillNames.some((n) => existsSync(join(root.dir, n, "SKILL.md"))); + if (hasAny) return root; + } + return null; +} + +function agentFromDir(dir: string): string { + if (dir.includes(`.claude${sep}`) || dir.endsWith(".claude")) return "claude-code"; + if (dir.includes(`.codex${sep}`) || dir.includes(`.agents${sep}`)) return "codex"; + if (dir.includes(`.cursor${sep}`)) return "cursor"; + return "unknown"; +} + +/** Hash every manifest skill that is installed under `root`. */ +function hashInstalled(root: SkillRoot, skillNames: string[]): Record { + const out: Record = {}; + 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, + latest: SkillsManifest, +): Omit { + const names = new Set([...Object.keys(latest.skills), ...Object.keys(installed)]); + const skills: SkillDiff[] = []; + const summary = { current: 0, outdated: 0, missing: 0, localOnly: 0 }; + + for (const name of [...names].sort()) { + const latestEntry = latest.skills[name]; + const installedEntry = installed[name]; + let status: SkillStatus; + if (latestEntry && !installedEntry) status = "missing"; + else if (!latestEntry && installedEntry) status = "local-only"; + else if (installedEntry!.hash === latestEntry!.hash) status = "current"; + else status = "outdated"; + + if (status === "current") summary.current++; + else if (status === "outdated") summary.outdated++; + else if (status === "missing") summary.missing++; + else summary.localOnly++; + + skills.push({ + name, + status, + installedHash: installedEntry?.hash, + latestHash: latestEntry?.hash, + }); + } + + return { + // "Would `skills update` change something you already have?" Missing skills + // are reported separately — a partial install is a choice, not staleness. + updateAvailable: summary.outdated > 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; + for (let i = 0; i < 8; i++) { + const p = join(dir, MANIFEST_FILE); + if (existsSync(p)) return p; + const parent = join(dir, ".."); + if (parent === dir) break; + dir = parent; + } + return null; +} + +async function fetchManifest(url: string): Promise { + 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 (await res.json()) as SkillsManifest; + } finally { + clearTimeout(timeout); + } +} + +/** + * Resolve main's live HEAD sha via `git ls-remote`. GitHub's branch-raw CDN + * (raw.githubusercontent.com///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 { + 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 { + 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 { + if (source && (source.startsWith(".") || source.startsWith("/"))) { + 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 } = {}, +): Promise { + const latest = await resolveLatestManifest(opts.source, opts.cwd); + const skillNames = Object.keys(latest.skills); + const root = locateInstall(skillNames, { dir: opts.dir, cwd: opts.cwd }); + const installed = root ? hashInstalled(root, skillNames) : {}; + const diff = diffSkills(installed, latest); + return { location: root?.dir ?? null, agent: root?.agent ?? null, ...diff }; +} diff --git a/packages/cli/src/utils/skillsUpdateCheck.ts b/packages/cli/src/utils/skillsUpdateCheck.ts new file mode 100644 index 0000000000..6de24062cf --- /dev/null +++ b/packages/cli/src/utils/skillsUpdateCheck.ts @@ -0,0 +1,79 @@ +// 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; +} + +/** Synchronous read from cache — never fetches. */ +function getSkillsUpdateMeta(): SkillsUpdateMeta { + const config = readConfig(); + return { + updateAvailable: config.skillsUpdateAvailable ?? false, + outdated: config.skillsOutdatedCount ?? 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 { + 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; + writeConfig(config); + } + return { updateAvailable: result.updateAvailable, outdated: result.summary.outdated }; +} + +/** + * 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 { + 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. */ +function skillsNoticeText(meta: SkillsUpdateMeta): string | null { + if (meta.outdated < 1) return null; + const noun = meta.outdated === 1 ? "skill" : "skills"; + return `\n ${meta.outdated} HyperFrames ${noun} out of date.\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); +} diff --git a/packages/cli/src/utils/updateCheck.ts b/packages/cli/src/utils/updateCheck.ts index 5bff8b7fd6..a498d810d7 100644 --- a/packages/cli/src/utils/updateCheck.ts +++ b/packages/cli/src/utils/updateCheck.ts @@ -102,15 +102,25 @@ export function withMeta(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; diff --git a/skills-manifest.json b/skills-manifest.json new file mode 100644 index 0000000000..7b8efac319 --- /dev/null +++ b/skills-manifest.json @@ -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": "899a9534fa8e2889", + "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 + } + } +} diff --git a/skills/hyperframes/SKILL.md b/skills/hyperframes/SKILL.md index 9a23ba6c0d..f4d668776d 100644 --- a/skills/hyperframes/SKILL.md +++ b/skills/hyperframes/SKILL.md @@ -83,6 +83,15 @@ 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. If a task is behaving unexpectedly, or before a long build, it's worth confirming the installed skills are current: + +- **Check:** `npx hyperframes skills check` (add `--json` for a machine-readable verdict; exits non-zero when something is outdated). +- **Update:** `npx hyperframes skills update`. + +The CLI also surfaces a one-line reminder when a `render` / `lint` / `validate` run detects stale skills. + ## Workflow details ### `/product-launch-video` From c6105f96fe4502e3bbeeee416a33c840138127f7 Mon Sep 17 00:00:00 2001 From: Miao Yang Date: Fri, 26 Jun 2026 19:49:42 +0800 Subject: [PATCH 02/14] fix(cli): add execFile to child_process mock in skills test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/cli/src/commands/skills.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/cli/src/commands/skills.test.ts b/packages/cli/src/commands/skills.test.ts index 2c51c38f05..1f8bc18274 100644 --- a/packages/cli/src/commands/skills.test.ts +++ b/packages/cli/src/commands/skills.test.ts @@ -23,6 +23,10 @@ const state: { execCalls: ExecCall[]; spawnCalls: SpawnCall[] } = { }; 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) => { state.execCalls.push({ command, args }); return Buffer.from("11.0.0"); From 6961b21c47f86d9d00c6ced0f6f340c9dc2928e0 Mon Sep 17 00:00:00 2001 From: Miao Yang Date: Fri, 26 Jun 2026 20:16:20 +0800 Subject: [PATCH 03/14] feat(cli): init installs all skills; skills update pulls the full set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/cli/src/commands/init.ts | 33 ++++++++++++++--------------- packages/cli/src/commands/skills.ts | 32 +++++++--------------------- skills-manifest.json | 2 +- skills/hyperframes/SKILL.md | 6 ++++-- 4 files changed, 29 insertions(+), 44 deletions(-) diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 02b0dc3bd5..0e8a408b3f 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -803,10 +803,12 @@ 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"]; + // --all pulls every skill (including ones not yet installed); --yes keeps + // it non-interactive. When Claude Code is driving (CLAUDECODE env var), + // target its native dir so skills land in .claude/skills/. + const args = process.env["CLAUDECODE"] + ? ["--all", "--agent", "claude-code", "--yes"] + : ["--all", "--yes"]; await installAllSkills({ cwd: destDir, extraArgs: args }); } @@ -1023,20 +1025,17 @@ 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 + // Always install (and refresh) all AI coding skills — init is the one place + // skills are pulled in full, so this grabs every skill including ones not yet + // installed and brings existing ones to the latest. 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 }); - } + const { installAllSkills } = await import("./skills.js"); + // --all pulls every skill; --yes keeps it non-interactive. When Claude Code + // is driving, target .claude/skills/ (mirrors the non-interactive path). + const args = process.env["CLAUDECODE"] + ? ["--all", "--agent", "claude-code", "--yes"] + : ["--all", "--yes"]; + await installAllSkills({ cwd: destDir, extraArgs: args }); } // Auto-launch studio preview diff --git a/packages/cli/src/commands/skills.ts b/packages/cli/src/commands/skills.ts index 77a046521c..83c0923b07 100644 --- a/packages/cli/src/commands/skills.ts +++ b/packages/cli/src/commands/skills.ts @@ -11,7 +11,7 @@ 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 installed skills to the latest", "hyperframes skills update"], + ["Update all skills to the latest (installs any missing)", "hyperframes skills update"], ]; function hasNpx(): boolean { @@ -153,30 +153,14 @@ const checkCommand = defineCommand({ const updateCommand = defineCommand({ meta: { name: "update", - description: "Update installed HyperFrames skills to the latest version", + description: "Update all HyperFrames skills to the latest — installs any not yet present", }, - args: { - yes: { type: "boolean", description: "Skip prompts (auto-detect scope)", default: false }, - }, - async run({ args }) { - if (!hasNpx()) { - clack.log.error(c.error("npx not found. Install Node.js and retry.")); - return; - } - // The upstream `skills` CLI owns the update mechanism (reads - // skills-lock.json and re-fetches changed skills). We wrap it so agents and - // users reference one tool, and so the passive nudge can point here. - console.log(); - console.log(c.bold("Updating HyperFrames skills...")); - console.log(); - const updateArgs = ["skills", "update"]; - if (args.yes) updateArgs.push("--yes"); - try { - await spawnNpx(updateArgs); - } catch (err) { - clack.log.error(c.error(`Update failed: ${(err as Error).message}`)); - process.exitCode = 1; - } + args: {}, + async run() { + // `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. + await installAllSkills({ extraArgs: ["--all", "--yes"] }); }, }); diff --git a/skills-manifest.json b/skills-manifest.json index 7b8efac319..9c5d49f389 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -14,7 +14,7 @@ "files": 1 }, "hyperframes": { - "hash": "899a9534fa8e2889", + "hash": "f45ffc9c3fc1b1b9", "files": 1 }, "hyperframes-animation": { diff --git a/skills/hyperframes/SKILL.md b/skills/hyperframes/SKILL.md index f4d668776d..a2ee50ced5 100644 --- a/skills/hyperframes/SKILL.md +++ b/skills/hyperframes/SKILL.md @@ -85,10 +85,12 @@ After they run it, re-read the workflow's skill and continue. ## Keeping skills current -HyperFrames skills are versioned. If a task is behaving unexpectedly, or before a long build, it's worth confirming the installed skills are current: +HyperFrames skills are versioned. `npx hyperframes init` installs (and refreshes) **all** skills as part of project setup — including any not yet present — so a freshly init'd project always has the complete, latest set. Opt out 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 something is outdated). -- **Update:** `npx hyperframes skills update`. +- **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. From 05b5d557636195bda1c94646e3280bd48ecef944 Mon Sep 17 00:00:00 2001 From: Miao Yang Date: Fri, 26 Jun 2026 20:26:05 +0800 Subject: [PATCH 04/14] feat(cli): skills check treats missing skills as needing an update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/cli/src/telemetry/config.ts | 3 ++ packages/cli/src/utils/skillsManifest.test.ts | 29 +++++++++++++++++-- packages/cli/src/utils/skillsManifest.ts | 7 +++-- packages/cli/src/utils/skillsUpdateCheck.ts | 18 ++++++++---- skills-manifest.json | 2 +- skills/hyperframes/SKILL.md | 2 +- 6 files changed, 48 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/telemetry/config.ts b/packages/cli/src/telemetry/config.ts index acb214a0dd..0a6d3461c0 100644 --- a/packages/cli/src/telemetry/config.ts +++ b/packages/cli/src/telemetry/config.ts @@ -61,6 +61,8 @@ export interface HyperframesConfig { 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 = { @@ -105,6 +107,7 @@ export function readConfig(): HyperframesConfig { lastSkillsCheck: parsed.lastSkillsCheck, skillsUpdateAvailable: parsed.skillsUpdateAvailable, skillsOutdatedCount: parsed.skillsOutdatedCount, + skillsMissingCount: parsed.skillsMissingCount, }; cachedConfig = config; diff --git a/packages/cli/src/utils/skillsManifest.test.ts b/packages/cli/src/utils/skillsManifest.test.ts index 4f4f32dff8..0b4401a19b 100644 --- a/packages/cli/src/utils/skillsManifest.test.ts +++ b/packages/cli/src/utils/skillsManifest.test.ts @@ -96,12 +96,35 @@ describe("diffSkills", () => { expect(diff.summary).toEqual({ current: 1, outdated: 1, missing: 1, localOnly: 1 }); }); - it("flags updateAvailable only when an installed skill is outdated", () => { - // Missing-only must NOT trigger updateAvailable (a partial install is a choice). + 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(false); + 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 local-only skill (installed, not in the manifest) doesn't trigger one. + const localOnly = diffSkills( + { + keep: { hash: "h1", files: 1 }, + changed: { hash: "h2", files: 1 }, + gone: { hash: "h3", files: 1 }, + extra: { hash: "hx", files: 1 }, + }, + latest, + ); + expect(localOnly.updateAvailable).toBe(false); }); }); diff --git a/packages/cli/src/utils/skillsManifest.ts b/packages/cli/src/utils/skillsManifest.ts index a77df39626..15fbe017f6 100644 --- a/packages/cli/src/utils/skillsManifest.ts +++ b/packages/cli/src/utils/skillsManifest.ts @@ -234,9 +234,10 @@ export function diffSkills( } return { - // "Would `skills update` change something you already have?" Missing skills - // are reported separately — a partial install is a choice, not staleness. - updateAvailable: summary.outdated > 0, + // 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. + // (local-only skills — installed but not in the manifest — don't count.) + updateAvailable: summary.outdated > 0 || summary.missing > 0, summary, skills, }; diff --git a/packages/cli/src/utils/skillsUpdateCheck.ts b/packages/cli/src/utils/skillsUpdateCheck.ts index 6de24062cf..e3f9a2e36f 100644 --- a/packages/cli/src/utils/skillsUpdateCheck.ts +++ b/packages/cli/src/utils/skillsUpdateCheck.ts @@ -15,6 +15,7 @@ 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. */ @@ -23,6 +24,7 @@ function getSkillsUpdateMeta(): SkillsUpdateMeta { return { updateAvailable: config.skillsUpdateAvailable ?? false, outdated: config.skillsOutdatedCount ?? 0, + missing: config.skillsMissingCount ?? 0, }; } @@ -40,9 +42,14 @@ async function refreshSkillsCache(): Promise { 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 }; + return { + updateAvailable: result.updateAvailable, + outdated: result.summary.outdated, + missing: result.summary.missing, + }; } /** @@ -61,11 +68,12 @@ export async function checkSkillsForUpdate(force?: boolean): Promise Date: Fri, 26 Jun 2026 20:31:26 +0800 Subject: [PATCH 05/14] fix(cli): install/update skills straight from the GitHub repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- packages/cli/src/commands/init.ts | 2 +- packages/cli/src/commands/skills.test.ts | 25 +++++++++++++++++++++--- packages/cli/src/commands/skills.ts | 12 ++++++++---- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 0e8a408b3f..3fb11789a0 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -817,7 +817,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.`, diff --git a/packages/cli/src/commands/skills.test.ts b/packages/cli/src/commands/skills.test.ts index 1f8bc18274..2417f67d21 100644 --- a/packages/cli/src/commands/skills.test.ts +++ b/packages/cli/src/commands/skills.test.ts @@ -81,13 +81,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", diff --git a/packages/cli/src/commands/skills.ts b/packages/cli/src/commands/skills.ts index 83c0923b07..f9f94138b8 100644 --- a/packages/cli/src/commands/skills.ts +++ b/packages/cli/src/commands/skills.ts @@ -49,13 +49,17 @@ function spawnNpx(args: string[], opts: { cwd?: string } = {}): Promise { } function runSkillsAdd( - repo: string, + source: string, opts: { cwd?: string; extraArgs?: string[] } = {}, ): Promise { - return spawnNpx(["skills", "add", repo, ...(opts.extraArgs ?? ["--all"])], opts); + return spawnNpx(["skills", "add", source, ...(opts.extraArgs ?? ["--all"])], opts); } -const SOURCES = [{ name: "HyperFrames", repo: "heygen-com/hyperframes" }]; +// 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[] } = {}, @@ -70,7 +74,7 @@ export async function installAllSkills( console.log(c.bold(`Installing ${source.name} skills...`)); console.log(); try { - await runSkillsAdd(source.repo, opts); + await runSkillsAdd(source.url, opts); } catch { console.log(c.dim(`${source.name} skills skipped`)); } From 8e3435f06413d728024a16a6f1c39eba0c9cc30f Mon Sep 17 00:00:00 2001 From: Miao Yang Date: Fri, 26 Jun 2026 20:51:24 +0800 Subject: [PATCH 06/14] feat(cli): init checks skills against GitHub, installs only when stale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- packages/cli/src/commands/init.ts | 57 +++++++++++++++++++++---------- skills-manifest.json | 2 +- skills/hyperframes/SKILL.md | 2 +- 3 files changed, 41 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 3fb11789a0..90814d4612 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -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 { + 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,14 +837,7 @@ export default defineCommand({ } if (!skipSkills) { - const { installAllSkills } = await import("./skills.js"); - // --all pulls every skill (including ones not yet installed); --yes keeps - // it non-interactive. When Claude Code is driving (CLAUDECODE env var), - // target its native dir so skills land in .claude/skills/. - const args = process.env["CLAUDECODE"] - ? ["--all", "--agent", "claude-code", "--yes"] - : ["--all", "--yes"]; - await installAllSkills({ cwd: destDir, extraArgs: args }); + await ensureSkillsCurrent(destDir); } console.log(); @@ -1025,17 +1053,10 @@ export default defineCommand({ const files = readdirSync(destDir); clack.note(files.map((f) => c.accent(f)).join("\n"), c.success(`Created ${name}/`)); - // Always install (and refresh) all AI coding skills — init is the one place - // skills are pulled in full, so this grabs every skill including ones not yet - // installed and brings existing ones to the latest. Opt out with --skip-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 { installAllSkills } = await import("./skills.js"); - // --all pulls every skill; --yes keeps it non-interactive. When Claude Code - // is driving, target .claude/skills/ (mirrors the non-interactive path). - const args = process.env["CLAUDECODE"] - ? ["--all", "--agent", "claude-code", "--yes"] - : ["--all", "--yes"]; - await installAllSkills({ cwd: destDir, extraArgs: args }); + await ensureSkillsCurrent(destDir); } // Auto-launch studio preview diff --git a/skills-manifest.json b/skills-manifest.json index 736f2c5ae6..cba5f9e1c4 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -14,7 +14,7 @@ "files": 1 }, "hyperframes": { - "hash": "9d821dd01de5d33a", + "hash": "9edf5c75cb79ae65", "files": 1 }, "hyperframes-animation": { diff --git a/skills/hyperframes/SKILL.md b/skills/hyperframes/SKILL.md index 71f36e443e..1540b315b2 100644 --- a/skills/hyperframes/SKILL.md +++ b/skills/hyperframes/SKILL.md @@ -85,7 +85,7 @@ After they run it, re-read the workflow's skill and continue. ## Keeping skills current -HyperFrames skills are versioned. `npx hyperframes init` installs (and refreshes) **all** skills as part of project setup — including any not yet present — so a freshly init'd project always has the complete, latest set. Opt out with `init --skip-skills`. +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). Opt out with `init --skip-skills`. If a task is behaving unexpectedly, or before a long build, confirm the installed skills are current: From 1cf81c761af40d810908b26fd0f17a51c860d77f Mon Sep 17 00:00:00 2001 From: Miao Yang Date: Fri, 26 Jun 2026 20:58:29 +0800 Subject: [PATCH 07/14] refactor(cli): address skills manifest review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/cli/src/utils/skillsManifest.test.ts | 13 +++-- packages/cli/src/utils/skillsManifest.ts | 50 ++++++++++++------- 2 files changed, 39 insertions(+), 24 deletions(-) diff --git a/packages/cli/src/utils/skillsManifest.test.ts b/packages/cli/src/utils/skillsManifest.test.ts index 0b4401a19b..b0c63246eb 100644 --- a/packages/cli/src/utils/skillsManifest.test.ts +++ b/packages/cli/src/utils/skillsManifest.test.ts @@ -78,12 +78,12 @@ describe("diffSkills", () => { }, }; - it("classifies current / outdated / missing / local-only", () => { + it("classifies current / outdated / missing and ignores skills not in the manifest", () => { const installed: Record = { keep: { hash: "h1", files: 1 }, // current changed: { hash: "DIFFERENT", files: 1 }, // outdated // gone: not installed → missing - extra: { hash: "hx", files: 1 }, // local-only + 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])); @@ -91,9 +91,8 @@ describe("diffSkills", () => { keep: "current", changed: "outdated", gone: "missing", - extra: "local-only", }); - expect(diff.summary).toEqual({ current: 1, outdated: 1, missing: 1, localOnly: 1 }); + expect(diff.summary).toEqual({ current: 1, outdated: 1, missing: 1 }); }); it("flags updateAvailable when a skill is outdated OR missing", () => { @@ -115,8 +114,8 @@ describe("diffSkills", () => { ); expect(allCurrent.updateAvailable).toBe(false); - // A local-only skill (installed, not in the manifest) doesn't trigger one. - const localOnly = diffSkills( + // 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 }, @@ -125,6 +124,6 @@ describe("diffSkills", () => { }, latest, ); - expect(localOnly.updateAvailable).toBe(false); + expect(withExtra.updateAvailable).toBe(false); }); }); diff --git a/packages/cli/src/utils/skillsManifest.ts b/packages/cli/src/utils/skillsManifest.ts index 15fbe017f6..8328bb84e9 100644 --- a/packages/cli/src/utils/skillsManifest.ts +++ b/packages/cli/src/utils/skillsManifest.ts @@ -19,7 +19,7 @@ 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 { join, relative, sep } from "node:path"; +import { isAbsolute, join, relative, sep } from "node:path"; import { promisify } from "node:util"; const execFileAsync = promisify(execFile); @@ -58,7 +58,7 @@ export interface SkillsManifest { skills: Record; } -export type SkillStatus = "current" | "outdated" | "missing" | "local-only"; +export type SkillStatus = "current" | "outdated" | "missing"; export interface SkillDiff { name: string; @@ -73,7 +73,7 @@ export interface SkillsCheckResult { /** Agent convention inferred from the location (claude-code, codex, …). */ agent: string | null; updateAvailable: boolean; - summary: { current: number; outdated: number; missing: number; localOnly: number }; + summary: { current: number; outdated: number; missing: number }; skills: SkillDiff[]; } @@ -87,7 +87,7 @@ const FETCH_TIMEOUT_MS = 4000; function listFilesSorted(dir: string): string[] { const out: string[] = []; const walk = (d: string): void => { - for (const name of readdirSync(d).sort()) { + for (const name of readdirSync(d)) { if (name === ".DS_Store") continue; const p = join(d, name); if (statSync(p).isDirectory()) walk(p); @@ -95,6 +95,8 @@ function listFilesSorted(dir: string): string[] { } }; 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(); } @@ -207,36 +209,35 @@ export function diffSkills( installed: Record, latest: SkillsManifest, ): Omit { - const names = new Set([...Object.keys(latest.skills), ...Object.keys(installed)]); + // 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, localOnly: 0 }; + const summary = { current: 0, outdated: 0, missing: 0 }; - for (const name of [...names].sort()) { - const latestEntry = latest.skills[name]; + for (const name of Object.keys(latest.skills).sort()) { + const latestEntry = latest.skills[name]!; const installedEntry = installed[name]; let status: SkillStatus; - if (latestEntry && !installedEntry) status = "missing"; - else if (!latestEntry && installedEntry) status = "local-only"; - else if (installedEntry!.hash === latestEntry!.hash) status = "current"; + 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 if (status === "missing") summary.missing++; - else summary.localOnly++; + else summary.missing++; skills.push({ name, status, installedHash: installedEntry?.hash, - latestHash: latestEntry?.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. - // (local-only skills — installed but not in the manifest — don't count.) updateAvailable: summary.outdated > 0 || summary.missing > 0, summary, skills, @@ -258,13 +259,26 @@ function findRepoManifest(cwd = process.cwd()): string | null { 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 | 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 { 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 (await res.json()) as SkillsManifest; + return asSkillsManifest(await res.json(), url); } finally { clearTimeout(timeout); } @@ -332,7 +346,9 @@ async function resolveLatestManifest( source?: string, cwd = process.cwd(), ): Promise { - if (source && (source.startsWith(".") || source.startsWith("/"))) { + // 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) { From 497faf6454fb0d7e9307f0616705ed245d1bcd1a Mon Sep 17 00:00:00 2001 From: Miao Yang Date: Fri, 26 Jun 2026 21:34:00 +0800 Subject: [PATCH 08/14] fix(cli): strict skills update + auto-discover any agent host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `/skills//SKILL.md` (plus the XDG `.config//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) --- packages/cli/src/commands/skills.test.ts | 48 ++++++- packages/cli/src/commands/skills.ts | 24 +++- packages/cli/src/utils/skillsManifest.test.ts | 128 ++++++++++++++++++ packages/cli/src/utils/skillsManifest.ts | 91 +++++++++---- skills-manifest.json | 2 +- skills/hyperframes/SKILL.md | 2 +- 6 files changed, 258 insertions(+), 37 deletions(-) diff --git a/packages/cli/src/commands/skills.test.ts b/packages/cli/src/commands/skills.test.ts index 2417f67d21..48ee0b3e0d 100644 --- a/packages/cli/src/commands/skills.test.ts +++ b/packages/cli/src/commands/skills.test.ts @@ -17,9 +17,10 @@ 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", () => ({ @@ -35,7 +36,7 @@ vi.mock("node:child_process", () => ({ (command: string, args: ReadonlyArray, 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; }, ), @@ -58,6 +59,7 @@ describe("hyperframes skills", () => { beforeEach(() => { state.execCalls = []; state.spawnCalls = []; + state.spawnExitCode = 0; vi.resetModules(); }); @@ -122,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; + 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; + 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; + } + }); }); diff --git a/packages/cli/src/commands/skills.ts b/packages/cli/src/commands/skills.ts index f9f94138b8..6a352ed516 100644 --- a/packages/cli/src/commands/skills.ts +++ b/packages/cli/src/commands/skills.ts @@ -62,10 +62,15 @@ function runSkillsAdd( const SOURCES = [{ name: "HyperFrames", url: "https://github.com/heygen-com/hyperframes" }]; export async function installAllSkills( - opts: { cwd?: string; extraArgs?: string[] } = {}, + opts: { cwd?: string; extraArgs?: string[]; strict?: boolean } = {}, ): Promise { 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; } @@ -75,7 +80,8 @@ export async function installAllSkills( console.log(); try { await runSkillsAdd(source.url, opts); - } catch { + } catch (err) { + if (opts.strict) throw err instanceof Error ? err : new Error(String(err)); console.log(c.dim(`${source.name} skills skipped`)); } } @@ -164,7 +170,17 @@ const updateCommand = defineCommand({ // `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. - await installAllSkills({ extraArgs: ["--all", "--yes"] }); + // + // 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; + } }, }); diff --git a/packages/cli/src/utils/skillsManifest.test.ts b/packages/cli/src/utils/skillsManifest.test.ts index b0c63246eb..fc1386c272 100644 --- a/packages/cli/src/utils/skillsManifest.test.ts +++ b/packages/cli/src/utils/skillsManifest.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { hashSkillBundle, buildManifest, + checkSkills, diffSkills, type SkillsManifest, type SkillEntry, @@ -127,3 +128,130 @@ describe("diffSkills", () => { 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"); + }); +}); diff --git a/packages/cli/src/utils/skillsManifest.ts b/packages/cli/src/utils/skillsManifest.ts index 8328bb84e9..2e94c36a13 100644 --- a/packages/cli/src/utils/skillsManifest.ts +++ b/packages/cli/src/utils/skillsManifest.ts @@ -147,27 +147,64 @@ interface SkillRoot { } /** - * Candidate locations where `npx skills add` may have installed skills, in - * priority order (project before global, Claude Code before others). + * Map a host directory name to an agent label: ".claude" → "claude-code", + * ".factory" → "factory", "opencode" (under .config) → "opencode". */ -function defaultSkillRoots(cwd = process.cwd(), home = homedir()): SkillRoot[] { - const conventions: Array<[string, string]> = [ - [".claude/skills", "claude-code"], - [".agents/skills", "codex"], - [".codex/skills", "codex"], - [".cursor/skills", "cursor"], - ]; - const roots: SkillRoot[] = []; - for (const [rel, agent] of conventions) - roots.push({ dir: join(cwd, rel), agent, scope: "project" }); - for (const [rel, agent] of conventions) - roots.push({ dir: join(home, rel), agent, scope: "global" }); - return roots; +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 `/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 `//skills` (or the XDG + * `/.config//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[], @@ -178,21 +215,16 @@ function locateInstall( ? { dir: opts.dir, agent: agentFromDir(opts.dir), scope: "project" } : null; } - for (const root of defaultSkillRoots(opts.cwd, opts.home)) { - if (!existsSync(root.dir)) continue; - const hasAny = skillNames.some((n) => existsSync(join(root.dir, n, "SKILL.md"))); - if (hasAny) return root; + 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; } -function agentFromDir(dir: string): string { - if (dir.includes(`.claude${sep}`) || dir.endsWith(".claude")) return "claude-code"; - if (dir.includes(`.codex${sep}`) || dir.includes(`.agents${sep}`)) return "codex"; - if (dir.includes(`.cursor${sep}`)) return "cursor"; - return "unknown"; -} - /** Hash every manifest skill that is installed under `root`. */ function hashInstalled(root: SkillRoot, skillNames: string[]): Record { const out: Record = {}; @@ -249,7 +281,8 @@ export function diffSkills( /** Walk up from `cwd` to find a repo checkout that ships the manifest. */ function findRepoManifest(cwd = process.cwd()): string | null { let dir = cwd; - for (let i = 0; i < 8; i++) { + // 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, ".."); @@ -363,11 +396,11 @@ async function resolveLatestManifest( * manifest. Pure-ish (network only via `resolveLatestManifest`). */ export async function checkSkills( - opts: { dir?: string; source?: string; cwd?: string } = {}, + opts: { dir?: string; source?: string; cwd?: string; home?: string } = {}, ): Promise { const latest = await resolveLatestManifest(opts.source, opts.cwd); const skillNames = Object.keys(latest.skills); - const root = locateInstall(skillNames, { dir: opts.dir, cwd: opts.cwd }); + 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 }; diff --git a/skills-manifest.json b/skills-manifest.json index cba5f9e1c4..303a4a5cb5 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -14,7 +14,7 @@ "files": 1 }, "hyperframes": { - "hash": "9edf5c75cb79ae65", + "hash": "00ad363cda72268d", "files": 1 }, "hyperframes-animation": { diff --git a/skills/hyperframes/SKILL.md b/skills/hyperframes/SKILL.md index 1540b315b2..409efb5bde 100644 --- a/skills/hyperframes/SKILL.md +++ b/skills/hyperframes/SKILL.md @@ -85,7 +85,7 @@ 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). Opt out with `init --skip-skills`. +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: From 5ac3a6c9eb906b1e339fb372d302bd4b4c592460 Mon Sep 17 00:00:00 2001 From: Miao Yang Date: Fri, 26 Jun 2026 21:49:59 +0800 Subject: [PATCH 09/14] fix(cli): resolve CodeQL file-system race + de-flake Windows npx test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/cli/scripts/gen-skills-manifest.ts | 14 ++++++++++---- packages/cli/src/utils/npxCommand.test.ts | 6 ++++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/cli/scripts/gen-skills-manifest.ts b/packages/cli/scripts/gen-skills-manifest.ts index bdee2e9c54..2a508e3c7b 100644 --- a/packages/cli/scripts/gen-skills-manifest.ts +++ b/packages/cli/scripts/gen-skills-manifest.ts @@ -8,7 +8,7 @@ // is fully deterministic: same skill content ⇒ byte-identical manifest. `--check` // exits non-zero when the committed manifest doesn't match current skill content. -import { existsSync, readFileSync, writeFileSync } from "node:fs"; +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"; @@ -49,9 +49,15 @@ function reportDrift(fresh: SkillsManifest, committed: SkillsManifest | null): v const fresh = buildManifest(skillsRoot, { source: "heygen-com/hyperframes" }); -const committed: SkillsManifest | null = existsSync(outPath) - ? (JSON.parse(readFileSync(outPath, "utf8")) as SkillsManifest) - : null; +// 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; diff --git a/packages/cli/src/utils/npxCommand.test.ts b/packages/cli/src/utils/npxCommand.test.ts index 038e9b0d94..1709ef8abd 100644 --- a/packages/cli/src/utils/npxCommand.test.ts +++ b/packages/cli/src/utils/npxCommand.test.ts @@ -18,9 +18,11 @@ describe("buildNpxCommand", () => { 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+/); - }); + }, // making this smoke test flaky. Give it generous headroom (it still asserts // Real npx cold-start on Windows CI routinely exceeds vitest's 5s default, + // a real version string, so it isn't reduced to a tautology by mocking). + 60_000); }); From a7431533780c1412ca5726f96e9e4c51205b9154 Mon Sep 17 00:00:00 2001 From: Miao Yang Date: Fri, 26 Jun 2026 21:57:11 +0800 Subject: [PATCH 10/14] 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) --- packages/cli/src/utils/npxCommand.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/utils/npxCommand.test.ts b/packages/cli/src/utils/npxCommand.test.ts index 1709ef8abd..35b43d5879 100644 --- a/packages/cli/src/utils/npxCommand.test.ts +++ b/packages/cli/src/utils/npxCommand.test.ts @@ -14,6 +14,9 @@ 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, { @@ -22,7 +25,5 @@ describe("buildNpxCommand", () => { }).trim(); expect(version).toMatch(/^\d+\.\d+\.\d+/); - }, // making this smoke test flaky. Give it generous headroom (it still asserts // Real npx cold-start on Windows CI routinely exceeds vitest's 5s default, - // a real version string, so it isn't reduced to a tautology by mocking). - 60_000); + }, 60_000); }); From 1738a709f777608259d52fc745aa9fc1b2319e73 Mon Sep 17 00:00:00 2001 From: Miao Yang Date: Sat, 27 Jun 2026 06:47:21 +0800 Subject: [PATCH 11/14] fix(cli): install skills once globally + symlink-mirror to every agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous install path sprayed a full ~6.7MB skill copy into each of the ~70 agent conventions `skills add --all` knows (a fresh init produced 40+ dirs / 341MB, incl. a stray dotless `agent/` from the Eve convention). Install ONCE, globally, as one faithful copy, then symlink it everywhere: - `skills add --skill '*' --global --agent claude-code universal --copy` lands real files in ~/.claude/skills (Claude Code reads this at global priority) and ~/.agents/skills (the shared universal store). - mirrorGlobalSkills() fans that store out to every OTHER installed agent's GLOBAL dir (~/.cursor/skills, goose -> ~/.config/goose/skills, ...) — but only for agents present on the machine (marker dir exists), so nothing is sprayed. Unix: per-skill relative symlink into the store (one source of truth, auto-fresh on update); Windows: copy (symlinks need admin / Developer Mode there — the same fallback upstream and gstack make). Why global: skills are framework-general knowledge, not project content; Claude Code (and most agents) prioritize the personal/global scope, so the global copy is the one actually loaded — and it installs once instead of multiplying per project. The per-agent dir list is GENERATED from upstream's src/agents.ts at a pinned tag (the `skills` package exports nothing importable), committed as agentDirs.generated.ts and resolved env-faithfully at runtime (XDG_CONFIG_HOME / CODEX_HOME / CLAUDE_CONFIG_DIR honored). Regenerate with `bun run --cwd packages/cli gen:agent-dirs` when the pin moves. Covers all 70 agents that define a global dir (eve/promptscript define none); the bare project-dir agents (openclaw, astrbot) are namespaced globally, so the stray-`agent/` footgun is gone. `skills check` now scans global ($HOME) before project (cwd) to match the runtime load order — so it reports on the copy the agent will really use, not a stale project copy a newer global install silently overrides. Test plan: - skills.test.ts: install spawns the global --copy args, never --all; update stays strict + exits non-zero on failure. - skillsMirror.test.ts: Unix relative symlinks, Windows copy, XDG_CONFIG_HOME honored, install-owned stores skipped, marker-gating, idempotent refresh, generated-table shape. - skillsManifest.test.ts: check is global-first. - Full CLI suite green (981); oxlint / oxfmt / tsc clean; gen:agent-dirs --check clean (offline + network produce byte-identical output). - Benchmark (isolated HOME, local CLI): claude+hermes and all 70 agents — ~/.claude + ~/.agents real (19 each), every installed agent's global dir = 19 symlinks into the store, zero spray into unseeded agents, check global-first. (The 9 "outdated" check reports are the separate skills.sh registry lag, not this change.) - .fallowrc.jsonc: exempt the codegen script's inherent parser complexity and the parallel-case duplication in skillsManifest.test.ts (same rationale the config already uses for SlideshowPanel.test.ts / hyperframes-player.test.ts). Co-Authored-By: Claude Opus 4.8 (1M context) --- .fallowrc.jsonc | 10 + packages/cli/package.json | 3 +- packages/cli/scripts/sync-agent-dirs.ts | 169 +++++++++++++++++ packages/cli/src/commands/init.ts | 22 +-- packages/cli/src/commands/skills.test.ts | 68 ++++--- packages/cli/src/commands/skills.ts | 41 ++++- packages/cli/src/utils/agentDirs.generated.ts | 101 +++++++++++ packages/cli/src/utils/skillsManifest.test.ts | 13 +- packages/cli/src/utils/skillsManifest.ts | 10 +- packages/cli/src/utils/skillsMirror.test.ts | 171 ++++++++++++++++++ packages/cli/src/utils/skillsMirror.ts | 126 +++++++++++++ 11 files changed, 680 insertions(+), 54 deletions(-) create mode 100644 packages/cli/scripts/sync-agent-dirs.ts create mode 100644 packages/cli/src/utils/agentDirs.generated.ts create mode 100644 packages/cli/src/utils/skillsMirror.test.ts create mode 100644 packages/cli/src/utils/skillsMirror.ts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 30e0a27aa5..170e55d539 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -261,6 +261,11 @@ // utils/compositionServer.ts; the remaining clone is per-command logging text // (different labels/help lines) — extracting it would over-abstract. "packages/cli/src/commands/present.ts", + // skillsManifest.test.ts: parallel arrange/act/assert cases for locateInstall + // (project vs global scope, per-agent host conventions, claude-code priority). + // Each case seeds a dir then asserts the resolved location/agent; collapsing + // the shared seed/assert shape would obscure what each scope/host verifies. + "packages/cli/src/utils/skillsManifest.test.ts", ], }, "health": { @@ -296,6 +301,11 @@ // body is linear validation that reads clearly inline. "packages/cli/src/commands/play.ts", "packages/cli/src/commands/present.ts", + // sync-agent-dirs.ts: a build-time codegen that regex-parses upstream + // agents.ts. parseAgents/resolveGlobalExpr are branchy by nature (literal + // vs base-var args, validation throws) but small and well-tested via the + // generated table's shape test; this is dev tooling, not shipped runtime. + "packages/cli/scripts/sync-agent-dirs.ts", ], }, } diff --git a/packages/cli/package.json b/packages/cli/package.json index 79cbafc4fd..0835ac92a3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -23,7 +23,8 @@ "build:beat-analyzer": "node scripts/build-beat-analyzer.mjs", "build:copy": "node scripts/build-copy.mjs", "typecheck": "tsc --noEmit", - "gen:skills-manifest": "tsx scripts/gen-skills-manifest.ts" + "gen:skills-manifest": "tsx scripts/gen-skills-manifest.ts", + "gen:agent-dirs": "tsx scripts/sync-agent-dirs.ts" }, "dependencies": { "@hono/node-server": "^1.8.0", diff --git a/packages/cli/scripts/sync-agent-dirs.ts b/packages/cli/scripts/sync-agent-dirs.ts new file mode 100644 index 0000000000..6460758d9a --- /dev/null +++ b/packages/cli/scripts/sync-agent-dirs.ts @@ -0,0 +1,169 @@ +// Generate (or verify) packages/cli/src/utils/agentDirs.generated.ts — the +// home-relative GLOBAL skills directory for every agent the upstream +// vercel-labs/skills CLI knows about, plus a marker dir that means "this agent +// is installed on this machine". +// +// bun run --cwd packages/cli gen:agent-dirs # write/update (fetches upstream) +// bun run --cwd packages/cli gen:agent-dirs --check # verify only (CI / pre-commit) +// bun packages/cli/scripts/sync-agent-dirs.ts --src # offline +// +// Why generated, not hand-maintained: `skills add --global` installs into the +// per-agent dirs encoded in upstream's `src/agents.ts` (~70 agents). We mirror +// the canonical store into those same dirs, so the list must track upstream. The +// `skills` npm package exports nothing importable (CLI-only, bundled dist), so +// we parse the source at a PINNED tag and commit the result — deterministic at +// runtime, no network at install time. Bump SKILLS_REF and re-run on upgrade. + +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +// Pin to the upstream release whose dir layout we install against. Bump this +// (and re-run) when the bundled `skills` version moves. +const SKILLS_REPO = "vercel-labs/skills"; +const SKILLS_REF = "v1.5.13"; +const AGENTS_TS_URL = `https://raw.githubusercontent.com/${SKILLS_REPO}/${SKILLS_REF}/src/agents.ts`; + +const here = dirname(fileURLToPath(import.meta.url)); +const outPath = join(here, "..", "src", "utils", "agentDirs.generated.ts"); + +const isCheck = process.argv.includes("--check"); +const srcFlag = process.argv.indexOf("--src"); +const srcPath = srcFlag !== -1 ? process.argv[srcFlag + 1] : undefined; + +// The base directories agents.ts builds globalSkillsDir from. Each is an env +// override with a documented default — the mirror resolves them at runtime so a +// machine with XDG_CONFIG_HOME / CODEX_HOME / CLAUDE_CONFIG_DIR set lands in the +// same place the agent actually reads. We store the base NAME + suffix here +// rather than a frozen path so that resolution stays faithful to upstream. +const BASE_VARS = [ + "home", + "configHome", + "codexHome", + "claudeHome", + "vibeHome", + "hermesHome", + "autohandHome", +] as const; +type BaseVar = (typeof BASE_VARS)[number]; + +interface AgentGlobalDir { + agent: string; + base: BaseVar; + sub: string; +} + +function posixJoin(parts: string[]): string { + return parts + .flatMap((p) => p.split("/")) + .filter((s) => s && s !== ".") + .join("/"); +} + +/** Resolve a globalSkillsDir expression to { base, sub }, or null if none. */ +function resolveGlobalExpr(expr: string): { base: BaseVar; sub: string } | null { + const e = expr.trim(); + if (e === "undefined") return null; // agent defines no global skills dir + // openclaw's helper falls back to ~/.openclaw/skills when no variant is present. + if (e.startsWith("getOpenClawGlobalSkillsDir")) return { base: "home", sub: ".openclaw/skills" }; + const m = e.match(/^join\(([\s\S]+)\)$/); + if (!m) throw new Error(`Unparseable globalSkillsDir: ${expr}`); + const args = m[1]!.split(",").map((a) => a.trim()); + const first = args[0]!; + if (!(BASE_VARS as readonly string[]).includes(first)) { + throw new Error(`globalSkillsDir does not start with a known base var: ${expr}`); + } + const segs: string[] = []; + for (const raw of args.slice(1)) { + const lit = raw.match(/^['"]([^'"]*)['"]$/); + if (!lit) throw new Error(`Non-literal segment "${raw}" in globalSkillsDir: ${expr}`); + segs.push(lit[1]!); + } + return { base: first as BaseVar, sub: posixJoin(segs) }; +} + +function parseAgents(source: string): AgentGlobalDir[] { + const re = /^ {2}(?:"([a-z0-9-]+)"|'([a-z0-9-]+)'|([a-z0-9-]+)):\s*\{/gm; + const blocks: { key: string; pos: number }[] = []; + let m: RegExpExecArray | null; + while ((m = re.exec(source))) blocks.push({ key: m[1] || m[2] || m[3]!, pos: m.index }); + if (blocks.length < 60) { + throw new Error(`Parsed only ${blocks.length} agent blocks — upstream layout likely changed`); + } + + const out: AgentGlobalDir[] = []; + for (let i = 0; i < blocks.length; i++) { + const seg = source.slice(blocks[i]!.pos, blocks[i + 1] ? blocks[i + 1]!.pos : source.length); + const gd = seg.match(/globalSkillsDir:\s*([\s\S]+?),\n/); + const expr = gd ? gd[1]!.trim().replace(/\s+/g, " ") : "undefined"; + const resolved = resolveGlobalExpr(expr); + if (resolved === null) continue; // no global dir (e.g. eve, promptscript) + out.push({ agent: blocks[i]!.key, base: resolved.base, sub: resolved.sub }); + } + return out; +} + +function render(rows: AgentGlobalDir[]): string { + const lines = rows.map( + (r) => + ` { agent: ${JSON.stringify(r.agent)}, base: ${JSON.stringify(r.base)}, sub: ${JSON.stringify(r.sub)} },`, + ); + return `// @generated by packages/cli/scripts/sync-agent-dirs.ts — DO NOT EDIT. +// Source: ${SKILLS_REPO}@${SKILLS_REF} (src/agents.ts). Regenerate with: +// bun run --cwd packages/cli gen:agent-dirs +// +// Each entry is one agent the upstream \`skills\` CLI installs to. The agent's +// GLOBAL skills directory is \`join(, )\`, where \`base\` is one of the +// env-overridable home dirs below (resolved at runtime by skillsMirror.ts, so +// XDG_CONFIG_HOME / CODEX_HOME / CLAUDE_CONFIG_DIR are honored). Agents with no +// global skills dir upstream (eve, promptscript) are omitted. + +/** Env-overridable base dirs, matching upstream agents.ts. */ +export type AgentDirBase = +${BASE_VARS.map((b) => ` | ${JSON.stringify(b)}`).join("\n")}; + +export interface AgentGlobalDir { + /** Upstream agent key. */ + agent: string; + /** Base directory the global skills dir is rooted at. */ + base: AgentDirBase; + /** POSIX suffix joined onto the resolved base. */ + sub: string; +} + +export const AGENT_GLOBAL_DIRS: readonly AgentGlobalDir[] = [ +${lines.join("\n")} +]; +`; +} + +async function loadAgentsSource(): Promise { + if (srcPath) return readFileSync(srcPath, "utf8"); + const res = await fetch(AGENTS_TS_URL); + if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${AGENTS_TS_URL}`); + return res.text(); +} + +const source = await loadAgentsSource(); +const rows = parseAgents(source); +const next = render(rows); + +if (isCheck) { + let current = ""; + try { + current = readFileSync(outPath, "utf8"); + } catch { + /* missing → drift */ + } + if (current !== next) { + console.error( + `agentDirs.generated.ts is out of date (parsed ${rows.length} agents from ${SKILLS_REPO}@${SKILLS_REF}).\n` + + `Run: bun run --cwd packages/cli gen:agent-dirs`, + ); + process.exit(1); + } + console.log(`agentDirs.generated.ts is up to date (${rows.length} agents).`); +} else { + writeFileSync(outPath, next, "utf8"); + console.log(`Wrote ${rows.length} agents → ${outPath}`); +} diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 90814d4612..55740ad47e 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -574,22 +574,18 @@ 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. + * Ensure the 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 up-to-date + * machine is a no-op. Best-effort: if the version check can't reach GitHub, it + * installs anyway. The install itself (`installAllSkills`) installs the full + * set once GLOBALLY (~/.claude/skills + ~/.agents/skills) and mirrors it into + * every other installed agent, so it is project-independent — the check is + * global-first to match. */ async function ensureSkillsCurrent(destDir: string): Promise { 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...")); @@ -602,7 +598,7 @@ async function ensureSkillsCurrent(destDir: string): Promise { } if (needsInstall) { - await installAllSkills({ cwd: destDir, extraArgs }); + await installAllSkills({ cwd: destDir }); } else { console.log(c.success("AI coding skills are already up to date.")); } diff --git a/packages/cli/src/commands/skills.test.ts b/packages/cli/src/commands/skills.test.ts index 48ee0b3e0d..8428b26e1a 100644 --- a/packages/cli/src/commands/skills.test.ts +++ b/packages/cli/src/commands/skills.test.ts @@ -48,6 +48,25 @@ vi.mock("@clack/prompts", () => ({ }, })); +// The install fans out to other agents via mirrorGlobalSkills, which touches +// the real $HOME. Stub it so these arg-shape tests never create symlinks in the +// dev machine's agent dirs — the mirror has its own isolated-HOME unit tests. +vi.mock("../utils/skillsMirror.js", () => ({ + mirrorGlobalSkills: vi.fn(() => ({ source: null, mirrored: [] })), +})); + +// The global install command this CLI runs (after `skills add `). +const GLOBAL_ARGS = [ + "--skill", + "*", + "--global", + "--agent", + "claude-code", + "universal", + "--copy", + "--yes", +] as const; + function setPlatform(platform: NodeJS.Platform): void { Object.defineProperty(process, "platform", { value: platform, @@ -87,13 +106,13 @@ describe("hyperframes skills", () => { "linux", "npx", ["--version"], - ["skills", "add", "https://github.com/heygen-com/hyperframes", "--all"], + ["skills", "add", "https://github.com/heygen-com/hyperframes", ...GLOBAL_ARGS], ], [ "darwin", "npx", ["--version"], - ["skills", "add", "https://github.com/heygen-com/hyperframes", "--all"], + ["skills", "add", "https://github.com/heygen-com/hyperframes", ...GLOBAL_ARGS], ], [ "win32", @@ -107,7 +126,7 @@ describe("hyperframes skills", () => { "skills", "add", "https://github.com/heygen-com/hyperframes", - "--all", + ...GLOBAL_ARGS, ], ], ] as const)( @@ -125,13 +144,9 @@ describe("hyperframes skills", () => { }, ); - // 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 - + /** Run `skills update` with the mocked install exit code; returns the exitCode it left. */ + async function runUpdate(installExitCode: number): Promise { + state.spawnExitCode = installExitCode; const prevExit = process.exitCode; process.exitCode = 0; try { @@ -140,30 +155,27 @@ describe("hyperframes skills", () => { const updateCmd = subs.update; expect(updateCmd).toBeDefined(); await updateCmd!.run?.({ args: {}, rawArgs: [], cmd: updateCmd } as never); - expect(process.exitCode).toBe(1); + return process.exitCode; } finally { process.exitCode = prevExit; } + } + + // 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"); + expect(await runUpdate(1)).toBe(1); }); 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; - 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; - } + expect(await runUpdate(0)).toBe(0); + // pulls the full set straight from GitHub, globally, as a faithful copy + expect(state.spawnCalls[0]?.args).toContain("https://github.com/heygen-com/hyperframes"); + expect(state.spawnCalls[0]?.args).toContain("--global"); + expect(state.spawnCalls[0]?.args).toContain("--copy"); + expect(state.spawnCalls[0]?.args).not.toContain("--all"); }); }); diff --git a/packages/cli/src/commands/skills.ts b/packages/cli/src/commands/skills.ts index 6a352ed516..9514daac7d 100644 --- a/packages/cli/src/commands/skills.ts +++ b/packages/cli/src/commands/skills.ts @@ -5,6 +5,7 @@ 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 { mirrorGlobalSkills } from "../utils/skillsMirror.js"; import type { Example } from "./_examples.js"; export const examples: Example[] = [ @@ -48,11 +49,28 @@ function spawnNpx(args: string[], opts: { cwd?: string } = {}): Promise { }); } +// One faithful global install: --copy lands real files in Claude Code's global +// store (~/.claude/skills, which Claude Code reads at global priority) plus the +// shared universal store (~/.agents/skills). mirrorGlobalSkills then fans that +// store out to every OTHER installed agent's global dir. Skills are +// framework-general knowledge, so installing once globally beats copying a full +// set into every project — and avoids the ~70-agent `--all` spray entirely. +const GLOBAL_INSTALL_ARGS = [ + "--skill", + "*", + "--global", + "--agent", + "claude-code", + "universal", + "--copy", + "--yes", +]; + function runSkillsAdd( source: string, opts: { cwd?: string; extraArgs?: string[] } = {}, ): Promise { - return spawnNpx(["skills", "add", source, ...(opts.extraArgs ?? ["--all"])], opts); + return spawnNpx(["skills", "add", source, ...(opts.extraArgs ?? GLOBAL_INSTALL_ARGS)], opts); } // Use the full GitHub URL (not the `owner/repo` slug) so `skills add` git-clones @@ -85,6 +103,18 @@ export async function installAllSkills( console.log(c.dim(`${source.name} skills skipped`)); } } + + // Fan the global Claude store out to every other installed agent. No-op when + // the global store is absent (e.g. a custom --dir install), so it's safe to + // run unconditionally after any install path. + try { + const { mirrored } = mirrorGlobalSkills(); + if (mirrored.length > 0) { + console.log(c.dim(`Linked skills into ${mirrored.length} other agent director(ies).`)); + } + } catch { + // best-effort: a mirror failure must not fail the install + } } // ── check ──────────────────────────────────────────────────────────────────── @@ -167,16 +197,17 @@ const updateCommand = defineCommand({ }, args: {}, async run() { - // `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. + // The global install re-fetches every skill to the latest AND installs ones + // not yet present, then re-mirrors — 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 }); + await installAllSkills({ strict: true }); } catch (err) { clack.log.error(c.error(`Update failed: ${(err as Error).message}`)); process.exitCode = 1; diff --git a/packages/cli/src/utils/agentDirs.generated.ts b/packages/cli/src/utils/agentDirs.generated.ts new file mode 100644 index 0000000000..a769c53169 --- /dev/null +++ b/packages/cli/src/utils/agentDirs.generated.ts @@ -0,0 +1,101 @@ +// @generated by packages/cli/scripts/sync-agent-dirs.ts — DO NOT EDIT. +// Source: vercel-labs/skills@v1.5.13 (src/agents.ts). Regenerate with: +// bun run --cwd packages/cli gen:agent-dirs +// +// Each entry is one agent the upstream `skills` CLI installs to. The agent's +// GLOBAL skills directory is `join(, )`, where `base` is one of the +// env-overridable home dirs below (resolved at runtime by skillsMirror.ts, so +// XDG_CONFIG_HOME / CODEX_HOME / CLAUDE_CONFIG_DIR are honored). Agents with no +// global skills dir upstream (eve, promptscript) are omitted. + +/** Env-overridable base dirs, matching upstream agents.ts. */ +export type AgentDirBase = + | "home" + | "configHome" + | "codexHome" + | "claudeHome" + | "vibeHome" + | "hermesHome" + | "autohandHome"; + +export interface AgentGlobalDir { + /** Upstream agent key. */ + agent: string; + /** Base directory the global skills dir is rooted at. */ + base: AgentDirBase; + /** POSIX suffix joined onto the resolved base. */ + sub: string; +} + +export const AGENT_GLOBAL_DIRS: readonly AgentGlobalDir[] = [ + { agent: "aider-desk", base: "home", sub: ".aider-desk/skills" }, + { agent: "amp", base: "configHome", sub: "agents/skills" }, + { agent: "antigravity", base: "home", sub: ".gemini/antigravity/skills" }, + { agent: "antigravity-cli", base: "home", sub: ".gemini/antigravity-cli/skills" }, + { agent: "astrbot", base: "home", sub: ".astrbot/data/skills" }, + { agent: "autohand-code", base: "autohandHome", sub: "skills" }, + { agent: "augment", base: "home", sub: ".augment/skills" }, + { agent: "bob", base: "home", sub: ".bob/skills" }, + { agent: "claude-code", base: "claudeHome", sub: "skills" }, + { agent: "openclaw", base: "home", sub: ".openclaw/skills" }, + { agent: "cline", base: "home", sub: ".agents/skills" }, + { agent: "codearts-agent", base: "home", sub: ".codeartsdoer/skills" }, + { agent: "codebuddy", base: "home", sub: ".codebuddy/skills" }, + { agent: "codemaker", base: "home", sub: ".codemaker/skills" }, + { agent: "codestudio", base: "home", sub: ".codestudio/skills" }, + { agent: "codex", base: "codexHome", sub: "skills" }, + { agent: "command-code", base: "home", sub: ".commandcode/skills" }, + { agent: "continue", base: "home", sub: ".continue/skills" }, + { agent: "cortex", base: "home", sub: ".snowflake/cortex/skills" }, + { agent: "crush", base: "home", sub: ".config/crush/skills" }, + { agent: "cursor", base: "home", sub: ".cursor/skills" }, + { agent: "deepagents", base: "home", sub: ".deepagents/agent/skills" }, + { agent: "devin", base: "configHome", sub: "devin/skills" }, + { agent: "dexto", base: "home", sub: ".agents/skills" }, + { agent: "droid", base: "home", sub: ".factory/skills" }, + { agent: "firebender", base: "home", sub: ".firebender/skills" }, + { agent: "forgecode", base: "home", sub: ".forge/skills" }, + { agent: "gemini-cli", base: "home", sub: ".gemini/skills" }, + { agent: "github-copilot", base: "home", sub: ".copilot/skills" }, + { agent: "goose", base: "configHome", sub: "goose/skills" }, + { agent: "hermes-agent", base: "hermesHome", sub: "skills" }, + { agent: "inference-sh", base: "home", sub: ".inferencesh/skills" }, + { agent: "jazz", base: "home", sub: ".jazz/skills" }, + { agent: "junie", base: "home", sub: ".junie/skills" }, + { agent: "iflow-cli", base: "home", sub: ".iflow/skills" }, + { agent: "kilo", base: "home", sub: ".kilocode/skills" }, + { agent: "kimi-code-cli", base: "home", sub: ".agents/skills" }, + { agent: "kiro-cli", base: "home", sub: ".kiro/skills" }, + { agent: "kode", base: "home", sub: ".kode/skills" }, + { agent: "lingma", base: "home", sub: ".lingma/skills" }, + { agent: "loaf", base: "home", sub: ".agents/skills" }, + { agent: "mcpjam", base: "home", sub: ".mcpjam/skills" }, + { agent: "mistral-vibe", base: "vibeHome", sub: "skills" }, + { agent: "moxby", base: "home", sub: ".moxby/skills" }, + { agent: "mux", base: "home", sub: ".mux/skills" }, + { agent: "opencode", base: "configHome", sub: "opencode/skills" }, + { agent: "openhands", base: "home", sub: ".openhands/skills" }, + { agent: "ona", base: "home", sub: ".ona/skills" }, + { agent: "pi", base: "home", sub: ".pi/agent/skills" }, + { agent: "qoder", base: "home", sub: ".qoder/skills" }, + { agent: "qoder-cn", base: "home", sub: ".qoder-cn/skills" }, + { agent: "qwen-code", base: "home", sub: ".qwen/skills" }, + { agent: "replit", base: "configHome", sub: "agents/skills" }, + { agent: "reasonix", base: "home", sub: ".reasonix/skills" }, + { agent: "rovodev", base: "home", sub: ".rovodev/skills" }, + { agent: "roo", base: "home", sub: ".roo/skills" }, + { agent: "tabnine-cli", base: "home", sub: ".tabnine/agent/skills" }, + { agent: "terramind", base: "home", sub: ".terramind/skills" }, + { agent: "tinycloud", base: "home", sub: ".tinycloud/skills" }, + { agent: "trae", base: "home", sub: ".trae/skills" }, + { agent: "trae-cn", base: "home", sub: ".trae-cn/skills" }, + { agent: "warp", base: "home", sub: ".agents/skills" }, + { agent: "windsurf", base: "home", sub: ".codeium/windsurf/skills" }, + { agent: "zed", base: "home", sub: ".agents/skills" }, + { agent: "zencoder", base: "home", sub: ".zencoder/skills" }, + { agent: "zenflow", base: "home", sub: ".zencoder/skills" }, + { agent: "neovate", base: "home", sub: ".neovate/skills" }, + { agent: "pochi", base: "home", sub: ".pochi/skills" }, + { agent: "adal", base: "home", sub: ".adal/skills" }, + { agent: "universal", base: "configHome", sub: "agents/skills" }, +]; diff --git a/packages/cli/src/utils/skillsManifest.test.ts b/packages/cli/src/utils/skillsManifest.test.ts index fc1386c272..23878f154e 100644 --- a/packages/cli/src/utils/skillsManifest.test.ts +++ b/packages/cli/src/utils/skillsManifest.test.ts @@ -190,18 +190,21 @@ describe("checkSkills install detection", () => { expect(res.agent).toBe(agent); }); - it("prefers project scope over global, regardless of convention order", async () => { + it("prefers global scope over project (matches how agents load 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, ".claude/skills"), "alpha"); // global, higher-priority host - installSkill(join(project, ".hermes/skills"), "alpha"); // project, lower-priority host + installSkill(join(home, ".claude/skills"), "alpha"); // global — what the agent actually loads + installSkill(join(project, ".hermes/skills"), "alpha"); // project — overridden by the global copy + // Claude Code (and most agents) give the personal/global scope priority over + // the project scope, and HyperFrames installs globally — so check reports on + // the global copy the agent will really use, not a stale project copy. const res = await checkSkills({ source, cwd: project, home }); - expect(res.location).toBe(join(project, ".hermes/skills")); - expect(res.agent).toBe("hermes"); + expect(res.location).toBe(join(home, ".claude/skills")); + expect(res.agent).toBe("claude-code"); }); it("reports no location and an available update when nothing is installed", async () => { diff --git a/packages/cli/src/utils/skillsManifest.ts b/packages/cli/src/utils/skillsManifest.ts index 2e94c36a13..62fb1f8fe1 100644 --- a/packages/cli/src/utils/skillsManifest.ts +++ b/packages/cli/src/utils/skillsManifest.ts @@ -204,7 +204,13 @@ function discoverSkillRoots(base: string, scope: "project" | "global"): SkillRoo /** * 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. + * Otherwise scan global ($HOME) then project (cwd), auto-discovering hosts. + * + * Global is checked FIRST to match how agents actually load skills: Claude Code + * (and most others) give the personal/global scope priority over the project + * scope, and HyperFrames now installs globally. Checking global-first means + * `check` reports on the copy the agent will really use — not a stale project + * copy that a newer global install silently overrides. */ function locateInstall( skillNames: string[], @@ -216,8 +222,8 @@ function locateInstall( : null; } const roots = [ - ...discoverSkillRoots(opts.cwd ?? process.cwd(), "project"), ...discoverSkillRoots(opts.home ?? homedir(), "global"), + ...discoverSkillRoots(opts.cwd ?? process.cwd(), "project"), ]; for (const root of roots) { if (skillNames.some((n) => existsSync(join(root.dir, n, "SKILL.md")))) return root; diff --git a/packages/cli/src/utils/skillsMirror.test.ts b/packages/cli/src/utils/skillsMirror.test.ts new file mode 100644 index 0000000000..9b2df7c80c --- /dev/null +++ b/packages/cli/src/utils/skillsMirror.test.ts @@ -0,0 +1,171 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readlinkSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { isAbsolute, join } from "node:path"; +import { mirrorGlobalSkills } from "./skillsMirror.js"; +import { AGENT_GLOBAL_DIRS } from "./agentDirs.generated.js"; + +const tmpDirs: string[] = []; + +// Resolve agent dirs under the isolated HOME with default (unset) env, so the +// dev machine's real XDG_CONFIG_HOME / CODEX_HOME never leak into the test. +const ENV: NodeJS.ProcessEnv = {}; + +function makeHome(): string { + const home = mkdtempSync(join(tmpdir(), "mirror-home-")); + tmpDirs.push(home); + return home; +} + +/** Seed a real ~/.claude/skills store (the canonical global install). */ +function seedStore(home: string, skills: string[]): void { + for (const name of skills) { + const dir = join(home, ".claude", "skills", name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "SKILL.md"), `# ${name}\n`, "utf8"); + } +} + +/** Pretend an agent is installed by creating its marker dir. */ +function installMarker(home: string, marker: string): void { + mkdirSync(join(home, ...marker.split("/")), { recursive: true }); +} + +afterEach(() => { + for (const d of tmpDirs.splice(0)) rmSync(d, { recursive: true, force: true }); +}); + +describe("mirrorGlobalSkills", () => { + it("no-ops when there is no global Claude store", () => { + const home = makeHome(); + const result = mirrorGlobalSkills({ home, platform: "linux", env: ENV }); + expect(result.source).toBeNull(); + expect(result.mirrored).toEqual([]); + }); + + it("mirrors the store into installed agents as relative symlinks (Unix)", () => { + const home = makeHome(); + seedStore(home, ["hyperframes", "hyperframes-core"]); + installMarker(home, ".cursor"); // cursor present + installMarker(home, ".config/goose"); // goose present (XDG base) + // windsurf NOT installed (no ~/.codeium/windsurf) + + const { mirrored } = mirrorGlobalSkills({ home, platform: "linux", env: ENV }); + const agents = mirrored.map((m) => m.agent); + expect(agents).toContain("cursor"); + expect(agents).toContain("goose"); + expect(agents).not.toContain("windsurf"); + + const link = join(home, ".cursor", "skills", "hyperframes"); + expect(lstatSync(link).isSymbolicLink()).toBe(true); + expect(isAbsolute(readlinkSync(link))).toBe(false); // relative target + expect(realpathSync(link)).toBe(realpathSync(join(home, ".claude", "skills", "hyperframes"))); + expect(existsSync(join(link, "SKILL.md"))).toBe(true); + + // goose lands in the XDG config dir (~/.config/goose), not ~/.goose + expect( + existsSync(join(home, ".config", "goose", "skills", "hyperframes-core", "SKILL.md")), + ).toBe(true); + }); + + it("honors XDG_CONFIG_HOME for config-based agents", () => { + const home = makeHome(); + const xdg = makeHome(); // a separate absolute XDG config root + seedStore(home, ["hyperframes"]); + mkdirSync(join(xdg, "goose"), { recursive: true }); // goose marker under XDG + + const { mirrored } = mirrorGlobalSkills({ + home, + platform: "linux", + env: { XDG_CONFIG_HOME: xdg }, + }); + expect(mirrored.map((m) => m.agent)).toContain("goose"); + expect(existsSync(join(xdg, "goose", "skills", "hyperframes", "SKILL.md"))).toBe(true); + expect(existsSync(join(home, ".config", "goose", "skills"))).toBe(false); + }); + + it("copies instead of symlinking on Windows", () => { + const home = makeHome(); + seedStore(home, ["hyperframes"]); + installMarker(home, ".cursor"); + + mirrorGlobalSkills({ home, platform: "win32", env: ENV }); + const target = join(home, ".cursor", "skills", "hyperframes"); + expect(lstatSync(target).isSymbolicLink()).toBe(false); + expect(lstatSync(target).isDirectory()).toBe(true); + expect(existsSync(join(target, "SKILL.md"))).toBe(true); + }); + + it("never mirrors onto the install-owned stores (.claude / .agents)", () => { + const home = makeHome(); + seedStore(home, ["hyperframes"]); + installMarker(home, ".agents"); // .agents present (the universal install creates it) + + const { mirrored } = mirrorGlobalSkills({ home, platform: "linux", env: ENV }); + expect(mirrored.map((m) => m.agent)).not.toContain("claude-code"); + // the .agents-family agents (cline/dexto/…) map to .agents/skills and are skipped + expect(mirrored.map((m) => m.agent)).not.toContain("cline"); + // ~/.agents/skills is the real universal store — must stay untouched (no link created) + expect(existsSync(join(home, ".agents", "skills"))).toBe(false); + }); + + it("is idempotent and refreshes stale entries", () => { + const home = makeHome(); + seedStore(home, ["hyperframes"]); + installMarker(home, ".cursor"); + + mirrorGlobalSkills({ home, platform: "linux", env: ENV }); + // second run must not throw and must leave a valid link + const { mirrored } = mirrorGlobalSkills({ home, platform: "linux", env: ENV }); + expect(mirrored.map((m) => m.agent)).toContain("cursor"); + const link = join(home, ".cursor", "skills", "hyperframes"); + expect(realpathSync(link)).toBe(realpathSync(join(home, ".claude", "skills", "hyperframes"))); + }); +}); + +describe("AGENT_GLOBAL_DIRS (generated table)", () => { + it("is a non-trivial, well-formed table", () => { + const validBases = new Set([ + "home", + "configHome", + "codexHome", + "claudeHome", + "vibeHome", + "hermesHome", + "autohandHome", + ]); + expect(AGENT_GLOBAL_DIRS.length).toBeGreaterThan(50); + for (const e of AGENT_GLOBAL_DIRS) { + expect(validBases.has(e.base)).toBe(true); + expect(e.sub.endsWith("skills")).toBe(true); + expect(e.sub.startsWith("/")).toBe(false); // a suffix, not an absolute path + } + }); + + it("covers the major agents at their real bases", () => { + const byAgent = new Map(AGENT_GLOBAL_DIRS.map((e) => [e.agent, e])); + expect(byAgent.get("claude-code")).toMatchObject({ base: "claudeHome", sub: "skills" }); + expect(byAgent.get("cursor")).toMatchObject({ base: "home", sub: ".cursor/skills" }); + expect(byAgent.get("codex")).toMatchObject({ base: "codexHome", sub: "skills" }); + expect(byAgent.get("goose")).toMatchObject({ base: "configHome", sub: "goose/skills" }); + expect(byAgent.get("windsurf")).toMatchObject({ + base: "home", + sub: ".codeium/windsurf/skills", + }); + expect(byAgent.get("droid")).toMatchObject({ base: "home", sub: ".factory/skills" }); + // bare-dir-in-project agents become namespaced globally (no footgun) + expect(byAgent.get("openclaw")).toMatchObject({ base: "home", sub: ".openclaw/skills" }); + // agents with no upstream global dir are omitted + expect(byAgent.has("eve")).toBe(false); + expect(byAgent.has("promptscript")).toBe(false); + }); +}); diff --git a/packages/cli/src/utils/skillsMirror.ts b/packages/cli/src/utils/skillsMirror.ts new file mode 100644 index 0000000000..320b617d9e --- /dev/null +++ b/packages/cli/src/utils/skillsMirror.ts @@ -0,0 +1,126 @@ +// Fan the canonical global skills store out to every OTHER installed agent. +// +// `skills add --global --agent claude-code universal --copy` writes REAL files +// to two global stores: the Claude store (~/.claude/skills — what Claude Code +// reads, at global priority) and the shared universal store (~/.agents/skills, +// which Cursor/Codex/… read in PROJECT scope and the .agents-family agents read +// globally). But every other agent reads its OWN global dir (~/.cursor/skills, +// goose → ~/.config/goose/skills, …), which upstream's --global does NOT +// populate. +// +// So we mirror the canonical Claude store into each of those per-agent dirs, but +// only for agents the machine actually has (their marker dir exists). On Unix +// each skill is a relative symlink back into the store (one source of truth, +// near-zero size, auto-fresh on update); on Windows it's a copy, because +// symlinks there need admin / Developer Mode and otherwise silently dangle — +// the same fallback the upstream `skills` CLI and gstack both make. +// +// Agent dirs are resolved through the same env-overridable base dirs upstream +// uses (XDG_CONFIG_HOME, CODEX_HOME, CLAUDE_CONFIG_DIR, …), so a machine with +// those set mirrors into the exact dir the agent reads. + +import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, symlinkSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, isAbsolute, join, relative } from "node:path"; +import { AGENT_GLOBAL_DIRS, type AgentDirBase } from "./agentDirs.generated.js"; + +export interface MirrorResult { + /** The store mirrored from, or null when no global Claude store was found. */ + source: string | null; + /** Agents whose global dir was (re)populated. */ + mirrored: { agent: string; dir: string }[]; +} + +/** Resolve each env-overridable base dir exactly as upstream agents.ts does. */ +function resolveBases(home: string, env: NodeJS.ProcessEnv): Record { + const xdg = env["XDG_CONFIG_HOME"]?.trim(); + return { + home, + configHome: xdg && isAbsolute(xdg) ? xdg : join(home, ".config"), + codexHome: env["CODEX_HOME"]?.trim() || join(home, ".codex"), + claudeHome: env["CLAUDE_CONFIG_DIR"]?.trim() || join(home, ".claude"), + vibeHome: env["VIBE_HOME"]?.trim() || join(home, ".vibe"), + hermesHome: env["HERMES_HOME"]?.trim() || join(home, ".hermes"), + autohandHome: env["AUTOHAND_HOME"]?.trim() || join(home, ".autohand"), + }; +} + +/** Skill bundle names directly under a store (a dir/symlink with a SKILL.md). */ +function listSkillDirs(store: string): string[] { + return readdirSync(store, { withFileTypes: true }) + .filter( + (e) => (e.isDirectory() || e.isSymbolicLink()) && existsSync(join(store, e.name, "SKILL.md")), + ) + .map((e) => e.name); +} + +/** + * Point `targetSkill` at `sourceSkill`. Any prior entry (our symlink, a stale + * copy, or a previous install) is removed first so the mirror always reflects + * the canonical store — that's the whole point of "update". + */ +function linkOrCopy(sourceSkill: string, targetSkill: string, platform: NodeJS.Platform): void { + rmSync(targetSkill, { recursive: true, force: true }); + if (platform === "win32") { + cpSync(sourceSkill, targetSkill, { recursive: true }); + } else { + symlinkSync(relative(dirname(targetSkill), sourceSkill), targetSkill); + } +} + +/** + * Populate one agent's global dir from the store. Best-effort and idempotent; + * per-skill failures don't abort the others. Returns false if the dir couldn't + * be created at all. + */ +function mirrorInto( + targetDir: string, + source: string, + skills: string[], + platform: NodeJS.Platform, +): boolean { + try { + mkdirSync(targetDir, { recursive: true }); + } catch { + return false; + } + for (const skill of skills) { + try { + linkOrCopy(join(source, skill), join(targetDir, skill), platform); + } catch { + // best-effort per skill + } + } + return true; +} + +/** + * Mirror the global Claude store into every installed agent's global skills + * dir. Best-effort and idempotent: a no-op when the store is absent, and per + * skill failures (permissions, races) don't abort the rest. + */ +export function mirrorGlobalSkills( + opts: { home?: string; platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv } = {}, +): MirrorResult { + const home = opts.home ?? homedir(); + const platform = opts.platform ?? process.platform; + const bases = resolveBases(home, opts.env ?? process.env); + + // The two stores the global --copy install writes as real files. The mirror + // reads from the Claude store and must never link/copy onto either of them. + const source = join(bases.claudeHome, "skills"); + const universalStore = join(home, ".agents", "skills"); + if (!existsSync(source)) return { source: null, mirrored: [] }; + + const skills = listSkillDirs(source); + if (skills.length === 0) return { source, mirrored: [] }; + + const mirrored: { agent: string; dir: string }[] = []; + for (const { agent, base, sub } of AGENT_GLOBAL_DIRS) { + const targetDir = join(bases[base], ...sub.split("/").filter(Boolean)); + if (targetDir === source || targetDir === universalStore) continue; // install-owned + if (!existsSync(dirname(targetDir))) continue; // agent not installed (no marker) + if (mirrorInto(targetDir, source, skills, platform)) mirrored.push({ agent, dir: targetDir }); + } + return { source, mirrored }; +} From 2aa38603bd2761eb2b11d004459b557d414cfbaa Mon Sep 17 00:00:00 2001 From: Miao Yang Date: Sat, 27 Jun 2026 07:08:40 +0800 Subject: [PATCH 12/14] fix(cli): install skills with --full-depth so a fresh install reads as current MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `skills add ` without --full-depth fetches from the skills.sh registry blob ("Fetching skills"), which lags GitHub main by hours — so a freshly installed/updated set read as ~9 skills "outdated" right after install, and `skills update` couldn't fix it (it re-fetched the same stale blob → death loop). --full-depth switches it to a real `git clone` of HEAD ("Cloning repository"), the only path that yields the genuine latest. - Add --full-depth to the global install args. Verified (isolated HOME): blob path → 10 current / 9 outdated; --full-depth → 19 current / 0 outdated. - The clone is heavier than the blob fetch, so set GIT_LFS_SKIP_SMUDGE=1 (skills are text; the repo's LFS objects are unrelated binaries the install doesn't need) and raise the spawn timeout 120s → 300s. - Correct the stale comment that claimed a full URL already bypasses skills.sh — it doesn't; only --full-depth does. Benchmark (skills-bench, local CLI): B.death-loop and J1.init-detect-and-refresh flip FAIL → PASS (install/update/init now 19/0); mirror smoke reports 19 current / 0 outdated. (spine still reflects the raw documented `skills add ` command — the upstream skills.sh path, not this CLI.) Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/commands/skills.test.ts | 5 ++- packages/cli/src/commands/skills.ts | 42 +++++++++++++++++------- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/commands/skills.test.ts b/packages/cli/src/commands/skills.test.ts index 8428b26e1a..19fcb39b20 100644 --- a/packages/cli/src/commands/skills.test.ts +++ b/packages/cli/src/commands/skills.test.ts @@ -64,6 +64,7 @@ const GLOBAL_ARGS = [ "claude-code", "universal", "--copy", + "--full-depth", "--yes", ] as const; @@ -87,7 +88,7 @@ describe("hyperframes skills", () => { vi.restoreAllMocks(); }); - it("sets GIT_CLONE_PROTECTION_ACTIVE=0 on the spawned skills CLI child (GH #316)", async () => { + it("sets clone-safe env on the spawned skills CLI child (GH #316 + LFS skip)", async () => { setPlatform("linux"); const { default: skillsCmd } = await import("./skills.js"); @@ -99,6 +100,8 @@ describe("hyperframes skills", () => { expect(first!.args).toContain("skills"); expect(first!.args).toContain("add"); expect(first!.env?.GIT_CLONE_PROTECTION_ACTIVE).toBe("0"); + // --full-depth clones the repo; skip LFS so we don't drag in unrelated blobs. + expect(first!.env?.GIT_LFS_SKIP_SMUDGE).toBe("1"); }); it.each([ diff --git a/packages/cli/src/commands/skills.ts b/packages/cli/src/commands/skills.ts index 9514daac7d..ef50788bd5 100644 --- a/packages/cli/src/commands/skills.ts +++ b/packages/cli/src/commands/skills.ts @@ -30,15 +30,25 @@ function spawnNpx(args: string[], opts: { cwd?: string } = {}): Promise { return new Promise((resolve, reject) => { const child = spawn(npx.command, npx.args, { stdio: "inherit", - timeout: 120_000, + // We install with --full-depth (a full `git clone` of the repo, the only + // path that bypasses the laggy skills.sh blob — see GLOBAL_INSTALL_ARGS), + // which is heavier than the blob fetch, so allow more headroom. + timeout: 300_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), 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" }, + env: { + ...process.env, + // GH #316 — the upstream `skills` CLI shells out to `git clone`. When + // Git's clone-hook protection is active (default in 2.45.1, reverted in + // 2.45.2, still present on many corporate/CI setups), a globally + // registered `git lfs install` post-checkout hook aborts the clone. The + // args reaching this function are hardcoded (no user input), so opting + // out is safe. + GIT_CLONE_PROTECTION_ACTIVE: "0", + // Skills are text; the repo's LFS objects are unrelated binary assets. + // Skip the smudge so --full-depth doesn't drag down (or fail on) large + // LFS blobs the install doesn't need. + GIT_LFS_SKIP_SMUDGE: "1", + }, }); child.on("close", (code, signal) => { if (code === 0) resolve(); @@ -55,6 +65,13 @@ function spawnNpx(args: string[], opts: { cwd?: string } = {}): Promise { // store out to every OTHER installed agent's global dir. Skills are // framework-general knowledge, so installing once globally beats copying a full // set into every project — and avoids the ~70-agent `--all` spray entirely. +// +// --full-depth forces a full `git clone` of the repo at HEAD. Without it, even +// a full-URL `skills add` fetches from the skills.sh registry blob ("Fetching +// skills"), which lags GitHub main by hours — so a fresh install would read as +// several skills "outdated". --full-depth switches it to "Cloning repository", +// the only path that gives the genuine latest. (Verified: blob path → ~9 +// outdated; --full-depth → all current.) const GLOBAL_INSTALL_ARGS = [ "--skill", "*", @@ -63,6 +80,7 @@ const GLOBAL_INSTALL_ARGS = [ "claude-code", "universal", "--copy", + "--full-depth", "--yes", ]; @@ -73,10 +91,10 @@ function runSkillsAdd( return spawnNpx(["skills", "add", source, ...(opts.extraArgs ?? GLOBAL_INSTALL_ARGS)], 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. +// Use the full GitHub URL (not the `owner/repo` slug) as the clone source. The +// freshness comes from --full-depth (see GLOBAL_INSTALL_ARGS), which clones the +// repo at latest `main`; the URL just names what to clone. Our freshness check +// resolves "latest" straight from GitHub too, so install and check agree. const SOURCES = [{ name: "HyperFrames", url: "https://github.com/heygen-com/hyperframes" }]; export async function installAllSkills( From a3f66cd27241602e790a1429b0ed1bdc49fdfb4a Mon Sep 17 00:00:00 2001 From: Miao Yang Date: Sat, 27 Jun 2026 07:14:38 +0800 Subject: [PATCH 13/14] docs(skills): drop --skip-skills from workflow init so new projects refresh skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The creation workflows scaffolded with `hyperframes init … --skip-skills`, which skipped the skills currency check. Now that init installs globally, is a no-op when already current, and pulls the genuine latest (via --full-depth), there's no reason to skip it: removing --skip-skills means every new project runs the check and refreshes the global skill set from GitHub when it's stale. Add a one-line note to each workflow (embedded-captions, faceless-explainer, motion-graphics, music-to-video, pr-to-video, product-launch-video) and the hyperframes-cli + /hyperframes router explaining what init does. skills-manifest.json regenerated by the pre-commit hook to match the edited skill bundles. Co-Authored-By: Claude Opus 4.8 (1M context) --- skills-manifest.json | 16 ++++++++-------- skills/embedded-captions/SKILL.md | 4 +++- .../references/bespoke-vs-presets.md | 2 +- skills/faceless-explainer/SKILL.md | 2 +- skills/hyperframes-cli/SKILL.md | 2 +- skills/hyperframes/SKILL.md | 2 +- skills/motion-graphics/SKILL.md | 4 +++- skills/music-to-video/SKILL.md | 4 ++-- skills/pr-to-video/SKILL.md | 2 +- skills/product-launch-video/SKILL.md | 2 +- 10 files changed, 22 insertions(+), 18 deletions(-) diff --git a/skills-manifest.json b/skills-manifest.json index 303a4a5cb5..d69a9c0a32 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -2,11 +2,11 @@ "source": "heygen-com/hyperframes", "skills": { "embedded-captions": { - "hash": "d5561ac019e72ed5", + "hash": "7fa25370f9fbb4b3", "files": 144 }, "faceless-explainer": { - "hash": "d6beeca6029b815a", + "hash": "241a8a6387d7203f", "files": 17 }, "general-video": { @@ -14,7 +14,7 @@ "files": 1 }, "hyperframes": { - "hash": "00ad363cda72268d", + "hash": "1b35d1424ca18261", "files": 1 }, "hyperframes-animation": { @@ -22,7 +22,7 @@ "files": 108 }, "hyperframes-cli": { - "hash": "ed24d781c2ae462b", + "hash": "4eda382550997fe8", "files": 7 }, "hyperframes-core": { @@ -46,19 +46,19 @@ "files": 19 }, "motion-graphics": { - "hash": "5a256811f730f36e", + "hash": "be1d1f159d5eb0e4", "files": 23 }, "music-to-video": { - "hash": "abea9101f4322954", + "hash": "c188d0d159b926c2", "files": 132 }, "pr-to-video": { - "hash": "d7acaeb6281f99ac", + "hash": "4008beb64b6c2a9f", "files": 21 }, "product-launch-video": { - "hash": "5669874d3bdd5bd4", + "hash": "332de8e27ae3905f", "files": 18 }, "remotion-to-hyperframes": { diff --git a/skills/embedded-captions/SKILL.md b/skills/embedded-captions/SKILL.md index 2cabe9f7bc..45df255e20 100644 --- a/skills/embedded-captions/SKILL.md +++ b/skills/embedded-captions/SKILL.md @@ -105,7 +105,7 @@ Read the samples. Refuse if: ## Pipeline — 5 steps ``` -1. hyperframes init --non-interactive --video --skip-skills +1. hyperframes init --non-interactive --video 2. bash scripts/prepare.sh # matte ∥ transcribe (parallel) → safe-zones. One command. # → frames_fg/ transcript.json safe-zones.json 3. [AGENT STEP — the only creative step] author a small JSON; see below by mode @@ -117,6 +117,8 @@ Read the samples. Refuse if: + _postfx.sh; the deliverable is final_fx.mp4, final.mp4 is pre-plate-reaction) ``` +Step 1's `init` checks the installed skills against the latest on GitHub and updates the global set if any are out of date. + Step 3 differs by mode: ### Step 3 — Cinematic mode (pure embed) diff --git a/skills/embedded-captions/references/bespoke-vs-presets.md b/skills/embedded-captions/references/bespoke-vs-presets.md index 6e52516dfb..d6f8fd4406 100644 --- a/skills/embedded-captions/references/bespoke-vs-presets.md +++ b/skills/embedded-captions/references/bespoke-vs-presets.md @@ -135,7 +135,7 @@ For a new video that's clearly similar to an existing canonical example: ```bash # 1. Scaffold the project -hyperframes init --non-interactive --video --skip-skills +hyperframes init --non-interactive --video # 2. Matte + transcribe node scripts/matte.cjs diff --git a/skills/faceless-explainer/SKILL.md b/skills/faceless-explainer/SKILL.md index a14f15c18d..28b84de747 100644 --- a/skills/faceless-explainer/SKILL.md +++ b/skills/faceless-explainer/SKILL.md @@ -23,7 +23,7 @@ Goal: Lock the core video brief and create the HyperFrames project if needed. Initialize only if `hyperframes.json` is missing. Name `` from the topic in kebab-case, such as `compound-interest-explained`; never use workspace name or timestamp. -`npx hyperframes init "videos/" --non-interactive --skip-skills --example=blank` +`npx hyperframes init "videos/" --non-interactive --example=blank` — `init` checks the installed skills against the latest on GitHub and updates the global set if any are out of date. **Show sign-in status before the brief** — run `npx hyperframes auth status` and **relay its output verbatim (don't paraphrase or rewrite it).** It reports whether voice/BGM will use HeyGen or local engines and, when not signed in, how to sign in. **If not signed in, STOP and wait for the user to choose — sign in, or say "go"/"offline" to continue with local engines — before asking the brief or anything else.** Treat it as a real decision point, not a passing note; don't fold the choice into the brief question, and don't write keys into a per-repo `.env`. (In autonomous mode, note the status and continue offline.) See `../hyperframes-media` → Preflight for the canonical guidance. diff --git a/skills/hyperframes-cli/SKILL.md b/skills/hyperframes-cli/SKILL.md index f84776d0da..f893d9fd56 100644 --- a/skills/hyperframes-cli/SKILL.md +++ b/skills/hyperframes-cli/SKILL.md @@ -9,7 +9,7 @@ Everything runs through `npx hyperframes` unless project instructions specify a ## Workflow -1. **Scaffold** — `npx hyperframes init my-video` (or `capture` from a URL) +1. **Scaffold** — `npx hyperframes init my-video` (or `capture` from a URL). `init` also checks the installed skills against the latest on GitHub and updates the global set if any are out of date — keep it on (don't pass `--skip-skills`) so each new project pulls our latest skills. 2. **Write** — author HTML composition (see the `hyperframes-core` skill) 3. **Lint** — `npx hyperframes lint` 4. **Validate** — `npx hyperframes validate` (runtime errors + contrast) diff --git a/skills/hyperframes/SKILL.md b/skills/hyperframes/SKILL.md index 409efb5bde..3845b785c1 100644 --- a/skills/hyperframes/SKILL.md +++ b/skills/hyperframes/SKILL.md @@ -85,7 +85,7 @@ 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`. +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. The creation workflows scaffold with `init` (no `--skip-skills`), so starting a new project always runs this check and pulls our latest skills from GitHub when they're stale. Opt out only by adding `--skip-skills`. If a task is behaving unexpectedly, or before a long build, confirm the installed skills are current: diff --git a/skills/motion-graphics/SKILL.md b/skills/motion-graphics/SKILL.md index 39fba133b8..a9593aef28 100644 --- a/skills/motion-graphics/SKILL.md +++ b/skills/motion-graphics/SKILL.md @@ -83,9 +83,11 @@ Only when `$PROJECT_DIR/hyperframes.json` is absent: ```bash PROJECT_DIR="${MOTION_GRAPHICS_DIR:-videos/}" mkdir -p "$(dirname "$PROJECT_DIR")" -npx hyperframes init "$PROJECT_DIR" --non-interactive --skip-skills --example=blank +npx hyperframes init "$PROJECT_DIR" --non-interactive --example=blank ``` +`init` checks the installed skills against the latest on GitHub and updates the global set if any are out of date. + **Constraints:** never `hyperframes init` in the workspace root; never nest another `hyperframes/` inside `PROJECT_DIR`; every Bash command (master + subagents) is a `(cd "$PROJECT_DIR" && ...)` subshell — never bare `cd`. ### Step 1 — Plan (subagent: Director Part 1) diff --git a/skills/music-to-video/SKILL.md b/skills/music-to-video/SKILL.md index c37e46468e..e7f28c5281 100644 --- a/skills/music-to-video/SKILL.md +++ b/skills/music-to-video/SKILL.md @@ -26,10 +26,10 @@ Goal: Establish the music source, create the HyperFrames project, and note any u The **music is the spine** — establish one track before anything else. This skill is tuned for **fast, high-energy BGM**: a strong beat grid drives the cuts (calm tracks work, but pace by phrase rather than beat). If the user gave you audio — a music file, or a video to pull the audio from — use it. If not, generate one: choose the mood from the user's description (e.g. "driving synthwave", "trap beat", "upbeat corporate") and produce a track via `/hyperframes-media` (`references/bgm.md` — HeyGen retrieval when credentialed, else local Lyria / MusicGen; ElevenLabs or another generator also works). Before generating, run `npx hyperframes auth status` and **relay its output verbatim (don't paraphrase or rewrite it)** — it shows whether BGM comes from HeyGen or local MusicGen and, if not signed in, how to sign in. **If not signed in, STOP and wait for the user to choose — sign in, or continue offline with local MusicGen — before generating the track**; don't write keys into a per-repo `.env`. (In autonomous mode, note the status and continue offline.) See `/hyperframes-media` → Preflight for the canonical guidance. Either way the track lands at `assets/bgm.mp3`. Stage any user-supplied images or videos so frames can weave them in on the beat grid; otherwise typography carries the whole video. -Initialize only if `hyperframes.json` is missing. Name `` from the brief in kebab-case, such as `midnight-drive-loop` — never a timestamp. +Initialize only if `hyperframes.json` is missing. Name `` from the brief in kebab-case, such as `midnight-drive-loop` — never a timestamp. `init` checks the installed skills against the latest on GitHub and updates the global set if any are out of date. ```bash -npx hyperframes init "videos/" --non-interactive --skip-skills --example=blank +npx hyperframes init "videos/" --non-interactive --example=blank mkdir -p "$PROJECT_DIR/assets" "$PROJECT_DIR/renders" cp "" "$PROJECT_DIR/assets/bgm.mp3" # extract from a video first if needed # only if the user gave you images/videos: diff --git a/skills/pr-to-video/SKILL.md b/skills/pr-to-video/SKILL.md index ba6c0f1454..dc51b00e31 100644 --- a/skills/pr-to-video/SKILL.md +++ b/skills/pr-to-video/SKILL.md @@ -42,7 +42,7 @@ State the basis in one phrase when you propose it (e.g. "~40s — small change, Initialize only if `hyperframes.json` is missing. Name `` from the PR in kebab-case, such as `acme-sdk-pr-1842`; never use the workspace name or a timestamp. -`npx hyperframes init "videos/" --non-interactive --skip-skills --example=blank` +`npx hyperframes init "videos/" --non-interactive --example=blank` — `init` checks the installed skills against the latest on GitHub and updates the global set if any are out of date. **Show sign-in status before the brief** — run `npx hyperframes auth status` and **relay its output verbatim (don't paraphrase or rewrite it).** It reports whether voice/BGM will use HeyGen or local engines and, when not signed in, how to sign in. **If not signed in, STOP and wait for the user to choose — sign in, or say "go"/"offline" to continue with local engines — before asking the brief or anything else.** Treat it as a real decision point, not a passing note; don't fold the choice into the brief question, and don't write keys into a per-repo `.env`. (In autonomous mode, note the status and continue offline.) See `../hyperframes-media` → Preflight for the canonical guidance. diff --git a/skills/product-launch-video/SKILL.md b/skills/product-launch-video/SKILL.md index d8aab42c5d..f9a5a74194 100644 --- a/skills/product-launch-video/SKILL.md +++ b/skills/product-launch-video/SKILL.md @@ -23,7 +23,7 @@ Goal: Lock the core video brief and create the HyperFrames project if needed. Initialize only if `hyperframes.json` is missing. Name `` from the brand or domain in kebab-case, such as `acme-promo`; never use workspace name or timestamp. -`npx hyperframes init "videos/" --non-interactive --skip-skills --example=blank` +`npx hyperframes init "videos/" --non-interactive --example=blank` — `init` checks the installed skills against the latest on GitHub and updates the global set if any are out of date. **Show sign-in status before the brief** — run `npx hyperframes auth status` and **relay its output verbatim (don't paraphrase or rewrite it).** It reports whether voice/BGM will use HeyGen or local engines and, when not signed in, how to sign in. **If not signed in, STOP and wait for the user to choose — sign in, or say "go"/"offline" to continue with local engines — before asking the brief or anything else.** Treat it as a real decision point, not a passing note; don't fold the choice into the brief question, and don't write keys into a per-repo `.env`. (In autonomous mode, note the status and continue offline.) See `../hyperframes-media` → Preflight for the canonical guidance. From 402fbfaef002bf72ad33caafddf007c5ee682088 Mon Sep 17 00:00:00 2001 From: Miao Yang Date: Sat, 27 Jun 2026 08:24:37 +0800 Subject: [PATCH 14/14] fix(cli): scope agent mirror to HyperFrames' own skills, not the whole store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mirrorGlobalSkills listed every */SKILL.md in ~/.claude/skills and fanned them out — but that store is shared, so a user's gstack / personal / company Claude skills would get symlinked (and, since linkOrCopy removes the target first, could overwrite a same-named skill) into Cursor / Codex / Goose / etc. Scope the mirror to HyperFrames' own skills via the upstream lock's source attribution — the same definition the prune already uses (skillsAttributedToSource) — never a directory listing. New hyperframesSkillNames() reads the global lock and returns only skills attributed to heygen-com/hyperframes; the mirror intersects that allow-list with what's in the store. Empty (no lock / nothing attributed) → mirror nothing, never everything. Also fixes the cosmetic "director(ies)" log typo (now singular/plural-aware) and extracts the fan-out into mirrorToInstalledAgents() to keep installAllSkills under the complexity gate. Regression: skillsMirror.test.ts asserts a foreign gstack skill in the store is neither mirrored out nor allowed to replace another agent's same-named skill; the skills-bench harness seeds ~/.claude/skills/gstack and asserts it never leaks to any agent. 1045 CLI tests + lint/types/fallow green. Addresses Magi's request-changes on #1753. Co-Authored-By: Claude Opus 4.8 (1M context) --- .fallowrc.jsonc | 5 ++ packages/cli/src/commands/skills.test.ts | 3 + packages/cli/src/commands/skills.ts | 35 ++++++++---- packages/cli/src/utils/skillsManifest.ts | 16 ++++++ packages/cli/src/utils/skillsMirror.test.ts | 61 ++++++++++++++++++--- packages/cli/src/utils/skillsMirror.ts | 17 ++++-- 6 files changed, 115 insertions(+), 22 deletions(-) diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 44e25277a0..89a2c79a8c 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -268,6 +268,11 @@ // Each case seeds a dir then asserts the resolved location/agent; collapsing // the shared seed/assert shape would obscure what each scope/host verifies. "packages/cli/src/utils/skillsManifest.test.ts", + // skills.test.ts: parallel prune cases (removed-in-global vs project, non-slug + // rejection, --source/--dir plumbing) share a mock-checkSkills → runSkillsUpdate + // → assert-remove-spawn shape; each verifies a distinct prune behavior, so + // extracting the shared scaffold would obscure what each case asserts. + "packages/cli/src/commands/skills.test.ts", ], }, "health": { diff --git a/packages/cli/src/commands/skills.test.ts b/packages/cli/src/commands/skills.test.ts index ef33119c1f..0ca3042c35 100644 --- a/packages/cli/src/commands/skills.test.ts +++ b/packages/cli/src/commands/skills.test.ts @@ -54,6 +54,9 @@ vi.mock("@clack/prompts", () => ({ // default returns nothing removed, and the prune test overrides per-call. vi.mock("../utils/skillsManifest.js", () => ({ checkSkills: vi.fn(async () => ({ skills: [] })), + // installAllSkills resolves the HyperFrames skill names (lock-attributed) to + // scope the mirror; pin it so these arg-shape tests don't read a real lock. + hyperframesSkillNames: vi.fn(() => ["hyperframes"]), })); // The install fans out to other agents via mirrorGlobalSkills, which touches diff --git a/packages/cli/src/commands/skills.ts b/packages/cli/src/commands/skills.ts index f89357fdc6..c74e4aff8c 100644 --- a/packages/cli/src/commands/skills.ts +++ b/packages/cli/src/commands/skills.ts @@ -6,6 +6,7 @@ import { buildNpxCommand } from "../utils/npxCommand.js"; import { withMeta } from "../utils/updateCheck.js"; import { checkSkills, + hyperframesSkillNames, SKILLS_CLI_LOCK_PATHS_VERIFIED_AT, type SkillDiff, type SkillsCheckResult, @@ -124,6 +125,28 @@ function runSkillsRemove(names: string[], opts: { global: boolean }): Promise 0) { + console.log( + c.dim(`Linked skills into ${n} other agent ${n === 1 ? "directory" : "directories"}.`), + ); + } + } catch { + // best-effort + } +} + export async function installAllSkills( opts: { cwd?: string; extraArgs?: string[]; strict?: boolean } = {}, ): Promise { @@ -149,17 +172,7 @@ export async function installAllSkills( } } - // Fan the global Claude store out to every other installed agent. No-op when - // the global store is absent (e.g. a custom install), so it's safe to run - // unconditionally after any install path. - try { - const { mirrored } = mirrorGlobalSkills(); - if (mirrored.length > 0) { - console.log(c.dim(`Linked skills into ${mirrored.length} other agent director(ies).`)); - } - } catch { - // best-effort: a mirror failure must not fail the install - } + mirrorToInstalledAgents(); } // ── check ──────────────────────────────────────────────────────────────────── diff --git a/packages/cli/src/utils/skillsManifest.ts b/packages/cli/src/utils/skillsManifest.ts index fa0e8afa98..f6a9dc18ee 100644 --- a/packages/cli/src/utils/skillsManifest.ts +++ b/packages/cli/src/utils/skillsManifest.ts @@ -406,6 +406,22 @@ function readSkillLock(path: string): SkillLock | null { } } +/** + * The skill names the upstream lock attributes to HyperFrames, for a scope. + * The mirror MUST scope by this — never by listing `~/.claude/skills`, which is + * shared across sources, so a directory listing would fan a user's gstack / + * personal / company skills out to every agent. Same source-attribution the + * prune uses. Empty when the lock is absent (we can't attribute → mirror none). + */ +export function hyperframesSkillNames(opts: { + scope: "project" | "global"; + cwd?: string; + home?: string; +}): string[] { + const lockPath = lockPathForScope(opts.scope, { cwd: opts.cwd, home: opts.home }); + return skillsAttributedToSource(readSkillLock(lockPath), DEFAULT_REPO_SLUG); +} + interface RemovedResult { removed: SkillDiff[]; /** The lock was absent at the expected path — removed-detection silently no-ops. */ diff --git a/packages/cli/src/utils/skillsMirror.test.ts b/packages/cli/src/utils/skillsMirror.test.ts index 9b2df7c80c..b98d02efc7 100644 --- a/packages/cli/src/utils/skillsMirror.test.ts +++ b/packages/cli/src/utils/skillsMirror.test.ts @@ -5,6 +5,7 @@ import { mkdirSync, mkdtempSync, readlinkSync, + readFileSync, realpathSync, rmSync, writeFileSync, @@ -26,7 +27,7 @@ function makeHome(): string { return home; } -/** Seed a real ~/.claude/skills store (the canonical global install). */ +/** Seed real skill bundles under ~/.claude/skills (the canonical global store). */ function seedStore(home: string, skills: string[]): void { for (const name of skills) { const dir = join(home, ".claude", "skills", name); @@ -47,7 +48,12 @@ afterEach(() => { describe("mirrorGlobalSkills", () => { it("no-ops when there is no global Claude store", () => { const home = makeHome(); - const result = mirrorGlobalSkills({ home, platform: "linux", env: ENV }); + const result = mirrorGlobalSkills({ + skills: ["hyperframes"], + home, + platform: "linux", + env: ENV, + }); expect(result.source).toBeNull(); expect(result.mirrored).toEqual([]); }); @@ -59,7 +65,12 @@ describe("mirrorGlobalSkills", () => { installMarker(home, ".config/goose"); // goose present (XDG base) // windsurf NOT installed (no ~/.codeium/windsurf) - const { mirrored } = mirrorGlobalSkills({ home, platform: "linux", env: ENV }); + const { mirrored } = mirrorGlobalSkills({ + skills: ["hyperframes", "hyperframes-core"], + home, + platform: "linux", + env: ENV, + }); const agents = mirrored.map((m) => m.agent); expect(agents).toContain("cursor"); expect(agents).toContain("goose"); @@ -77,6 +88,31 @@ describe("mirrorGlobalSkills", () => { ).toBe(true); }); + // The blocker Magi flagged: ~/.claude/skills is shared, so a user's gstack / + // personal / company skills live there too. The mirror must fan out ONLY + // HyperFrames' own skills (the lock-attributed allow-list), never everything + // in the store — and must not remove/replace a same-named skill already in + // another agent's dir. + it("only mirrors the allow-listed skills, never other sources' (gstack)", () => { + const home = makeHome(); + seedStore(home, ["hyperframes", "gstack"]); // gstack is a foreign skill in the store + installMarker(home, ".cursor"); + // cursor already has its OWN gstack skill from another source — must survive. + const foreign = join(home, ".cursor", "skills", "gstack"); + mkdirSync(foreign, { recursive: true }); + writeFileSync(join(foreign, "SKILL.md"), "# gstack (cursor's own, not ours)\n", "utf8"); + + mirrorGlobalSkills({ skills: ["hyperframes"], home, platform: "linux", env: ENV }); + + // our skill got linked + expect(lstatSync(join(home, ".cursor", "skills", "hyperframes")).isSymbolicLink()).toBe(true); + // gstack was NOT mirrored from the store... + expect(existsSync(join(home, ".claude", "skills", "gstack"))).toBe(true); // still in store + // ...and cursor's pre-existing gstack was neither replaced with a symlink nor removed + expect(lstatSync(foreign).isSymbolicLink()).toBe(false); + expect(readFileSync(join(foreign, "SKILL.md"), "utf8")).toContain("cursor's own"); + }); + it("honors XDG_CONFIG_HOME for config-based agents", () => { const home = makeHome(); const xdg = makeHome(); // a separate absolute XDG config root @@ -84,6 +120,7 @@ describe("mirrorGlobalSkills", () => { mkdirSync(join(xdg, "goose"), { recursive: true }); // goose marker under XDG const { mirrored } = mirrorGlobalSkills({ + skills: ["hyperframes"], home, platform: "linux", env: { XDG_CONFIG_HOME: xdg }, @@ -98,7 +135,7 @@ describe("mirrorGlobalSkills", () => { seedStore(home, ["hyperframes"]); installMarker(home, ".cursor"); - mirrorGlobalSkills({ home, platform: "win32", env: ENV }); + mirrorGlobalSkills({ skills: ["hyperframes"], home, platform: "win32", env: ENV }); const target = join(home, ".cursor", "skills", "hyperframes"); expect(lstatSync(target).isSymbolicLink()).toBe(false); expect(lstatSync(target).isDirectory()).toBe(true); @@ -110,7 +147,12 @@ describe("mirrorGlobalSkills", () => { seedStore(home, ["hyperframes"]); installMarker(home, ".agents"); // .agents present (the universal install creates it) - const { mirrored } = mirrorGlobalSkills({ home, platform: "linux", env: ENV }); + const { mirrored } = mirrorGlobalSkills({ + skills: ["hyperframes"], + home, + platform: "linux", + env: ENV, + }); expect(mirrored.map((m) => m.agent)).not.toContain("claude-code"); // the .agents-family agents (cline/dexto/…) map to .agents/skills and are skipped expect(mirrored.map((m) => m.agent)).not.toContain("cline"); @@ -123,9 +165,14 @@ describe("mirrorGlobalSkills", () => { seedStore(home, ["hyperframes"]); installMarker(home, ".cursor"); - mirrorGlobalSkills({ home, platform: "linux", env: ENV }); + mirrorGlobalSkills({ skills: ["hyperframes"], home, platform: "linux", env: ENV }); // second run must not throw and must leave a valid link - const { mirrored } = mirrorGlobalSkills({ home, platform: "linux", env: ENV }); + const { mirrored } = mirrorGlobalSkills({ + skills: ["hyperframes"], + home, + platform: "linux", + env: ENV, + }); expect(mirrored.map((m) => m.agent)).toContain("cursor"); const link = join(home, ".cursor", "skills", "hyperframes"); expect(realpathSync(link)).toBe(realpathSync(join(home, ".claude", "skills", "hyperframes"))); diff --git a/packages/cli/src/utils/skillsMirror.ts b/packages/cli/src/utils/skillsMirror.ts index 320b617d9e..e44cef472b 100644 --- a/packages/cli/src/utils/skillsMirror.ts +++ b/packages/cli/src/utils/skillsMirror.ts @@ -99,9 +99,12 @@ function mirrorInto( * dir. Best-effort and idempotent: a no-op when the store is absent, and per * skill failures (permissions, races) don't abort the rest. */ -export function mirrorGlobalSkills( - opts: { home?: string; platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv } = {}, -): MirrorResult { +export function mirrorGlobalSkills(opts: { + skills: readonly string[]; + home?: string; + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; +}): MirrorResult { const home = opts.home ?? homedir(); const platform = opts.platform ?? process.platform; const bases = resolveBases(home, opts.env ?? process.env); @@ -112,7 +115,13 @@ export function mirrorGlobalSkills( const universalStore = join(home, ".agents", "skills"); if (!existsSync(source)) return { source: null, mirrored: [] }; - const skills = listSkillDirs(source); + // Mirror ONLY HyperFrames' own skills (by name), NEVER everything in the + // store: ~/.claude/skills is shared, so a user's gstack / personal / company + // skills live there too and must not be fanned out to (or overwrite) other + // agents. `opts.skills` is the lock-attributed HyperFrames set (see + // hyperframesSkillNames). + const allowed = new Set(opts.skills); + const skills = listSkillDirs(source).filter((name) => allowed.has(name)); if (skills.length === 0) return { source, mirrored: [] }; const mirrored: { agent: string; dir: string }[] = [];