UNPKG

@phren/cli

Version:

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

71 lines (70 loc) • 2.66 kB
/** * Write an edited file back into the store. * * The store is git-backed and other tools read it, so a save is not a bare * writeFileSync: it refuses to clobber a symlink, checks that a skill is still * loadable before replacing a working one, and lands atomically. */ import * as fs from "fs"; import { atomicWriteText } from "../phren-paths.js"; import { validateSkillFrontmatter, parseSkillFrontmatter } from "../link/skills.js"; import { errorMessage } from "../utils.js"; /** * The registry never lists a symlink as a skill — it skips them — so a link * here means we are pointed at one of the mirrors in ~/.claude/skills or * ~/.copilot/skills. Writing atomically through one would replace the link * with a regular file and silently detach the mirror from the store, so refuse * rather than quietly break it. Matches the guard the write_skill MCP tool has. */ function symlinkRefusal(filePath) { try { if (fs.existsSync(filePath) && fs.lstatSync(filePath).isSymbolicLink()) { return "Refusing to write through a symlinked path — edit the file in the store instead."; } } catch { // An unreadable path fails later, with a better message, at write time. } return null; } /** Compare the fields the skill manifests bake in, so a re-sync only runs when needed. */ function frontmatterDiffers(before, after) { const key = (raw) => { const { frontmatter } = parseSkillFrontmatter(raw); if (!frontmatter) return ""; const fm = frontmatter; return JSON.stringify([fm.name, fm.description, fm.command, fm.aliases]); }; return key(before) !== key(after); } export function saveEditedFile(filePath, content, kind) { const refusal = symlinkRefusal(filePath); if (refusal) return { ok: false, error: refusal }; let previous = ""; try { previous = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : ""; } catch { previous = ""; } if (kind === "skill") { // A skill with broken frontmatter loads as nameless and description-less, // so it is better to refuse than to leave one behind. const check = validateSkillFrontmatter(content, filePath); if (!check.valid) { return { ok: false, error: check.errors[0] ?? "invalid skill frontmatter" }; } } try { atomicWriteText(filePath, content); } catch (err) { return { ok: false, error: errorMessage(err) }; } return { ok: true, frontmatterChanged: kind === "skill" && frontmatterDiffers(previous, content), }; }