UNPKG

@phren/cli

Version:

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

269 lines (268 loc) • 15.3 kB
import { mcpResponse } from "./types.js"; import { z } from "zod"; import * as fs from "fs"; import * as path from "path"; import { readInstallPreferences, updateInstallPreferences } from "../init/preferences.js"; import { readCustomHooks, getHookTarget, HOOK_EVENT_VALUES, validateCustomHookCommand, validateCustomWebhookUrl } from "../hooks.js"; import { hookConfigPath } from "../shared.js"; import { patchJsonFile, upsertCustomPrePromptSiblings } from "../init/config.js"; import { isRecord } from "../shared.js"; import { errorMessage } from "../utils.js"; import { PROJECT_HOOK_EVENTS, isProjectHookEnabled, readProjectConfig, writeProjectHookConfig, clearProjectHookOverride } from "../project-config.js"; import { isValidProjectName } from "../utils.js"; const HOOK_TOOLS = ["claude", "copilot", "cursor", "codex"]; const VALID_CUSTOM_EVENTS = HOOK_EVENT_VALUES; /** * Sync pre-prompt custom hooks into Claude's settings.json as siblings. * Best-effort: errors are logged but do not fail the calling MCP tool. * Called after add_custom_hook and remove_custom_hook update prefs so that * the live Claude config reflects the new state without requiring `phren init`. */ function syncPrePromptSiblingsBestEffort(phrenPath) { try { const settingsPath = hookConfigPath("claude", phrenPath); if (!fs.existsSync(settingsPath)) return; // Claude not initialized; nothing to sync patchJsonFile(settingsPath, (data) => { const hooksMap = isRecord(data.hooks) ? data.hooks : (data.hooks = {}); upsertCustomPrePromptSiblings(hooksMap, phrenPath); }); } catch (err) { // Don't crash the MCP tool — sync is a convenience, not a contract. // The next `phren init` will re-attempt the sync from a clean slate. console.warn(`syncPrePromptSiblingsBestEffort: ${errorMessage(err)}`); } } function normalizeHookTool(input) { if (!input) return null; const lower = input.toLowerCase(); return HOOK_TOOLS.includes(lower) ? lower : null; } function normalizeProjectHookEvent(input) { if (!input) return null; const normalized = input.trim().toLowerCase(); const aliasMap = { userpromptsubmit: "UserPromptSubmit", prompt: "UserPromptSubmit", stop: "Stop", sessionstart: "SessionStart", start: "SessionStart", posttooluse: "PostToolUse", tool: "PostToolUse", }; return aliasMap[normalized] ?? null; } export function register(server, ctx) { const { phrenPath } = ctx; // ── list_hooks ─────────────────────────────────────────────────────────── server.registerTool("list_hooks", { title: "◆ phren · hooks", description: "List hook status for all tools (claude, copilot, cursor, codex) with enable/disable state, " + "config file paths, and custom integration hooks.", inputSchema: z.object({ project: z.string().optional().describe("Optional project name to include project-level lifecycle hook overrides."), }), }, async ({ project }) => { const prefs = readInstallPreferences(phrenPath); const globalEnabled = prefs.hooksEnabled !== false; const toolPrefs = prefs.hookTools && typeof prefs.hookTools === "object" ? prefs.hookTools : {}; const paths = { claude: hookConfigPath("claude", phrenPath), copilot: hookConfigPath("copilot", phrenPath), cursor: hookConfigPath("cursor", phrenPath), codex: hookConfigPath("codex", phrenPath), }; const customHooks = readCustomHooks(phrenPath); let projectHooks = null; if (project !== undefined) { if (!isValidProjectName(project) || !fs.existsSync(path.join(phrenPath, project))) { return mcpResponse({ ok: false, error: `Project "${project}" not found.` }); } const config = readProjectConfig(phrenPath, project); projectHooks = { project, baseEnabled: typeof config.hooks?.enabled === "boolean" ? config.hooks.enabled : null, configPath: path.join(phrenPath, project, "phren.project.yaml"), events: PROJECT_HOOK_EVENTS.map((event) => ({ event, configured: typeof config.hooks?.[event] === "boolean" ? config.hooks[event] : null, enabled: isProjectHookEnabled(phrenPath, project, event, config), })), }; } const tools = HOOK_TOOLS.map(tool => ({ tool, enabled: globalEnabled && toolPrefs[tool] !== false, configPath: paths[tool], configExists: fs.existsSync(paths[tool]), })); const lines = [ `Hooks globally ${globalEnabled ? "enabled" : "disabled"}`, "", ...tools.map(t => `${t.tool}: ${t.enabled ? "enabled" : "disabled"} | config: ${t.configExists ? t.configPath : "(not found)"}`), ]; if (projectHooks) { lines.push("", `Project ${projectHooks.project}: base ${projectHooks.baseEnabled === null ? "inherit" : projectHooks.baseEnabled ? "enabled" : "disabled"} | config: ${projectHooks.configPath}`, ...projectHooks.events.map((event) => `${event.event}: ${event.enabled ? "enabled" : "disabled"}${event.configured === null ? " (inherit)" : ` (explicit ${event.configured ? "on" : "off"})`}`)); } if (customHooks.length > 0) { lines.push("", `${customHooks.length} custom hook(s):`); for (const h of customHooks) { const hookKind = "webhook" in h ? "[webhook] " : ""; lines.push(` ${h.event}: ${hookKind}${getHookTarget(h)}${h.timeout ? ` (${h.timeout}ms)` : ""}`); } } return mcpResponse({ ok: true, message: lines.join("\n"), data: { globalEnabled, tools, customHooks, projectHooks } }); }); // ── toggle_hooks ───────────────────────────────────────────────────────── server.registerTool("toggle_hooks", { title: "◆ phren · toggle hooks", description: "Enable or disable hooks globally, for a specific tool, or for a tracked project.", inputSchema: z.object({ enabled: z.boolean().describe("true to enable, false to disable."), clear: z.boolean().optional().describe("When true and project is set, removes the per-project override and restores inheritance from global. Ignores enabled."), tool: z.string().optional().describe("Specific tool. Omit to toggle globally."), project: z.string().optional().describe("Tracked project name for project-level lifecycle hook overrides."), event: z.string().optional().describe("Optional lifecycle event for project-level overrides: UserPromptSubmit, Stop, SessionStart, PostToolUse."), }), }, async ({ enabled, clear, tool, project, event }) => { if (tool && project) { return mcpResponse({ ok: false, error: "Pass either tool or project, not both." }); } // Clear per-project override (restore inheritance) if (clear && project) { if (!isValidProjectName(project) || !fs.existsSync(path.join(phrenPath, project))) { return mcpResponse({ ok: false, error: `Project "${project}" not found.` }); } clearProjectHookOverride(phrenPath, project, event); const scope = event ? `${event} hook` : "hooks"; return mcpResponse({ ok: true, message: `Cleared ${scope} override for ${project} — now inheriting from global.`, data: { project, event, cleared: true } }); } if (clear) { return mcpResponse({ ok: false, error: "clear requires project." }); } if (event && !project) { return mcpResponse({ ok: false, error: "event requires project." }); } if (project) { if (!isValidProjectName(project) || !fs.existsSync(path.join(phrenPath, project))) { return mcpResponse({ ok: false, error: `Project "${project}" not found.` }); } const normalizedEvent = normalizeProjectHookEvent(event); if (event && !normalizedEvent) { return mcpResponse({ ok: false, error: `Invalid event "${event}". Use: ${PROJECT_HOOK_EVENTS.join(", ")}` }); } if (normalizedEvent) { writeProjectHookConfig(phrenPath, project, { [normalizedEvent]: enabled }); return mcpResponse({ ok: true, message: `${enabled ? "Enabled" : "Disabled"} ${normalizedEvent} hook for ${project}.`, data: { project, event: normalizedEvent, enabled }, }); } writeProjectHookConfig(phrenPath, project, { enabled }); return mcpResponse({ ok: true, message: `${enabled ? "Enabled" : "Disabled"} hooks for project ${project}.`, data: { project, enabled }, }); } if (tool) { const normalized = normalizeHookTool(tool); if (!normalized) { return mcpResponse({ ok: false, error: `Invalid tool "${tool}". Use: ${HOOK_TOOLS.join(", ")}` }); } updateInstallPreferences(phrenPath, (prefs) => ({ hookTools: { ...(prefs.hookTools && typeof prefs.hookTools === "object" ? prefs.hookTools : {}), [normalized]: enabled, }, })); return mcpResponse({ ok: true, message: `${enabled ? "Enabled" : "Disabled"} hooks for ${normalized}.`, data: { tool: normalized, enabled } }); } updateInstallPreferences(phrenPath, () => ({ hooksEnabled: enabled })); return mcpResponse({ ok: true, message: `${enabled ? "Enabled" : "Disabled"} hooks globally.`, data: { global: true, enabled } }); }); // ── add_custom_hook ────────────────────────────────────────────────────── server.registerTool("add_custom_hook", { title: "◆ phren · add custom hook", description: "Add a custom integration hook. Valid events: " + VALID_CUSTOM_EVENTS.join(", ") + ". " + "Provide either command (shell) or webhook (HTTP POST URL), not both.", inputSchema: z.object({ event: z.enum(VALID_CUSTOM_EVENTS).describe("Hook event name."), command: z.string().optional().describe("Shell command to execute."), webhook: z.string().optional().describe("HTTP POST URL to call asynchronously (webhook hook)."), secret: z.string().optional().describe("HMAC-SHA256 signing secret for webhook hooks. Sent as X-Phren-Signature header."), timeout: z.number().int().min(1).optional().describe("Timeout in ms (default 5000)."), }), }, async ({ event, command, webhook, secret, timeout }) => { if (!command && !webhook) return mcpResponse({ ok: false, error: "Provide either command or webhook." }); if (command && webhook) return mcpResponse({ ok: false, error: "Provide command or webhook, not both." }); let newHook; if (webhook) { const trimmed = webhook.trim(); const webhookErr = validateCustomWebhookUrl(trimmed); if (webhookErr) return mcpResponse({ ok: false, error: webhookErr }); newHook = { event, webhook: trimmed, ...(secret ? { secret } : {}), ...(timeout !== undefined ? { timeout } : {}) }; } else { const cmdErr = validateCustomHookCommand(command); if (cmdErr) return mcpResponse({ ok: false, error: cmdErr }); newHook = { event, command: command, ...(timeout !== undefined ? { timeout } : {}) }; } return ctx.withWriteQueue(async () => { let totalAfter = 0; updateInstallPreferences(phrenPath, (prefs) => { const existing = Array.isArray(prefs.customHooks) ? prefs.customHooks : []; totalAfter = existing.length + 1; return { customHooks: [...existing, newHook] }; }); // Mirror the new hook (if pre-prompt) into Claude's settings.json // immediately, so users don't have to re-run `phren init` for the // sibling-hook fix to take effect. if (event === "pre-prompt" && "command" in newHook) { syncPrePromptSiblingsBestEffort(phrenPath); } return mcpResponse({ ok: true, message: `Added custom hook for "${event}": ${"webhook" in newHook ? "[webhook] " : ""}${getHookTarget(newHook)}`, data: { hook: newHook, total: totalAfter } }); }); }); // ── remove_custom_hook ─────────────────────────────────────────────────── server.registerTool("remove_custom_hook", { title: "◆ phren · remove custom hook", description: "Remove custom hook(s) by event and optional command text (partial match).", inputSchema: z.object({ event: z.enum(VALID_CUSTOM_EVENTS).describe("Hook event name to match."), command: z.string().optional().describe("Partial command text. Omit to remove all hooks for the event."), }), }, async ({ event, command }) => { return ctx.withWriteQueue(async () => { let removed = 0; let remainingCount = 0; updateInstallPreferences(phrenPath, (prefs) => { const existing = Array.isArray(prefs.customHooks) ? prefs.customHooks : []; const remaining = existing.filter(h => h.event !== event || (command != null && !getHookTarget(h).includes(command))); removed = existing.length - remaining.length; remainingCount = remaining.length; return { customHooks: remaining }; }); if (removed === 0) { return mcpResponse({ ok: false, error: `No custom hooks matched event="${event}"${command ? ` command containing "${command}"` : ""}.` }); } // If we just removed any pre-prompt hooks, drop their sibling // entries from Claude's settings.json. The sync function diffs the // current prefs against the previously-managed list and removes // anything that disappeared. if (event === "pre-prompt") { syncPrePromptSiblingsBestEffort(phrenPath); } return mcpResponse({ ok: true, message: `Removed ${removed} custom hook(s) for "${event}".`, data: { removed, remaining: remainingCount } }); }); }); }