@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
147 lines (146 loc) • 5.71 kB
JavaScript
import * as fs from "fs";
import * as path from "path";
import { homePath } from "../shared.js";
import { findProjectDir } from "../project-locator.js";
import { buildSkillManifest, skillMirrorPath } from "./registry.js";
import { setSkillEnabled } from "./state.js";
import { errorMessage } from "../utils.js";
import { isManagedSymlink } from "../link/skills.js";
import { logger } from "../logger.js";
import { getNonPrimaryStores } from "../store-registry.js";
function normalizeSkillRemovalTarget(skillPath) {
if (!skillPath)
return skillPath;
if (path.basename(skillPath).toLowerCase() === "skill.md") {
return path.dirname(skillPath);
}
return skillPath;
}
/** Every store root whose skills phren links, so a link into a team store counts as phren's own. */
function managedSkillRoots(phrenPath) {
return [phrenPath, ...getNonPrimaryStores(phrenPath).map((store) => store.path)];
}
function isManagedSkillLink(dest, managedRoots) {
return managedRoots.some((root) => isManagedSymlink(dest, root));
}
function symlinkManagedSkill(src, dest, managedRoots) {
try {
const stat = fs.lstatSync(dest);
if (stat.isSymbolicLink()) {
const currentTarget = fs.readlinkSync(dest);
const resolvedTarget = path.resolve(path.dirname(dest), currentTarget);
if (resolvedTarget === path.resolve(src))
return;
if (!isManagedSkillLink(dest, managedRoots))
return;
fs.unlinkSync(dest);
}
else {
return;
}
}
catch (err) {
if (err.code !== "ENOENT")
throw err;
}
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.symlinkSync(src, dest);
}
/** A folder phren made for a flat skill: it holds only a managed SKILL.md link. */
function isManagedSkillFolder(dest, managedRoots) {
try {
if (!fs.lstatSync(dest).isDirectory())
return false;
const entries = fs.readdirSync(dest);
return entries.length === 1 && entries[0] === "SKILL.md" && isManagedSkillLink(path.join(dest, "SKILL.md"), managedRoots);
}
catch {
return false;
}
}
function removeManagedSkillLink(dest, managedRoots) {
try {
if (isManagedSkillFolder(dest, managedRoots)) {
fs.unlinkSync(path.join(dest, "SKILL.md"));
fs.rmdirSync(dest);
return;
}
if (!isManagedSkillLink(dest, managedRoots))
return;
fs.unlinkSync(dest);
}
catch (err) {
if (err.code !== "ENOENT")
logger.debug("skill-files", `removeManagedSkillLink: ${errorMessage(err)}`);
}
}
function writeSkillArtifacts(destDir, manifest) {
const parentDir = path.dirname(destDir);
fs.mkdirSync(parentDir, { recursive: true });
fs.writeFileSync(path.join(parentDir, "skill-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
fs.writeFileSync(path.join(parentDir, "skill-commands.json"), `${JSON.stringify({
scope: manifest.scope,
project: manifest.project,
generatedAt: manifest.generatedAt,
commands: manifest.commands.filter((command) => command.registered),
problems: manifest.problems,
}, null, 2)}\n`);
}
export function syncScopeSkillsToDir(phrenPath, scope, destDir) {
const manifest = buildSkillManifest(phrenPath, "", scope, destDir);
const expectedNames = new Set();
const managedRoots = managedSkillRoots(phrenPath);
fs.mkdirSync(destDir, { recursive: true });
for (const skill of manifest.skills) {
const entryPath = path.join(destDir, skill.name);
if (!skill.visibleToAgents) {
removeManagedSkillLink(entryPath, managedRoots);
continue;
}
expectedNames.add(skill.name);
// A skill that switched between flat and folder leaves the other shape behind.
if (skill.format === "folder" ? isManagedSkillFolder(entryPath, managedRoots) : isManagedSkillLink(entryPath, managedRoots)) {
removeManagedSkillLink(entryPath, managedRoots);
}
symlinkManagedSkill(skill.root, skillMirrorPath(destDir, skill), managedRoots);
}
for (const entry of fs.readdirSync(destDir)) {
if (expectedNames.has(entry))
continue;
removeManagedSkillLink(path.join(destDir, entry), managedRoots);
}
writeSkillArtifacts(destDir, manifest);
return manifest;
}
export function syncSkillLinksForScope(phrenPath, scope) {
if (scope.toLowerCase() === "global") {
const manifest = syncScopeSkillsToDir(phrenPath, "global", homePath(".claude", "skills"));
for (const tool of [".agents", ".copilot"]) {
const dir = homePath(tool, "skills");
if (fs.existsSync(path.join(path.dirname(dir), "skill-manifest.json")))
syncScopeSkillsToDir(phrenPath, "global", dir);
}
return manifest;
}
const projectDir = findProjectDir(scope);
if (!projectDir)
return null;
return syncScopeSkillsToDir(phrenPath, scope, path.join(projectDir, ".claude", "skills"));
}
export function setSkillEnabledAndSync(phrenPath, scope, name, enabled) {
setSkillEnabled(phrenPath, scope, name, enabled);
syncSkillLinksForScope(phrenPath, scope);
}
export function removeSkillPath(skillPath) {
const target = normalizeSkillRemovalTarget(skillPath);
if (!target || !fs.existsSync(target))
return target;
const stat = fs.lstatSync(target);
if (stat.isDirectory()) {
fs.rmSync(target, { recursive: true, force: true });
}
else {
fs.unlinkSync(target);
}
return target;
}