UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

147 lines (146 loc) • 5.71 kB
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; }