@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
85 lines (84 loc) • 4.18 kB
JavaScript
import * as fs from "node:fs";
import * as path from "node:path";
import { createHash } from "node:crypto";
import { atomicWriteText } from "../phren-paths.js";
import { BUILTIN_MODULES } from "./registry.js";
import { moduleSnapshot } from "./runtime.js";
export function skillEnabled(store, name, profile) {
const owner = BUILTIN_MODULES.find(module => module.skills.includes(name.replace(/\.md$/, "")));
return !owner || moduleSnapshot(store, profile).has(owner.name);
}
export function reconcileStarterSkills(store, starter) {
const ledgerFile = path.join(store, ".runtime", "module-starters.json");
let ledger = {};
try {
ledger = JSON.parse(fs.readFileSync(ledgerFile, "utf8"));
}
catch (error) {
if (error.code !== "ENOENT")
throw error;
}
const digest = (file) => createHash("sha256").update(fs.readFileSync(file)).digest("hex");
const snapshot = moduleSnapshot(store);
for (const module of BUILTIN_MODULES)
for (const skill of module.skills) {
const source = path.join(starter, "global", "skills", skill);
const visit = (dir, prefix) => {
if (!fs.existsSync(dir))
return;
const destination = path.join(store, prefix);
if (fs.lstatSync(destination, { throwIfNoEntry: false })?.isSymbolicLink())
return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const relative = path.join(prefix, entry.name);
const src = path.join(dir, entry.name), dest = path.join(store, relative);
if (entry.isDirectory()) {
visit(src, relative);
continue;
}
if (!entry.isFile())
continue;
const stat = fs.lstatSync(dest, { throwIfNoEntry: false });
if (stat && (!stat.isFile() || stat.isSymbolicLink()))
continue;
const expected = digest(src);
if (snapshot.has(module.name)) {
if (!stat) {
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(src, dest);
ledger[relative] = expected;
}
else if (digest(dest) === expected)
ledger[relative] = expected;
}
else if (stat && [expected, ledger[relative]].includes(digest(dest))) {
fs.unlinkSync(dest);
delete ledger[relative];
}
}
const target = path.join(store, prefix);
if (!snapshot.has(module.name) && fs.existsSync(target) && fs.readdirSync(target).length === 0)
fs.rmdirSync(target);
};
visit(source, path.join("global", "skills", skill));
}
const instructions = path.join(starter, "global", "AGENTS.md");
if (fs.existsSync(instructions)) {
const relative = "global/AGENTS.md", dest = path.join(store, relative);
const source = fs.readFileSync(instructions, "utf8"), desired = starterInstructions(store, source);
const stat = fs.lstatSync(dest, { throwIfNoEntry: false });
if (!stat || (stat.isFile() && !stat.isSymbolicLink() && [digest(instructions), ledger[relative]].includes(digest(dest)))) {
fs.mkdirSync(path.dirname(dest), { recursive: true });
atomicWriteText(dest, desired);
ledger[relative] = digest(dest);
}
}
fs.mkdirSync(path.dirname(ledgerFile), { recursive: true });
atomicWriteText(ledgerFile, JSON.stringify(ledger, null, 2) + "\n");
}
export function starterInstructions(store, content) {
if (moduleSnapshot(store).has("tasks"))
return content;
return content.split("\n").filter(line => !line.startsWith("- Tasks:")).join("\n")
.replace("findings, tasks and skills", "findings and skills");
}