UNPKG

@phren/cli

Version:

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

224 lines (223 loc) • 9.2 kB
/** * Guards against `phren init` silently repointing the user's global wiring * (CLI wrapper at ~/.local/bin/phren and Claude settings.json hooks/MCP) at a * different phren root than the one currently in use. * * Why: `phren init` is also exercised by tests/smoke scripts that invoke it * with `PHREN_PATH=/tmp/...`. Without an isolated $HOME the install logic * cheerfully rewrites the user's real wrapper and Claude hooks to point at * the throwaway path, which then disappears with `rm -rf /tmp/foo`. The next * Claude session boots with a SessionStart hook that throws * `NOT_FOUND: phren root not found. Run 'phren init'.` * * Behavior: before any global file is rewritten, scan the known locations. If * any of them already references a *different* path that resolves to a valid * phren root, refuse to proceed unless `--force` is passed. Stale wiring * (existing path missing or not a phren root) is not a conflict — init is the * right tool to repair it. * * Scope, stated precisely because the module name reads broader than it is: * this guards `phren init` only, and only the four locations enumerated in * findConflictingGlobalWiring — the CLI wrapper, settings.json mcpServers, * settings.json hooks, and (added later) the ~/.claude/CLAUDE.md symlink. * * It is NOT a guard on every user-level file phren writes. In particular the * home skill symlinks under ~/.claude/skills are not covered, and * `repairPreexistingInstall()` — which rewrites the same global surfaces and * runs on every SessionStart hook, from the web UI, and from `phren doctor` — * never calls into this module at all. The AGENTS.md symlink case is handled * at its own write site in init/setup.ts (repairGlobalClaudeSymlink) using the * helpers exported here, so it holds on those paths too. */ import * as fs from "fs"; import * as path from "path"; import { homePath, isRecord } from "../shared.js"; import { hookConfigPath } from "../provider-adapters.js"; const WRAPPER_POSIX_RE = /PHREN_PATH="\$\{PHREN_PATH:-([^}]+)\}"/; const WRAPPER_WIN_RE = /set "PHREN_PATH=([^"]+)"/; const HOOK_PHREN_PATH_RE = /PHREN_PATH=(?:'([^']+)'|"([^"]+)"|(\S+))/; function samePath(a, b) { return path.resolve(a) === path.resolve(b); } /** * A path counts as a "valid phren root" only if it currently looks like one * — root manifest, machines.yaml, or the global skills tree. We deliberately * tolerate partial roots so a clean `phren init` repair still works. */ export function looksLikePhrenRoot(candidate) { if (!fs.existsSync(candidate)) return false; if (fs.existsSync(path.join(candidate, "phren.root.yaml"))) return true; if (fs.existsSync(path.join(candidate, "machines.yaml"))) return true; if (fs.existsSync(path.join(candidate, "global"))) return true; return false; } /** * True when `candidate` is a *different* phren root than the one being * installed and still looks live. That combination is the one case where * rewriting a global file destroys wiring someone is actually using; a stale * root (deleted, or never a root) is exactly what init exists to repair. */ export function isLiveForeignPhrenRoot(candidate, newPhrenPath) { if (samePath(candidate, newPhrenPath)) return false; return looksLikePhrenRoot(candidate); } /** * Recover the phren root that owns a `~/.claude/CLAUDE.md` symlink, from the * link target's shape (`<root>/global/AGENTS.md`). Returns null when the * target is not structured like a phren global file, i.e. it belongs to * something that is not phren and must not be touched. */ export function phrenRootFromGlobalClaudeLink(target) { const resolved = path.resolve(target); if (!["AGENTS.md", "CLAUDE.md"].includes(path.basename(resolved))) return null; const globalDir = path.dirname(resolved); if (path.basename(globalDir) !== "global") return null; const root = path.dirname(globalDir); return root && root !== globalDir ? root : null; } /** * The phren root that `~/.claude/CLAUDE.md` currently points at, or null when * the file is absent, a regular file, or a symlink into something that isn't * a phren store. */ function readGlobalClaudeMdRoot() { const dest = homePath(".claude", "CLAUDE.md"); let target; try { if (!fs.lstatSync(dest).isSymbolicLink()) return null; target = path.resolve(path.dirname(dest), fs.readlinkSync(dest)); } catch { return null; } return phrenRootFromGlobalClaudeLink(target); } function readWrapperPath() { const wrapperName = process.platform === "win32" ? "phren.cmd" : "phren"; const wrapperFile = path.join(homePath(".local", "bin"), wrapperName); if (!fs.existsSync(wrapperFile)) return null; let content; try { content = fs.readFileSync(wrapperFile, "utf8"); } catch { return null; } if (!content.includes("PHREN_CLI_WRAPPER")) return null; const re = process.platform === "win32" ? WRAPPER_WIN_RE : WRAPPER_POSIX_RE; const m = content.match(re); return m?.[1] ?? null; } function extractHookPhrenPath(command) { const m = command.match(HOOK_PHREN_PATH_RE); if (!m) return null; return m[1] ?? m[2] ?? m[3] ?? null; } function readClaudeSettings() { const settingsPath = hookConfigPath("claude"); if (!fs.existsSync(settingsPath)) return null; try { return JSON.parse(fs.readFileSync(settingsPath, "utf8")); } catch { return null; } } export function findConflictingGlobalWiring(newPhrenPath) { const conflicts = []; const seen = new Set(); const record = (location, existingPath) => { if (samePath(existingPath, newPhrenPath)) return; if (!looksLikePhrenRoot(existingPath)) return; const key = `${location}:${path.resolve(existingPath)}`; if (seen.has(key)) return; seen.add(key); conflicts.push({ location, existingPath }); }; const wrapperPath = readWrapperPath(); if (wrapperPath) { record(`~/.local/bin/${process.platform === "win32" ? "phren.cmd" : "phren"} wrapper`, wrapperPath); } // ~/.claude/CLAUDE.md is a symlink into <root>/global/AGENTS.md, i.e. it is // global wiring exactly like the wrapper is — but it was not scanned, so // `phren init` with a throwaway PHREN_PATH would repoint the user's real one // and the guard would report no conflict. const claudeMdRoot = readGlobalClaudeMdRoot(); if (claudeMdRoot) { record("~/.claude/CLAUDE.md symlink", claudeMdRoot); } const settings = readClaudeSettings(); if (isRecord(settings)) { const mcpServers = settings.mcpServers; if (isRecord(mcpServers)) { const phrenServer = mcpServers.phren; if (isRecord(phrenServer) && Array.isArray(phrenServer.args) && phrenServer.args.length > 0) { const last = phrenServer.args[phrenServer.args.length - 1]; if (typeof last === "string") { record("~/.claude/settings.json mcpServers.phren", last); } } } const hooks = settings.hooks; if (isRecord(hooks)) { for (const eventName of ["UserPromptSubmit", "Stop", "SessionStart", "PostToolUse"]) { const eventHooks = hooks[eventName]; if (!Array.isArray(eventHooks)) continue; for (const entry of eventHooks) { if (!isRecord(entry)) continue; const inner = entry.hooks; if (!Array.isArray(inner)) continue; for (const h of inner) { if (!isRecord(h)) continue; const command = typeof h.command === "string" ? h.command : ""; if (!command) continue; const extracted = extractHookPhrenPath(command); if (extracted) { record(`~/.claude/settings.json hooks.${eventName}`, extracted); } } } } } } return conflicts; } export function assertNoGlobalWiringConflict(newPhrenPath, force) { if (force) return; const conflicts = findConflictingGlobalWiring(newPhrenPath); if (conflicts.length === 0) return; const lines = [ `phren init: refusing to repoint global wiring at ${newPhrenPath}.`, "", "Existing files reference a different phren root that still looks valid:", ]; for (const c of conflicts) { lines.push(` - ${c.location} → ${c.existingPath}`); } lines.push(""); lines.push("If you intend to switch the global wiring to the new path, re-run with --force."); lines.push("If you're running a smoke test, set HOME (and XDG_*) to a sandbox before invoking init."); throw new Error(lines.join("\n")); }