Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ jobs:
- ".github/workflows/ci.yml"
skills:
- "skills/**"
- "skills-manifest.json"
- "package.json"
- ".github/workflows/ci.yml"

Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions lefthook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
86 changes: 86 additions & 0 deletions packages/cli/scripts/gen-skills-manifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Generate (or verify) skills-manifest.json (repo root) — the published
// "latest" fingerprint of the HyperFrames skill bundle.
//
// bun run --cwd packages/cli gen:skills-manifest # write/update
// bun run --cwd packages/cli gen:skills-manifest --check # verify only (CI)
//
// The manifest is just per-skill content hashes (no version / timestamp), so it
// is fully deterministic: same skill content ⇒ byte-identical manifest. `--check`
// exits non-zero when the committed manifest doesn't match current skill content.

import { readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { buildManifest, MANIFEST_FILE, type SkillsManifest } from "../src/utils/skillsManifest.js";

const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(here, "..", "..", ".."); // packages/cli/scripts → repo root
const skillsRoot = join(repoRoot, "skills");
const outPath = join(repoRoot, MANIFEST_FILE);
const isCheck = process.argv.includes("--check");

/** Stable signature of the content hashes (order-independent). */
function signature(skills: SkillsManifest["skills"]): string {
return Object.keys(skills)
.sort()
.map((name) => `${name}:${skills[name]!.hash}`)
.join("\n");
}

function driftLine(name: string, oldHash?: string, newHash?: string): string | null {
if (oldHash === newHash) return null;
if (!oldHash) return ` + ${name} (new)`;
if (!newHash) return ` - ${name} (removed)`;
return ` ~ ${name} (${oldHash} → ${newHash})`;
}

function hashOf(skills: SkillsManifest["skills"], name: string): string | undefined {
return skills[name]?.hash;
}

function reportDrift(fresh: SkillsManifest, committed: SkillsManifest | null): void {
const oldSkills = committed === null ? {} : committed.skills;
const names = [...new Set([...Object.keys(fresh.skills), ...Object.keys(oldSkills)])].sort();
for (const name of names) {
const line = driftLine(name, hashOf(oldSkills, name), hashOf(fresh.skills, name));
if (line) console.log(line);
}
}

const fresh = buildManifest(skillsRoot, { source: "heygen-com/hyperframes" });

// Read the committed manifest directly (no existsSync precheck) so there's no
// check-then-write race on outPath — a missing or unreadable file just means
// "no committed manifest yet", and we write a fresh one below.
let committed: SkillsManifest | null = null;
try {
committed = JSON.parse(readFileSync(outPath, "utf8")) as SkillsManifest;
} catch {
committed = null;
}

const inSync = committed !== null && signature(committed.skills) === signature(fresh.skills);
const count = Object.keys(fresh.skills).length;

if (isCheck) {
if (inSync) {
console.log(`✓ ${MANIFEST_FILE} is in sync (${count} skills)`);
process.exit(0);
}
console.error(`✗ ${MANIFEST_FILE} is out of date — a skill changed without regenerating it.`);
reportDrift(fresh, committed);
console.error(
`\nRun: bun run --cwd packages/cli gen:skills-manifest (then commit ${MANIFEST_FILE})`,
);
process.exit(1);
}

// Write mode — churn-free: only rewrite when a content hash actually changed.
if (inSync) {
console.log(`${MANIFEST_FILE} already in sync — no change (${count} skills)`);
process.exit(0);
}

writeFileSync(outPath, JSON.stringify(fresh, null, 2) + "\n");
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
console.log(`Wrote ${outPath} (${count} skills)`);
reportDrift(fresh, committed);
13 changes: 12 additions & 1 deletion packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand All @@ -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.
Expand Down
60 changes: 40 additions & 20 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,41 @@ async function scaffoldProject(
}
}

/**
* Ensure the project's AI coding skills are present and current. Checks the
* installed skills against the latest published on GitHub and only (re)installs
* when something is outdated or missing — so re-running `init` on an already
* up-to-date project is a no-op. Best-effort: if the version check can't reach
* GitHub, it installs anyway. The install itself (`installAllSkills`) pulls the
* full set straight from the GitHub repo.
*/
async function ensureSkillsCurrent(destDir: string): Promise<void> {
const { installAllSkills } = await import("./skills.js");
const { checkSkills } = await import("../utils/skillsManifest.js");
// --all pulls every skill (incl. ones not yet installed); --yes keeps it
// non-interactive. When Claude Code is driving, target its native dir so
// skills land in .claude/skills/.
const extraArgs = process.env["CLAUDECODE"]
? ["--all", "--agent", "claude-code", "--yes"]
: ["--all", "--yes"];

console.log();
console.log(c.bold("Checking AI coding skills against GitHub..."));
let needsInstall = true;
try {
const result = await checkSkills({ cwd: destDir });
needsInstall = result.updateAvailable;
} catch {
// Couldn't reach GitHub (offline, rate-limited) — install anyway.
}

if (needsInstall) {
await installAllSkills({ cwd: destDir, extraArgs });
} else {
console.log(c.success("AI coding skills are already up to date."));
}
}

