@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
208 lines (207 loc) • 11.5 kB
JavaScript
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 },
});
});
});
}