comfyui-mcp
Version:
Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),
186 lines • 6.91 kB
JavaScript
import { createHash } from "node:crypto";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { normalizeInstallPathEnv } from "../utils/install-path-env.js";
import { join, resolve } from "node:path";
import { generateSkill } from "./skill-generator.js";
import { getNodePackDetails } from "./registry-client.js";
import { logger } from "../utils/logger.js";
const DEFAULT_CACHE_DIR = join(homedir(), ".comfyui-mcp", "skill-cache");
const HASH_CHARS = 12;
const defaultDeps = {
generate: generateSkill,
getDetails: getNodePackDetails,
now: () => new Date(),
};
function cacheDir() {
// #1526 — same treatment as the download cache root, and found the same way:
// review pointed out that calling that one "the last raw path env var" was
// false while this sat two files away. A trailing space here sends cached
// skills to a directory that is not the configured one, silently.
const configured = normalizeInstallPathEnv(process.env.COMFYUI_SKILL_CACHE_DIR, {
varName: "COMFYUI_SKILL_CACHE_DIR",
}).path;
return resolve(configured || DEFAULT_CACHE_DIR);
}
function shortHash(value) {
return createHash("sha256").update(value).digest("hex").slice(0, HASH_CHARS);
}
function contentHash(value) {
return createHash("sha256").update(value).digest("hex");
}
export function normalizeSkillSource(source) {
const trimmed = source.trim();
try {
const url = new URL(trimmed);
url.hash = "";
url.search = "";
url.hostname = url.hostname.toLowerCase();
url.pathname = url.pathname.replace(/\/+$/, "").replace(/\.git$/, "");
return url.toString().replace(/\/$/, "");
}
catch {
return trimmed.replace(/\/+$/, "");
}
}
function safeSegment(value) {
const segment = value
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 64);
return segment || "source";
}
export function buildSkillCacheKey(source, version) {
const normalized = normalizeSkillSource(source);
return `${safeSegment(normalized)}-${safeSegment(version)}-${shortHash(`${normalized}\0${version}`)}`;
}
async function resolveVersion(source, deps) {
const normalized = normalizeSkillSource(source);
if (!normalized.includes("github.com")) {
try {
const details = await deps.getDetails(normalized);
// The registry's version fields can be objects, not strings; only accept
// an actual string so safeSegment()'s .toLowerCase() never sees an object
// (the "value.toLowerCase is not a function" crash on a bare registry id).
const candidate = details.latest_version ?? details.versions?.[0]?.version;
if (typeof candidate === "string" && candidate.trim()) {
return candidate;
}
}
catch (err) {
logger.warn("Could not resolve node pack version for skill cache; using source hash", {
source,
error: err instanceof Error ? err.message : String(err),
});
}
}
return `source-${shortHash(normalized)}`;
}
function parseMetadata(raw) {
try {
const data = JSON.parse(raw);
if (typeof data.source === "string" &&
typeof data.version === "string" &&
typeof data.cachedAt === "string" &&
typeof data.contentHash === "string") {
return {
source: data.source,
version: data.version,
cachedAt: data.cachedAt,
contentHash: data.contentHash,
};
}
}
catch {
// Ignore corrupt cache entries.
}
return undefined;
}
function isNotFoundError(err) {
return (typeof err === "object" &&
err !== null &&
"code" in err &&
err.code === "ENOENT");
}
async function readCachedSkill(source, version, dir, safeKey) {
const normalized = normalizeSkillSource(source);
const entryDir = join(dir, safeKey);
let metadataRaw;
try {
metadataRaw = await readFile(join(entryDir, "metadata.json"), "utf-8");
}
catch (err) {
if (isNotFoundError(err))
return undefined;
throw err;
}
const metadata = parseMetadata(metadataRaw);
if (!metadata ||
normalizeSkillSource(metadata.source) !== normalized ||
metadata.version !== version) {
return undefined;
}
let markdown;
try {
markdown = await readFile(join(entryDir, "SKILL.md"), "utf-8");
}
catch (err) {
if (isNotFoundError(err))
return undefined;
throw err;
}
if (contentHash(markdown) !== metadata.contentHash) {
return undefined;
}
return { markdown, cacheHit: true, cacheDir: dir, safeKey, metadata };
}
async function writeCachedSkill(dir, safeKey, markdown, metadata) {
const entryDir = join(dir, safeKey);
const suffix = `.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const skillPath = join(entryDir, "SKILL.md");
const metadataPath = join(entryDir, "metadata.json");
const tempSkillPath = join(entryDir, `SKILL.md${suffix}`);
const tempMetadataPath = join(entryDir, `metadata.json${suffix}`);
await mkdir(entryDir, { recursive: true });
await writeFile(tempSkillPath, markdown, "utf-8");
await writeFile(tempMetadataPath, JSON.stringify(metadata, null, 2), "utf-8");
await rename(tempSkillPath, skillPath);
await rename(tempMetadataPath, metadataPath);
}
export async function generateSkillCached(source, options = {}, deps = defaultDeps) {
const dir = cacheDir();
const version = await resolveVersion(source, deps);
const safeKey = buildSkillCacheKey(source, version);
if (!options.refresh) {
try {
const cached = await readCachedSkill(source, version, dir, safeKey);
if (cached)
return cached;
}
catch (err) {
logger.warn("Skill cache read failed; generating without cache hit", {
source,
error: err instanceof Error ? err.message : String(err),
});
}
}
const markdown = await deps.generate(source);
const metadata = {
source,
version,
cachedAt: deps.now().toISOString(),
contentHash: contentHash(markdown),
};
try {
await writeCachedSkill(dir, safeKey, markdown, metadata);
}
catch (err) {
logger.warn("Skill cache write failed; returning generated skill without caching", {
source,
error: err instanceof Error ? err.message : String(err),
});
}
return { markdown, cacheHit: false, cacheDir: dir, safeKey, metadata };
}
//# sourceMappingURL=skill-cache.js.map