// ---------------------------------------------------------------------------
// Exported command
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -802,20 +837,15 @@ export default defineCommand({
}

if (!skipSkills) {
const { installAllSkills } = await import("./skills.js");
// --yes keeps it non-interactive. When Claude Code is driving
// (CLAUDECODE env var), target its native dir so skills land in
// .claude/skills/ instead of only .agents/skills/.
const args = process.env["CLAUDECODE"] ? ["--agent", "claude-code", "--yes"] : ["--yes"];
await installAllSkills({ cwd: destDir, extraArgs: args });
await ensureSkillsCurrent(destDir);
}

console.log();
console.log("Get started:");
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.`,
Expand Down Expand Up @@ -1023,20 +1053,10 @@ export default defineCommand({
const files = readdirSync(destDir);
clack.note(files.map((f) => c.accent(f)).join("\n"), c.success(`Created ${name}/`));

// Offer to install AI coding skills
// Check skills against GitHub and (re)install only if outdated or missing —
// init is the one place the full set is pulled. Opt out with --skip-skills.
if (!skipSkills) {
const installSkills = await clack.confirm({
message: "Install AI coding skills? (for Claude Code, Cursor, Codex, etc.)",
initialValue: true,
});
if (clack.isCancel(installSkills)) {
clack.cancel("Setup cancelled.");
process.exit(0);
}
if (installSkills) {
const { installAllSkills } = await import("./skills.js");
await installAllSkills({ cwd: destDir });
}
await ensureSkillsCurrent(destDir);
}

// Auto-launch studio preview
Expand Down
77 changes: 72 additions & 5 deletions packages/cli/src/commands/skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,17 @@ type ExecCall = {
};

const originalPlatform = process.platform;
const state: { execCalls: ExecCall[]; spawnCalls: SpawnCall[] } = {
const state: { execCalls: ExecCall[]; spawnCalls: SpawnCall[]; spawnExitCode: number } = {
execCalls: [],
spawnCalls: [],
spawnExitCode: 0,
};

vi.mock("node:child_process", () => ({
// `skillsManifest.ts` does `promisify(execFile)` at module load. These tests
// never invoke it (no skills-check path runs here), so a bare stub is enough
// to satisfy the named import — we deliberately don't spread the real module.
execFile: vi.fn(),
execFileSync: vi.fn((command: string, args: ReadonlyArray<string>) => {
state.execCalls.push({ command, args });
return Buffer.from("11.0.0");
Expand All @@ -31,7 +36,7 @@ vi.mock("node:child_process", () => ({
(command: string, args: ReadonlyArray<string>, opts?: { env?: NodeJS.ProcessEnv }) => {
state.spawnCalls.push({ command, args, env: opts?.env });
const fake = new EventEmitter();
setImmediate(() => fake.emit("close", 0, null));
setImmediate(() => fake.emit("close", state.spawnExitCode, null));
return fake;
},
),
Expand All @@ -54,6 +59,7 @@ describe("hyperframes skills", () => {
beforeEach(() => {
state.execCalls = [];
state.spawnCalls = [];
state.spawnExitCode = 0;
vi.resetModules();
});

Expand All @@ -77,13 +83,32 @@ describe("hyperframes skills", () => {
});

it.each([
["linux", "npx", ["--version"], ["skills", "add", "heygen-com/hyperframes", "--all"]],
["darwin", "npx", ["--version"], ["skills", "add", "heygen-com/hyperframes", "--all"]],
[
"linux",
"npx",
["--version"],
["skills", "add", "https://github.com/heygen-com/hyperframes", "--all"],
],
[
"darwin",
"npx",
["--version"],
["skills", "add", "https://github.com/heygen-com/hyperframes", "--all"],
],
[
"win32",
"cmd.exe",
["/d", "/s", "/c", "npx.cmd", "--version"],
["/d", "/s", "/c", "npx.cmd", "skills", "add", "heygen-com/hyperframes", "--all"],
[
"/d",
"/s",
"/c",
"npx.cmd",
"skills",
"add",
"https://github.com/heygen-com/hyperframes",
"--all",
],
],
] as const)(
"uses %s-compatible npx command for preflight and skills install",
Expand All @@ -99,4 +124,46 @@ describe("hyperframes skills", () => {
expect(state.spawnCalls[0]?.args).toEqual(expectedInstallArgs);
},
);

// The `skills check || skills update` recovery contract requires update to
// fail loudly — a swallowed install failure would let the `||` chain pass
// while nothing changed.
it("skills update exits non-zero when the install fails", async () => {
setPlatform("linux");
state.spawnExitCode = 1; // simulate `skills add` exiting non-zero

const prevExit = process.exitCode;
process.exitCode = 0;
try {
const { default: skillsCmd } = await import("./skills.js");
const subs = skillsCmd.subCommands as unknown as Record<string, typeof skillsCmd>;
const updateCmd = subs.update;
expect(updateCmd).toBeDefined();
await updateCmd!.run?.({ args: {}, rawArgs: [], cmd: updateCmd } as never);
expect(process.exitCode).toBe(1);
} finally {
process.exitCode = prevExit;
}
});

it("skills update exits zero on a successful install", async () => {
setPlatform("linux");
state.spawnExitCode = 0;

const prevExit = process.exitCode;
process.exitCode = 0;
try {
const { default: skillsCmd } = await import("./skills.js");
const subs = skillsCmd.subCommands as unknown as Record<string, typeof skillsCmd>;
const updateCmd = subs.update;
expect(updateCmd).toBeDefined();
await updateCmd!.run?.({ args: {}, rawArgs: [], cmd: updateCmd } as never);
expect(process.exitCode).toBe(0);
// pulls the full set straight from GitHub
expect(state.spawnCalls[0]?.args).toContain("https://github.com/heygen-com/hyperframes");
expect(state.spawnCalls[0]?.args).toContain("--all");
} finally {
process.exitCode = prevExit;
}
});
});
Loading
Loading