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