src / mcp / skillSync.ts
src / mcp / skillSync.ts
import fs from "node:fs";
import path from "node:path";
import { getProjectRoot } from "../core-bundle.mjs";
import type { McpResultPreset } from "./core-bundle.mjs";
import { sha256File, installedSkillRoot, type SkillCheckName } from "./skillStatus.js";
const GITHUB_API_TIMEOUT_MS = 10_000;
const GITHUB_OWNER = "ceveyne";
interface GithubContentEntry {
name: string;
path: string;
type: "file" | "dir";
download_url?: string | null;
}
async function fetchJson(url: string): Promise<GithubContentEntry[]> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), GITHUB_API_TIMEOUT_MS);
try {
const res = await fetch(url, { signal: controller.signal, headers: { Accept: "application/vnd.github+json" } });
if (!res.ok) throw new Error(`GitHub API ${res.status} for ${url}`);
return (await res.json()) as GithubContentEntry[];
} finally {
clearTimeout(timer);
}
}
async function fetchText(url: string): Promise<string> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), GITHUB_API_TIMEOUT_MS);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) throw new Error(`GitHub raw ${res.status} for ${url}`);
return await res.text();
} finally {
clearTimeout(timer);
}
}
/** This plugin's own package.json `version` string, used as an opaque "which build does the
* local skill cache belong to" marker — deliberately NOT "latest GitHub commit": the local cache
* only needs refreshing when THIS plugin build's expected skill content could have changed. */
function getPluginRevision(projectRoot: string): string | null {
try {
const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf-8"));
return typeof pkg.version === "string" ? pkg.version : null;
} catch {
return null;
}
}
/**
* Recursively fetches EVERY file from this skill's public GitHub mirror (owner/<skillName>-skill)
* as-is — all SKILL.*.md variants, all subfolders except install/ — for mirroring into the local
* plugin-directory cache (skills/<skillName>/). Unlike the old install-target fetch, this does
* NOT resolve a variant to plain SKILL.md; the local cache keeps every variant, same as the repo
* itself. Returns null on any fetch failure so the caller can fail open without touching disk.
*/
async function collectMirrorFiles(skillName: SkillCheckName): Promise<Map<string, string> | null> {
const files = new Map<string, string>();
async function walk(apiPath: string, relPrefix: string): Promise<void> {
const entries = await fetchJson(`https://api.github.com/repos/${GITHUB_OWNER}/${skillName}-skill/contents/${apiPath}?ref=main`);
for (const entry of entries) {
if (entry.type === "dir") {
if (entry.name === "install") continue;
await walk(entry.path, path.join(relPrefix, entry.name));
continue;
}
if (!entry.download_url) continue;
files.set(path.join(relPrefix, entry.name), await fetchText(entry.download_url));
}
}
try {
await walk("", "");
return files;
} catch {
return null;
}
}
/**
* Step 1 (see tool-description-skill-concept.md "local cache first"): keeps the plugin's own
* `skills/<skillName>/` directory current — the same place this content would live if the Hub had
* no file-count limit. Only hits GitHub when `.skill-revision` doesn't match this plugin build's
* own package.json version (never "is GitHub's HEAD newer" — that would poll on every session).
* Never throws; "failed" means the cache could not be confirmed/refreshed (stays whatever it was).
*/
export async function ensureLocalSkillCache(skillName: SkillCheckName, options?: { projectRoot?: string }): Promise<"current" | "refreshed" | "failed"> {
const projectRoot = options?.projectRoot ?? getProjectRoot();
const skillDir = path.join(projectRoot, "skills", skillName);
const revisionMarkerPath = path.join(skillDir, ".skill-revision");
const pluginRevision = getPluginRevision(projectRoot);
const cachedRevision = fs.existsSync(revisionMarkerPath) ? fs.readFileSync(revisionMarkerPath, "utf-8").trim() : null;
if (pluginRevision !== null && cachedRevision === pluginRevision) return "current";
const files = await collectMirrorFiles(skillName);
if (!files) return "failed";
try {
fs.mkdirSync(skillDir, { recursive: true });
for (const [relPath, content] of files) {
const fullPath = path.join(skillDir, relPath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content, "utf-8");
}
if (pluginRevision !== null) fs.writeFileSync(revisionMarkerPath, pluginRevision, "utf-8");
return "refreshed";
} catch {
return "failed";
}
}
function isSkillVariantFile(name: string): boolean {
return /^SKILL(\.[^.]+)?\.md$/i.test(name);
}
/**
* Step 2: propagates the (now-fresh) local cache's resolved SKILL.<preset>.md to the client's
* install target, writing `.synced-hash` so a future `checkSkillStatus()` can tell "stale because
* we have a newer version" apart from "stale because the user edited their copy" — the latter is
* never overwritten by this function; the caller must not invoke it when `userModified` is true.
*/
export function syncSkillToInstallTarget(skillName: SkillCheckName, preset: McpResultPreset, options?: { projectRoot?: string; homeDir?: string }): "synced" | "failed" {
const projectRoot = options?.projectRoot ?? getProjectRoot();
const skillDir = path.join(projectRoot, "skills", skillName);
const canonicalSkillPath = path.join(skillDir, `SKILL.${preset.name}.md`);
if (!fs.existsSync(canonicalSkillPath)) return "failed";
const targetDir = installedSkillRoot(preset, options?.homeDir, skillName);
try {
fs.mkdirSync(targetDir, { recursive: true });
fs.copyFileSync(canonicalSkillPath, path.join(targetDir, "SKILL.md"));
for (const entry of fs.readdirSync(skillDir, { withFileTypes: true })) {
if (entry.name === "install" || isSkillVariantFile(entry.name) || entry.name === ".skill-revision") continue;
const destPath = path.join(targetDir, entry.name);
if (entry.isDirectory()) {
fs.rmSync(destPath, { recursive: true, force: true });
fs.cpSync(path.join(skillDir, entry.name), destPath, { recursive: true });
}
}
fs.writeFileSync(path.join(targetDir, ".synced-hash"), sha256File(canonicalSkillPath), "utf-8");
return "synced";
} catch {
return "failed";
}
}
import fs from "node:fs";
import path from "node:path";
import { getProjectRoot } from "../core-bundle.mjs";
import type { McpResultPreset } from "./core-bundle.mjs";
import { sha256File, installedSkillRoot, type SkillCheckName } from "./skillStatus.js";
const GITHUB_API_TIMEOUT_MS = 10_000;
const GITHUB_OWNER = "ceveyne";
interface GithubContentEntry {
name: string;
path: string;
type: "file" | "dir";
download_url?: string | null;
}
async function fetchJson(url: string): Promise<GithubContentEntry[]> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), GITHUB_API_TIMEOUT_MS);
try {
const res = await fetch(url, { signal: controller.signal, headers: { Accept: "application/vnd.github+json" } });
if (!res.ok) throw new Error(`GitHub API ${res.status} for ${url}`);
return (await res.json()) as GithubContentEntry[];
} finally {
clearTimeout(timer);
}
}
async function fetchText(url: string): Promise<string> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), GITHUB_API_TIMEOUT_MS);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) throw new Error(`GitHub raw ${res.status} for ${url}`);
return await res.text();
} finally {
clearTimeout(timer);
}
}
/** This plugin's own package.json `version` string, used as an opaque "which build does the
* local skill cache belong to" marker — deliberately NOT "latest GitHub commit": the local cache
* only needs refreshing when THIS plugin build's expected skill content could have changed. */
function getPluginRevision(projectRoot: string): string | null {
try {
const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf-8"));
return typeof pkg.version === "string" ? pkg.version : null;
} catch {
return null;
}
}
/**
* Recursively fetches EVERY file from this skill's public GitHub mirror (owner/<skillName>-skill)
* as-is — all SKILL.*.md variants, all subfolders except install/ — for mirroring into the local
* plugin-directory cache (skills/<skillName>/). Unlike the old install-target fetch, this does
* NOT resolve a variant to plain SKILL.md; the local cache keeps every variant, same as the repo
* itself. Returns null on any fetch failure so the caller can fail open without touching disk.
*/
async function collectMirrorFiles(skillName: SkillCheckName): Promise<Map<string, string> | null> {
const files = new Map<string, string>();
async function walk(apiPath: string, relPrefix: string): Promise<void> {
const entries = await fetchJson(`https://api.github.com/repos/${GITHUB_OWNER}/${skillName}-skill/contents/${apiPath}?ref=main`);
for (const entry of entries) {
if (entry.type === "dir") {
if (entry.name === "install") continue;
await walk(entry.path, path.join(relPrefix, entry.name));
continue;
}
if (!entry.download_url) continue;
files.set(path.join(relPrefix, entry.name), await fetchText(entry.download_url));
}
}
try {
await walk("", "");
return files;
} catch {
return null;
}
}
/**
* Step 1 (see tool-description-skill-concept.md "local cache first"): keeps the plugin's own
* `skills/<skillName>/` directory current — the same place this content would live if the Hub had
* no file-count limit. Only hits GitHub when `.skill-revision` doesn't match this plugin build's
* own package.json version (never "is GitHub's HEAD newer" — that would poll on every session).
* Never throws; "failed" means the cache could not be confirmed/refreshed (stays whatever it was).
*/
export async function ensureLocalSkillCache(skillName: SkillCheckName, options?: { projectRoot?: string }): Promise<"current" | "refreshed" | "failed"> {
const projectRoot = options?.projectRoot ?? getProjectRoot();
const skillDir = path.join(projectRoot, "skills", skillName);
const revisionMarkerPath = path.join(skillDir, ".skill-revision");
const pluginRevision = getPluginRevision(projectRoot);
const cachedRevision = fs.existsSync(revisionMarkerPath) ? fs.readFileSync(revisionMarkerPath, "utf-8").trim() : null;
if (pluginRevision !== null && cachedRevision === pluginRevision) return "current";
const files = await collectMirrorFiles(skillName);
if (!files) return "failed";
try {
fs.mkdirSync(skillDir, { recursive: true });
for (const [relPath, content] of files) {
const fullPath = path.join(skillDir, relPath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content, "utf-8");
}
if (pluginRevision !== null) fs.writeFileSync(revisionMarkerPath, pluginRevision, "utf-8");
return "refreshed";
} catch {
return "failed";
}
}
function isSkillVariantFile(name: string): boolean {
return /^SKILL(\.[^.]+)?\.md$/i.test(name);
}
/**
* Step 2: propagates the (now-fresh) local cache's resolved SKILL.<preset>.md to the client's
* install target, writing `.synced-hash` so a future `checkSkillStatus()` can tell "stale because
* we have a newer version" apart from "stale because the user edited their copy" — the latter is
* never overwritten by this function; the caller must not invoke it when `userModified` is true.
*/
export function syncSkillToInstallTarget(skillName: SkillCheckName, preset: McpResultPreset, options?: { projectRoot?: string; homeDir?: string }): "synced" | "failed" {
const projectRoot = options?.projectRoot ?? getProjectRoot();
const skillDir = path.join(projectRoot, "skills", skillName);
const canonicalSkillPath = path.join(skillDir, `SKILL.${preset.name}.md`);
if (!fs.existsSync(canonicalSkillPath)) return "failed";
const targetDir = installedSkillRoot(preset, options?.homeDir, skillName);
try {
fs.mkdirSync(targetDir, { recursive: true });
fs.copyFileSync(canonicalSkillPath, path.join(targetDir, "SKILL.md"));
for (const entry of fs.readdirSync(skillDir, { withFileTypes: true })) {
if (entry.name === "install" || isSkillVariantFile(entry.name) || entry.name === ".skill-revision") continue;
const destPath = path.join(targetDir, entry.name);
if (entry.isDirectory()) {
fs.rmSync(destPath, { recursive: true, force: true });
fs.cpSync(path.join(skillDir, entry.name), destPath, { recursive: true });
}
}
fs.writeFileSync(path.join(targetDir, ".synced-hash"), sha256File(canonicalSkillPath), "utf-8");
return "synced";
} catch {
return "failed";
}
}