189 lines
6.7 KiB
JavaScript
Executable File
189 lines
6.7 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
// Maintainer tool: refresh the vendored HyperFrames skill in
|
|
// `skills/hyperframes/` from the upstream `heygen-com/hyperframes`
|
|
// publication.
|
|
//
|
|
// Why vendor instead of relying on `npx skills add`? Coverage. The
|
|
// `skills` CLI only symlinks into a known list of agent dirs (Claude
|
|
// Code, Codex, Cursor, Trae, Factory, etc.) — but OD supports a wider
|
|
// agent set (Hermes, Kimi, Qwen, BYOK CLIs that aren't on `skills`'s
|
|
// allowlist). By vendoring under `skills/hyperframes/` and routing the
|
|
// content through OD's own skill scanner (which injects the SKILL.md
|
|
// body into the system prompt), every OD-supported agent — including
|
|
// BYOK setups — gets HyperFrames guidance uniformly.
|
|
//
|
|
// This script does NOT auto-merge. Reasons:
|
|
// 1. We add an OD-specific frontmatter shim (od.mode/surface/preview/…)
|
|
// and an "Open Design integration" section near the top of
|
|
// SKILL.md. An auto-merge would either drop the shim (breaking OD
|
|
// classification) or duplicate it on every sync.
|
|
// 2. Upstream may rename references, restructure subdirs, or change
|
|
// `triggers`. A human eye catches that in one read.
|
|
//
|
|
// What it DOES do:
|
|
// - Run `npx skills add heygen-com/hyperframes -y` into a temp dir
|
|
// - Diff the upstream `hyperframes/` subtree against the vendored copy
|
|
// - Print a summary of changed files (added / modified / removed)
|
|
// - Exit non-zero when there's drift, so you notice
|
|
//
|
|
// Usage:
|
|
// node scripts/sync-hyperframes-skill.mjs # show diff
|
|
// node scripts/sync-hyperframes-skill.mjs --apply # NOT IMPLEMENTED;
|
|
// always reviewed
|
|
// by hand
|
|
//
|
|
// To actually apply: copy the upstream files in by hand, re-add the OD
|
|
// frontmatter shim and the "Open Design integration" section.
|
|
|
|
import { execFile as execFileCb } from 'node:child_process';
|
|
import { mkdtemp, readdir, readFile, rm, stat } from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { promisify } from 'node:util';
|
|
|
|
const execFile = promisify(execFileCb);
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
const REPO_ROOT = path.resolve(__dirname, '..');
|
|
const VENDORED = path.join(REPO_ROOT, 'skills', 'hyperframes');
|
|
|
|
async function main() {
|
|
const tmpRoot = await mkdtemp(path.join(os.tmpdir(), 'od-hf-sync-'));
|
|
try {
|
|
console.log(`[sync] installing upstream into ${tmpRoot}`);
|
|
// `-y` auto-accepts the install confirmation prompt; we install just
|
|
// the `hyperframes` sub-skill (the main one we vendor) to keep the
|
|
// probe focused.
|
|
await execFile(
|
|
'npx',
|
|
['-y', 'skills', 'add', 'heygen-com/hyperframes', '-s', 'hyperframes', '-y'],
|
|
{ cwd: tmpRoot, timeout: 90_000, maxBuffer: 16 * 1024 * 1024 },
|
|
);
|
|
|
|
const upstream = path.join(tmpRoot, '.agents', 'skills', 'hyperframes');
|
|
if (!(await exists(upstream))) {
|
|
console.error(
|
|
`[sync] upstream not found at expected path: ${upstream}\n` +
|
|
' The skills CLI may have changed where it installs to.',
|
|
);
|
|
process.exit(2);
|
|
}
|
|
|
|
const upstreamFiles = await collect(upstream);
|
|
const vendoredFiles = await collect(VENDORED);
|
|
|
|
const upstreamMap = new Map(upstreamFiles.map((f) => [f.rel, f]));
|
|
const vendoredMap = new Map(vendoredFiles.map((f) => [f.rel, f]));
|
|
|
|
const added = [];
|
|
const modified = [];
|
|
const removed = [];
|
|
|
|
for (const [rel, up] of upstreamMap) {
|
|
const ven = vendoredMap.get(rel);
|
|
if (!ven) {
|
|
added.push(rel);
|
|
continue;
|
|
}
|
|
// SKILL.md gets local edits (frontmatter shim + OD integration
|
|
// section), so a byte-for-byte compare always reports drift.
|
|
// Compare only the body AFTER our injected section by matching
|
|
// upstream's first H2 heading. Imperfect but useful as a hint.
|
|
if (rel === 'SKILL.md') {
|
|
const upstreamMarker = '\n## Approach\n';
|
|
const upBody = up.text.includes(upstreamMarker)
|
|
? up.text.slice(up.text.indexOf(upstreamMarker))
|
|
: up.text;
|
|
const venBody = ven.text.includes(upstreamMarker)
|
|
? ven.text.slice(ven.text.indexOf(upstreamMarker))
|
|
: ven.text;
|
|
if (upBody !== venBody) modified.push(`${rel} (body after ## Approach)`);
|
|
continue;
|
|
}
|
|
if (up.text !== ven.text) modified.push(rel);
|
|
}
|
|
for (const rel of vendoredMap.keys()) {
|
|
if (!upstreamMap.has(rel)) removed.push(rel);
|
|
}
|
|
|
|
if (added.length === 0 && modified.length === 0 && removed.length === 0) {
|
|
console.log('[sync] vendored copy matches upstream — nothing to do.');
|
|
process.exit(0);
|
|
}
|
|
|
|
console.log('\n[sync] DRIFT DETECTED — review and update by hand.\n');
|
|
if (added.length) {
|
|
console.log(` Added (in upstream, missing locally):`);
|
|
for (const r of added) console.log(` + ${r}`);
|
|
}
|
|
if (modified.length) {
|
|
console.log(` Modified upstream:`);
|
|
for (const r of modified) console.log(` ~ ${r}`);
|
|
}
|
|
if (removed.length) {
|
|
console.log(` Removed upstream (still vendored locally):`);
|
|
for (const r of removed) console.log(` - ${r}`);
|
|
}
|
|
console.log(
|
|
'\n Upstream copy lives at:\n' +
|
|
` ${upstream}\n` +
|
|
' (script does not auto-apply — re-run with diff tools, then\n' +
|
|
' commit the merge by hand. Re-add OD frontmatter shim if it\n' +
|
|
' gets dropped during the merge.)',
|
|
);
|
|
process.exit(1);
|
|
} finally {
|
|
// Best-effort cleanup. Leaves the upstream dir behind if the user
|
|
// wants to inspect it in the failure path.
|
|
if (process.env.OD_KEEP_HF_SYNC_TMP) {
|
|
console.log(`[sync] OD_KEEP_HF_SYNC_TMP set — leaving ${tmpRoot}`);
|
|
} else {
|
|
await rm(tmpRoot, { recursive: true, force: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
async function exists(p) {
|
|
try {
|
|
await stat(p);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function collect(root) {
|
|
const out = [];
|
|
await walk(root, '', out);
|
|
return out;
|
|
}
|
|
|
|
async function walk(root, rel, out) {
|
|
let entries;
|
|
try {
|
|
entries = await readdir(path.join(root, rel), { withFileTypes: true });
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const e of entries) {
|
|
const childRel = rel ? `${rel}/${e.name}` : e.name;
|
|
if (e.isDirectory()) {
|
|
await walk(root, childRel, out);
|
|
continue;
|
|
}
|
|
if (!e.isFile()) continue;
|
|
const text = await readFile(path.join(root, childRel), 'utf8').catch(
|
|
() => null,
|
|
);
|
|
if (text == null) continue;
|
|
out.push({ rel: childRel, text });
|
|
}
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error('[sync] failed:', err && err.message ? err.message : err);
|
|
process.exit(2);
|
|
});
|