UNPKG

@phren/cli

Version:

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

208 lines (207 loc) • 11.5 kB
import { mcpResponse } from "./types.js"; import { z } from "zod"; import * as fs from "fs"; import * as path from "path"; import { isValidProjectName, safeProjectPath } from "../utils.js"; import { parseSkillFrontmatter, validateSkillFrontmatter } from "../link/skills.js"; import { removeSkillPath, setSkillEnabledAndSync } from "../skill/files.js"; import { buildSkillManifest, findLocalSkill, findSkill, getAllSkills } from "../skill/registry.js"; export function register(server, ctx) { const { phrenPath, profile, withWriteQueue, updateFileInIndex } = ctx; // ── list_skills ────────────────────────────────────────────────────────── server.registerTool("list_skills", { title: "◆ phren · skills", description: "List all installed skills across global and project scopes.", inputSchema: z.object({ project: z.string().optional().describe("Filter to a specific project. Omit for all."), }), }, async ({ project }) => { if (project && !isValidProjectName(project)) { return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` }); } const resolvedSkills = project ? buildSkillManifest(phrenPath, profile, project).skills : null; const rawSkills = project ? null : getAllSkills(phrenPath, profile); const skills = resolvedSkills || rawSkills || []; if (!skills.length) { return mcpResponse({ ok: true, message: project ? `No skills found for "${project}".` : "No skills found.", data: { skills: [] } }); } const lines = skills.map(s => `${s.command} -> ${s.name} (${s.source}; ${s.enabled ? "enabled" : "disabled"})${s.description ? ` — ${s.description}` : ""}`); const serialized = resolvedSkills ? resolvedSkills.map(({ name, source, format, path: p, description, enabled, command, aliases, visibleToAgents, commandRegistered, overrides, mirrorTargets }) => ({ name, source, format, path: p, description: description ?? null, enabled, command: command ?? null, aliases, visibleToAgents, commandRegistered, overrides, mirrorTargets, })) : (rawSkills || []).map(({ name, source, format, path: p, description, enabled, command, aliases }) => ({ name, source, format, path: p, description: description ?? null, enabled, command: command ?? null, aliases, visibleToAgents: enabled, commandRegistered: enabled, overrides: [], mirrorTargets: [], })); return mcpResponse({ ok: true, message: `${skills.length} skill(s):\n${lines.join("\n")}`, data: { skills: serialized }, }); }); // ── read_skill ─────────────────────────────────────────────────────────── server.registerTool("read_skill", { title: "◆ phren · read skill", description: "Read a skill file's full contents with parsed frontmatter and validation.", inputSchema: z.object({ name: z.string().describe("Skill name (without .md)."), project: z.string().optional().describe("Project scope. Omit to search all."), }), }, async ({ name, project }) => { if (project && !isValidProjectName(project)) { return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` }); } const result = findSkill(phrenPath, profile, project, name); if (!result) { return mcpResponse({ ok: false, error: `Skill "${name}" not found${project ? ` in "${project}"` : ""}.` }); } if ("error" in result) { return mcpResponse({ ok: false, error: result.error }); } // Verify skill path doesn't escape phren via symlink try { const realPath = fs.realpathSync(result.path); const phrenReal = fs.realpathSync(phrenPath); if (!realPath.startsWith(phrenReal + path.sep) && !realPath.startsWith(path.dirname(phrenReal) + path.sep)) { return mcpResponse({ ok: false, error: `Skill path resolves outside phren store.` }); } } catch { /* path doesn't exist or can't resolve — let readFileSync handle it */ } const content = fs.readFileSync(result.path, "utf8"); const { frontmatter, body } = parseSkillFrontmatter(content); const { valid, errors } = validateSkillFrontmatter(content, result.path); return mcpResponse({ ok: true, message: content, data: { path: result.path, content, frontmatter: frontmatter ?? null, body, valid, errors }, }); }); // ── write_skill ────────────────────────────────────────────────────────── server.registerTool("write_skill", { title: "◆ phren · write skill", description: "Create or update a skill file with frontmatter validation. Scope: 'global' or a project name.", inputSchema: z.object({ name: z.string().describe("Skill name (without .md)."), content: z.string().describe("Full skill content including YAML frontmatter."), scope: z.string().describe("'global' or a project name."), }), }, async ({ name, content, scope }) => { if (scope.toLowerCase() !== "global" && !isValidProjectName(scope)) { return mcpResponse({ ok: false, error: `Invalid scope: "${scope}". Use 'global' or a project name.` }); } // Validate name is a safe basename: no path separators, no .. segments const safeName = name.replace(/\.md$/i, ""); if (!safeName || safeName.includes("/") || safeName.includes("\\") || safeName.includes("..") || path.basename(safeName) !== safeName) { return mcpResponse({ ok: false, error: `Invalid skill name: "${name}". Must be a simple filename with no path separators or traversal sequences.` }); } const { valid, errors } = validateSkillFrontmatter(content); if (!valid) { return mcpResponse({ ok: false, error: `Invalid frontmatter: ${errors.join("; ")}` }); } return withWriteQueue(async () => { const destDir = scope.toLowerCase() === "global" ? path.join(phrenPath, "global", "skills") : path.join(phrenPath, scope, "skills"); if (scope.toLowerCase() !== "global" && !fs.existsSync(path.join(phrenPath, scope))) { return mcpResponse({ ok: false, error: `Project "${scope}" not found.` }); } fs.mkdirSync(destDir, { recursive: true }); const existing = findLocalSkill(phrenPath, scope, safeName); const dest = existing ? safeProjectPath(destDir, path.relative(destDir, existing.path)) : safeProjectPath(destDir, `${safeName}.md`); if (!dest) { return mcpResponse({ ok: false, error: "Skill path escapes phren store." }); } try { if (fs.existsSync(dest) && fs.lstatSync(dest).isSymbolicLink()) { return mcpResponse({ ok: false, error: "Refusing to write through a symlinked skill path." }); } } catch { return mcpResponse({ ok: false, error: "Skill path escapes phren store." }); } const existed = Boolean(existing) || fs.existsSync(dest); fs.writeFileSync(dest, content); updateFileInIndex(dest); return mcpResponse({ ok: true, message: `${existed ? "Updated" : "Created"} skill "${name}" in ${scope}.`, data: { path: dest, created: !existed } }); }); }); // ── remove_skill ───────────────────────────────────────────────────────── server.registerTool("remove_skill", { title: "◆ phren · remove skill", description: "Remove a skill file by name.", inputSchema: z.object({ name: z.string().describe("Skill name (without .md)."), project: z.string().optional().describe("Project scope. Omit to search all."), }), }, async ({ name, project }) => { if (project && !isValidProjectName(project)) { return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` }); } const result = project ? findLocalSkill(phrenPath, project, name) : findSkill(phrenPath, profile, project, name); if (!result) { return mcpResponse({ ok: false, error: `Skill "${name}" not found${project ? ` in "${project}"` : ""}.` }); } if ("error" in result) { return mcpResponse({ ok: false, error: result.error }); } return withWriteQueue(async () => { const removedPath = removeSkillPath(result.format === "folder" ? result.root : result.path); updateFileInIndex(result.path); // called after delete so indexer removes the entry return mcpResponse({ ok: true, message: `Removed skill "${name}" (${removedPath}).`, data: { path: removedPath } }); }); }); // ── toggle_skill ───────────────────────────────────────────────────── server.registerTool("toggle_skill", { title: "◆ phren · toggle skill", description: "Enable or disable a skill without deleting its file.", inputSchema: z.object({ name: z.string().describe("Skill name (without .md)."), enabled: z.boolean().describe("true to enable, false to disable."), project: z.string().describe("Project scope or 'global'."), }), }, async ({ name, enabled, project }) => { if (project.toLowerCase() !== "global" && !isValidProjectName(project)) { return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` }); } const result = findSkill(phrenPath, profile, project, name); if (!result) { return mcpResponse({ ok: false, error: `Skill "${name}" not found in "${project}".` }); } if ("error" in result) { return mcpResponse({ ok: false, error: result.error }); } const verb = enabled ? "Enable" : "Disable"; return withWriteQueue(async () => { setSkillEnabledAndSync(phrenPath, result.source, result.name, enabled); return mcpResponse({ ok: true, message: `${verb}d skill "${result.name}" in ${result.source}${result.source === "global" ? " (all projects)" : ""}.`, data: { name: result.name, project: result.source, enabled }, }); }); }); }