File size: 7,029 Bytes
108e227 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 | #!/usr/bin/env node
// Runs website/scripts/extract-skills.py and generate-llms-txt.py before
// docusaurus build/start so that:
// - website/static/api/skills.json (lazy-fetched by src/pages/skills/index.tsx)
// - website/static/api/skills-meta.json (sidecar metadata for the Skills Hub)
// - website/static/llms.txt (agent-friendly short docs index)
// - website/static/llms-full.txt (full docs concat for LLM context)
// all exist without contributors remembering to run Python scripts manually.
// CI workflows still run the extraction explicitly, which is a no-op duplicate
// but matches their historical behaviour.
//
// We also try to pull a fresh copy of skills-index.json (the unified
// multi-source catalog) from the live docs site if it's not already on disk.
// That way local `npm run build` doesn't have to wait on
// scripts/build_skills_index.py crawling every skill source β which takes
// several minutes and burns GitHub API quota β but still gets the same
// 2000+ external skills the deployed site has.
//
// If python3 or its deps (pyyaml) aren't available on the local machine, we
// fall back to writing an empty skills.json so `npm run build` still
// succeeds β the Skills Hub page just shows an empty state, and llms.txt
// generation is skipped. CI always has the deps installed, so production
// deploys get real data.
import { spawnSync } from "node:child_process";
import { mkdirSync, writeFileSync, existsSync, statSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const websiteDir = resolve(scriptDir, "..");
const extractScript = join(scriptDir, "extract-skills.py");
const llmsScript = join(scriptDir, "generate-llms-txt.py");
const cronBlueprintsScript = join(scriptDir, "extract-automation-blueprints.py");
const pluginsScript = join(scriptDir, "extract-plugins.py");
const pluginStarsScript = join(scriptDir, "fetch-plugin-stars.py");
const outputFile = join(websiteDir, "static", "api", "skills.json");
const pluginsOutputFile = join(websiteDir, "static", "api", "plugins.json");
const pluginsMetaOutputFile = join(websiteDir, "static", "api", "plugins-meta.json");
const unifiedIndexFile = join(websiteDir, "static", "api", "skills-index.json");
const UNIFIED_INDEX_URL =
"https://hermes-agent.nousresearch.com/docs/api/skills-index.json";
const UNIFIED_INDEX_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h
function writeEmptyFallback(reason) {
mkdirSync(dirname(outputFile), { recursive: true });
writeFileSync(outputFile, "[]\n");
console.warn(
`[prebuild] extract-skills.py skipped (${reason}); wrote empty skills.json. ` +
`Install python3 + pyyaml locally for a populated Skills Hub page.`,
);
}
function runPython(script, label) {
if (!existsSync(script)) {
console.warn(`[prebuild] ${label} skipped (script missing)`);
return false;
}
const r = spawnSync("python3", [script], { stdio: "inherit", cwd: websiteDir });
if (r.error && r.error.code === "ENOENT") {
console.warn(`[prebuild] ${label} skipped (python3 not found)`);
return false;
}
if (r.status !== 0) {
console.warn(`[prebuild] ${label} exited with status ${r.status}`);
return false;
}
return true;
}
async function ensureUnifiedIndex() {
// If we have a recent copy on disk, trust it.
if (existsSync(unifiedIndexFile)) {
try {
const age = Date.now() - statSync(unifiedIndexFile).mtimeMs;
if (age < UNIFIED_INDEX_MAX_AGE_MS) {
return true;
}
console.log(
`[prebuild] skills-index.json is ${(age / 3600000).toFixed(1)}h old; ` +
`refreshing from ${UNIFIED_INDEX_URL}`,
);
} catch {
// fall through to re-fetch
}
}
try {
const resp = await fetch(UNIFIED_INDEX_URL, {
headers: { accept: "application/json" },
});
if (!resp.ok) {
console.warn(
`[prebuild] skills-index.json fetch returned HTTP ${resp.status}; ` +
`using local copy if any`,
);
return existsSync(unifiedIndexFile);
}
const text = await resp.text();
// Sanity check: must be valid JSON with a skills array
try {
const parsed = JSON.parse(text);
if (!parsed || !Array.isArray(parsed.skills)) {
console.warn(
"[prebuild] skills-index.json from live site has no skills array; ignoring",
);
return existsSync(unifiedIndexFile);
}
} catch (e) {
console.warn(`[prebuild] skills-index.json from live site is not valid JSON: ${e}`);
return existsSync(unifiedIndexFile);
}
mkdirSync(dirname(unifiedIndexFile), { recursive: true });
writeFileSync(unifiedIndexFile, text);
console.log(
`[prebuild] downloaded skills-index.json from ${UNIFIED_INDEX_URL} ` +
`(${(text.length / 1024).toFixed(0)} KB)`,
);
return true;
} catch (e) {
console.warn(`[prebuild] skills-index.json fetch failed: ${e}`);
return existsSync(unifiedIndexFile);
}
}
// 0) Pull unified index if we don't have a fresh one.
await ensureUnifiedIndex();
// 1) skills.json β required for the Skills Hub page.
if (!existsSync(extractScript)) {
writeEmptyFallback("extract script missing");
} else {
const r = spawnSync("python3", [extractScript], {
stdio: "inherit",
cwd: websiteDir,
});
if (r.error && r.error.code === "ENOENT") {
writeEmptyFallback("python3 not found");
} else if (r.status !== 0) {
writeEmptyFallback(`extract-skills.py exited with status ${r.status}`);
}
}
// 2) llms.txt + llms-full.txt β agent-friendly docs entrypoints. Non-fatal.
runPython(llmsScript, "generate-llms-txt.py");
// 3) automation-blueprints-index.json β Automation Blueprints catalog page. Non-fatal; the page
// renders an empty state if the generator can't run.
runPython(cronBlueprintsScript, "extract-automation-blueprints.py");
// 4a) plugin-stars.json β GitHub star counts for catalog ranking. Reuses the live
// site's daily cache (one CDN GET); only probes the API when that is >24h old,
// and never fails the build (no token / offline β whatever is cached or nothing).
runPython(pluginStarsScript, "fetch-plugin-stars.py");
// 4) plugins.json + plugins-meta.json β Plugin Catalog page. The script itself
// degrades gracefully (empty catalog, exit 0) when plugin-catalog/ is absent;
// if python3 is missing entirely, write the same empty fallback so the page
// renders its "just getting started" state instead of a fetch error.
if (!runPython(pluginsScript, "extract-plugins.py")) {
mkdirSync(dirname(pluginsOutputFile), { recursive: true });
writeFileSync(pluginsOutputFile, "[]\n");
writeFileSync(
pluginsMetaOutputFile,
JSON.stringify({
generatedAt: new Date().toISOString(),
total: 0,
byTier: { official: 0, community: 0 },
removedCount: 0,
}) + "\n",
);
console.warn("[prebuild] wrote empty plugins.json fallback");
}
|