@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
437 lines (436 loc) • 19.4 kB
JavaScript
/**
* Provider-specific MCP configuration backends.
* Handles IDE/tool config files for Claude, VS Code, Cursor, Copilot CLI, and Codex.
*/
import * as fs from "fs";
import * as path from "path";
import { buildLifecycleCommands, commandExists, phrenPackageSpec } from "../hooks.js";
import { isRecord, hookConfigPath, homePath, readRootManifest, atomicWriteText, } from "../shared.js";
import { isFeatureEnabled, errorMessage } from "../utils.js";
import { probeVsCodeConfig, resolveCodexMcpConfig, resolveCopilotMcpConfig, resolveCursorMcpConfig, } from "../provider-adapters.js";
import { getMcpEnabledPreference, getHooksEnabledPreference, readInstallPreferences, updateInstallPreferences } from "./preferences.js";
import { resolveEntryScript, log } from "./shared.js";
import { readCustomHooks } from "../hooks.js";
function getObjectProp(value, key) {
const candidate = value[key];
return isRecord(candidate) ? candidate : undefined;
}
export function patchJsonFile(filePath, patch) {
let data = {};
if (fs.existsSync(filePath)) {
try {
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
if (!isRecord(parsed))
throw new Error("top-level JSON value must be an object");
data = parsed;
}
catch (err) {
throw new Error(`Malformed JSON in ${filePath}: ${errorMessage(err)}`);
}
}
else {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
}
patch(data);
atomicWriteText(filePath, JSON.stringify(data, null, 2) + "\n");
}
/**
* True when the running copy of phren lives in npm's npx cache. That cache is
* npm's to evict, so a path into it is not something to write into anyone's
* settings: it works until the day it silently does not.
*/
export function isEphemeralInstall(entryScript) {
return /[\\/]_npx[\\/]/.test(entryScript);
}
/**
* How Claude (and the other MCP hosts) should start the phren server.
*
* A real install points straight at its own `dist/index.js`. An `npx` install
* does not: the file it would name sits in the npx cache, and the day npm
* evicts it the MCP server stops starting with no error anyone sees — which
* is exactly how `npx @phren/cli init` ends up "not working" a week later.
* That case goes through the CLI wrapper init writes to `~/.local/bin`, which
* knows how to find or fetch phren. Windows has no shell wrapper to lean on,
* so it falls back to `npx.cmd` with the real package name.
*/
export function buildMcpServerConfig(phrenPath, opts = {}) {
const entryScript = opts.entryScript ?? resolveEntryScript();
const platform = opts.platform ?? process.platform;
if (entryScript && !isEphemeralInstall(entryScript) && fs.existsSync(entryScript)) {
return { command: "node", args: [entryScript, phrenPath] };
}
if (platform !== "win32") {
return { command: opts.wrapperPath ?? homePath(".local", "bin", "phren"), args: [phrenPath] };
}
// MCP clients spawn servers with { shell: false } on Windows, so the bare
// `npx` name can't resolve the `npx.cmd` shim and the server fails to start.
return { command: "npx.cmd", args: ["-y", phrenPackageSpec(), phrenPath] };
}
function upsertMcpServer(data, mcpEnabled, preferredRoot, phrenPath) {
const knownRoots = ["mcpServers", "servers"];
const hadMcp = knownRoots.some((key) => Boolean(getObjectProp(data, key)?.phren));
if (mcpEnabled) {
let preferredRootValue = getObjectProp(data, preferredRoot);
if (!preferredRootValue) {
preferredRootValue = {};
data[preferredRoot] = preferredRootValue;
}
preferredRootValue.phren = buildMcpServerConfig(phrenPath);
return hadMcp ? "already_configured" : "installed";
}
for (const key of knownRoots) {
const root = getObjectProp(data, key);
if (root?.phren)
delete root.phren;
}
return hadMcp ? "disabled" : "already_disabled";
}
function configureMcpAtPath(filePath, mcpEnabled, preferredRoot, phrenPath) {
if (!mcpEnabled && !fs.existsSync(filePath))
return "already_disabled";
let status = "already_disabled";
patchJsonFile(filePath, (data) => {
status = upsertMcpServer(data, mcpEnabled, preferredRoot, phrenPath);
});
return status;
}
/**
* Read/write a TOML config file to upsert or remove [mcp_servers.phren].
* Lightweight: preserves all other content, only touches the phren section.
*/
function patchTomlMcpServer(filePath, mcpEnabled, phrenPath) {
let content = "";
const existed = fs.existsSync(filePath);
if (existed) {
content = fs.readFileSync(filePath, "utf8");
}
else if (!mcpEnabled) {
return "already_disabled";
}
const cfg = buildMcpServerConfig(phrenPath);
const escToml = (s) => s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
const argsToml = "[" + cfg.args.map((a) => `"${escToml(a)}"`).join(", ") + "]";
const newSection = `[mcp_servers.phren]\ncommand = "${escToml(cfg.command)}"\nargs = ${argsToml}\nstartup_timeout_sec = 30`;
const sectionRe = /^\[mcp_servers\.phren\]\s*\n(?:(?!\[)[^\n]*\n?)*/m;
const hadSection = sectionRe.test(content);
if (mcpEnabled) {
if (hadSection) {
content = content.replace(sectionRe, newSection + "\n");
atomicWriteText(filePath, content);
return "already_configured";
}
if (!existed) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
}
const sep = content.length > 0 && !content.endsWith("\n\n") ? (content.endsWith("\n") ? "\n" : "\n\n") : "";
content += sep + newSection + "\n";
atomicWriteText(filePath, content);
return "installed";
}
if (!hadSection)
return "already_disabled";
content = content.replace(sectionRe, "");
content = content.replace(/\n{3,}/g, "\n\n");
atomicWriteText(filePath, content);
return "disabled";
}
export function removeTomlMcpServer(filePath) {
if (!fs.existsSync(filePath))
return false;
let content = fs.readFileSync(filePath, "utf8");
const sectionRe = /^\[mcp_servers\.phren\]\s*\n(?:(?!\[)[^\n]*\n?)*/m;
if (!sectionRe.test(content))
return false;
content = content.replace(sectionRe, "").replace(/\n{3,}/g, "\n\n");
atomicWriteText(filePath, content);
return true;
}
export function removeMcpServerAtPath(filePath) {
if (!fs.existsSync(filePath))
return false;
let removed = false;
patchJsonFile(filePath, (data) => {
for (const key of ["mcpServers", "servers"]) {
const root = data[key];
if (isRecord(root) && root.phren) {
delete root.phren;
removed = true;
}
}
});
return removed;
}
export function isPhrenCommand(command) {
// Detect PHREN_PATH= env var prefix (present in all lifecycle hook commands)
if (/\bPHREN_PATH=/.test(command))
return true;
// Detect npx phren package references
if (command.includes("phren"))
return true;
// Detect bare "phren" executable segment
const segments = command.split(/[/\\\s]+/);
if (segments.some(seg => seg === "phren" || seg.startsWith("phren@")))
return true;
// Also match commands that include hook subcommands (used when installed via absolute path)
const HOOK_MARKERS = ["hook-prompt", "hook-stop", "hook-session-start", "hook-tool"];
if (HOOK_MARKERS.some(m => command.includes(m)))
return true;
return false;
}
/**
* Reconcile the set of pre-prompt custom hook commands in `customHooks` with
* sibling `UserPromptSubmit` entries in Claude Code's settings.json hooks map.
*
* Why: phren's chained `runPrePromptHooks` runs custom hooks sequentially
* inside its own UserPromptSubmit handler, so a slow custom hook (e.g. one
* that does an `ls` against a OneDrive-backed WSL mount) eats phren's whole
* response budget and times out before injecting anything. Registering each
* pre-prompt custom hook as a peer `UserPromptSubmit` entry lets Claude Code
* dispatch it in parallel with phren's own hook — they get independent
* timeout budgets and one cannot block the other.
*
* Identification: phren tracks "managed" sibling commands in
* `prefs.managedPrePromptSiblingCommands`. On each sync we (a) append a new
* sibling for each pre-prompt command not already in settings.json, (b)
* remove sibling entries whose command was previously managed but is no
* longer in `customHooks`, and (c) leave any user-authored hook entries
* (entries whose command never appeared in the managed list) untouched.
*
* Webhook custom hooks are not affected — they're already async/fire-and-
* forget and don't compete for hook event time.
*
* Idempotent.
*/
export function upsertCustomPrePromptSiblings(hooksMap, phrenPath) {
const customHooks = readCustomHooks(phrenPath);
const desired = [];
for (const h of customHooks) {
if (h.event !== "pre-prompt")
continue;
if (!("command" in h))
continue;
const cmd = h.command.trim();
if (!cmd)
continue;
const timeoutSec = h.timeout != null ? Math.max(1, Math.ceil(h.timeout / 1000)) : 15;
desired.push({ command: cmd, timeoutSec });
}
const desiredCommands = new Set(desired.map((d) => d.command));
const prefs = readInstallPreferences(phrenPath);
const previouslyManaged = new Set(Array.isArray(prefs.managedPrePromptSiblingCommands) ? prefs.managedPrePromptSiblingCommands : []);
if (!Array.isArray(hooksMap.UserPromptSubmit))
hooksMap.UserPromptSubmit = [];
const eventHooks = hooksMap.UserPromptSubmit;
// Remove stale siblings: previously managed but no longer desired
let removed = 0;
for (let i = eventHooks.length - 1; i >= 0; i--) {
const entry = eventHooks[i];
const inner = entry?.hooks;
if (!Array.isArray(inner) || inner.length !== 1)
continue;
const cmd = typeof inner[0]?.command === "string" ? inner[0].command : "";
if (!cmd)
continue;
if (previouslyManaged.has(cmd) && !desiredCommands.has(cmd)) {
eventHooks.splice(i, 1);
removed++;
}
}
// Compute set of commands currently present in settings.json (any entry)
const presentCommands = new Set();
for (const entry of eventHooks) {
const inner = entry?.hooks;
if (!Array.isArray(inner))
continue;
for (const h of inner) {
if (typeof h?.command === "string")
presentCommands.add(h.command);
}
}
// Append new siblings for each desired command not already present
let added = 0;
for (const d of desired) {
if (presentCommands.has(d.command))
continue;
eventHooks.push({
matcher: "",
hooks: [{ type: "command", command: d.command, timeout: d.timeoutSec }],
});
added++;
}
// Update bookkeeping: managed = current desired set
const newManaged = Array.from(desiredCommands).sort();
const oldManaged = Array.from(previouslyManaged).sort();
if (newManaged.length !== oldManaged.length || newManaged.some((c, i) => c !== oldManaged[i])) {
updateInstallPreferences(phrenPath, () => ({ managedPrePromptSiblingCommands: newManaged }));
}
return { added, removed };
}
export function configureClaude(phrenPath, opts = {}) {
const settingsPath = hookConfigPath("claude");
const claudeJsonPath = homePath(".claude.json");
const entryScript = resolveEntryScript();
const mcpEnabled = opts.mcpEnabled ?? getMcpEnabledPreference(phrenPath);
const hooksEnabled = opts.hooksEnabled ?? getHooksEnabledPreference(phrenPath);
const lifecycle = buildLifecycleCommands(phrenPath);
let status = "already_disabled";
if (fs.existsSync(claudeJsonPath)) {
patchJsonFile(claudeJsonPath, (data) => {
status = upsertMcpServer(data, mcpEnabled, "mcpServers", phrenPath);
});
}
patchJsonFile(settingsPath, (data) => {
const settingsStatus = upsertMcpServer(data, mcpEnabled, "mcpServers", phrenPath);
if (status === "already_disabled")
status = settingsStatus;
const hooksMap = isRecord(data.hooks) ? data.hooks : (data.hooks = {});
const upsertPhrenHook = (eventName, hookBody) => {
if (!Array.isArray(hooksMap[eventName]))
hooksMap[eventName] = [];
const eventHooks = hooksMap[eventName];
const marker = eventName === "UserPromptSubmit" ? "hook-prompt"
: eventName === "Stop" ? "hook-stop"
: eventName === "PostToolUse" ? "hook-tool"
: "hook-session-start";
// Find the HookEntry containing a phren hook command
const existingEntryIdx = eventHooks.findIndex((h) => h?.hooks?.some((hook) => typeof hook?.command === "string" &&
(hook.command.includes(marker) ||
isPhrenCommand(hook.command))));
if (existingEntryIdx >= 0) {
// Only rewrite the matching inner hook item; preserve sibling non-phren hooks
const entry = eventHooks[existingEntryIdx];
const innerIdx = (entry.hooks ?? []).findIndex((hook) => typeof hook?.command === "string" &&
(hook.command.includes(marker) ||
isPhrenCommand(hook.command)));
if (innerIdx >= 0 && entry.hooks) {
entry.hooks[innerIdx] = hookBody;
}
else {
// No matching inner hook found; append our hook body
if (!entry.hooks)
entry.hooks = [];
entry.hooks.push(hookBody);
}
}
else {
eventHooks.push({ matcher: "", hooks: [hookBody] });
}
};
const toolHookEnabled = hooksEnabled && isFeatureEnabled("PHREN_FEATURE_TOOL_HOOK", true);
if (hooksEnabled) {
upsertPhrenHook("UserPromptSubmit", {
type: "command",
command: lifecycle.userPromptSubmit || `node "${entryScript}" hook-prompt`,
timeout: 10,
});
// Mirror pre-prompt custom hooks into Claude's settings.json as
// peer UserPromptSubmit entries so they run in parallel with phren
// and don't share its timeout budget. See
// upsertCustomPrePromptSiblings docstring for details.
try {
upsertCustomPrePromptSiblings(hooksMap, phrenPath);
}
catch (err) {
// Sync is best-effort: a malformed prefs file should not break
// the rest of configureClaude. Surface a warning and continue.
console.warn(`configureClaude: pre-prompt sibling sync failed: ${errorMessage(err)}`);
}
upsertPhrenHook("Stop", {
type: "command",
command: lifecycle.stop,
});
upsertPhrenHook("SessionStart", {
type: "command",
command: lifecycle.sessionStart,
});
if (toolHookEnabled) {
upsertPhrenHook("PostToolUse", {
type: "command",
command: lifecycle.hookTool,
});
}
}
else {
for (const hookEvent of ["UserPromptSubmit", "Stop", "SessionStart", "PostToolUse"]) {
const hooks = hooksMap[hookEvent];
if (!Array.isArray(hooks))
continue;
hooksMap[hookEvent] = hooks.filter((h) => !h.hooks?.some((hook) => typeof hook.command === "string" && isPhrenCommand(hook.command)));
}
}
});
return status;
}
let _vscodeProbeCache = null;
/** Reset the VS Code path probe cache (for testing). */
export function resetVSCodeProbeCache() { _vscodeProbeCache = null; }
function probeVSCodePath() {
if (_vscodeProbeCache)
return _vscodeProbeCache;
_vscodeProbeCache = probeVsCodeConfig(commandExists);
return _vscodeProbeCache;
}
export function configureVSCode(phrenPath, opts = {}) {
const mcpEnabled = opts.mcpEnabled ?? getMcpEnabledPreference(phrenPath);
if (opts.scope === "workspace") {
const manifest = readRootManifest(phrenPath);
if (manifest?.installMode !== "project-local" || !manifest.workspaceRoot)
return "no_vscode";
const mcpFile = path.join(manifest.workspaceRoot, ".vscode", "mcp.json");
return configureMcpAtPath(mcpFile, mcpEnabled, "servers", "${workspaceFolder}/.phren");
}
const probe = probeVSCodePath();
if (!probe.installed || !probe.targetDir)
return "no_vscode";
const mcpFile = path.join(probe.targetDir, "mcp.json");
return configureMcpAtPath(mcpFile, mcpEnabled, "servers", phrenPath);
}
export function configureCursorMcp(phrenPath, opts = {}) {
const mcpEnabled = opts.mcpEnabled ?? getMcpEnabledPreference(phrenPath);
const resolved = resolveCursorMcpConfig(commandExists);
if (!resolved.installed)
return "no_cursor";
return configureMcpAtPath(resolved.target, mcpEnabled, "mcpServers", phrenPath);
}
export function configureCopilotMcp(phrenPath, opts = {}) {
const mcpEnabled = opts.mcpEnabled ?? getMcpEnabledPreference(phrenPath);
const resolved = resolveCopilotMcpConfig(commandExists);
if (!resolved.installed)
return "no_copilot";
let status = "already_disabled";
if (resolved.hasCliDir) {
status = configureMcpAtPath(resolved.cliConfig, mcpEnabled, "mcpServers", phrenPath);
}
if (resolved.existing && resolved.existing !== resolved.cliConfig) {
status = configureMcpAtPath(resolved.existing, mcpEnabled, "mcpServers", phrenPath);
}
if (!resolved.hasCliDir && !resolved.existing) {
status = configureMcpAtPath(resolved.cliConfig, mcpEnabled, "mcpServers", phrenPath);
}
return status;
}
export function configureCodexMcp(phrenPath, opts = {}) {
const mcpEnabled = opts.mcpEnabled ?? getMcpEnabledPreference(phrenPath);
const resolved = resolveCodexMcpConfig(phrenPath, commandExists);
if (!resolved.installed)
return "no_codex";
if (resolved.preferToml) {
return patchTomlMcpServer(resolved.tomlPath, mcpEnabled, phrenPath);
}
return configureMcpAtPath(resolved.existingJson, mcpEnabled, "mcpServers", phrenPath);
}
export function logMcpTargetStatus(tool, status, phase = "Configured") {
const text = {
installed: `${phase} ${tool} MCP`,
already_configured: `${tool} MCP already configured`,
disabled: `${tool} MCP disabled`,
already_disabled: `${tool} MCP already disabled`,
no_settings: `${tool} settings not found`,
no_vscode: `${tool} not detected`,
no_cursor: `${tool} not detected`,
no_copilot: `${tool} not detected`,
no_codex: `${tool} not detected`,
};
if (text[status])
log(` ${text[status]}`);
}