@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
560 lines (559 loc) • 24.8 kB
JavaScript
import { nonInteractiveGitEnv } from "../utils-helpers.js";
import { reconcileModuleHooks } from "../bridge/install.js";
import * as fs from "fs";
import * as path from "path";
import * as readline from "readline";
import * as yaml from "js-yaml";
import { loadYamlDocument } from "../phren-core.js";
import { execFileSync } from "child_process";
import { ROOT } from "../package-metadata.js";
import { configureMcpTargets, ensureGovernanceFiles, getHooksEnabledPreference, getMcpEnabledPreference, isVersionNewer, patchJsonFile, setMcpEnabledPreference, } from "../init/init.js";
import { configureAllHooks, detectInstalledTools } from "../hooks.js";
import { getMachineName, persistMachineName } from "../machine-identity.js";
import { debugLog, EXEC_TIMEOUT_MS, EXEC_TIMEOUT_QUICK_MS, isRecord, homePath, hookConfigPath, installPreferencesFile, atomicWriteText, } from "../shared.js";
import { errorMessage } from "../utils.js";
import { FINDINGS_FILENAME } from "../data/access.js";
import { ROOT_MANIFEST_FILENAME } from "../phren-paths.js";
import { STORES_FILENAME } from "../store-registry.js";
import { log } from "../init/shared.js";
import { listMachines as listMachinesShared, listProfiles as listProfilesShared, setMachineProfile, } from "../profile-store.js";
import { writeSkillMd, isManagedSymlink } from "./skills.js";
import { resolveManagementCapabilities } from "../init/management-preset.js";
import { syncScopeSkillsToDir } from "../skill/files.js";
import { findProjectDir } from "../project-locator.js";
import { getProjectOwnershipMode, readProjectConfig, } from "../project-config.js";
import { writeContextDefault, writeContextDebugging, writeContextPlanning, writeContextClean, readBackNativeMemory, rebuildMemory, } from "./context.js";
import { logger } from "../logger.js";
// Re-export sub-modules so existing imports from "./link.js" continue to work
export { runDoctor } from "./doctor.js";
export { updateFileChecksums, verifyFileChecksums } from "./checksums.js";
export { findProjectDir } from "../project-locator.js";
export { parseSkillFrontmatter, validateSkillFrontmatter, validateSkillsDir, readSkillManifestHooks, } from "./skills.js";
// ── Helpers (exported for link-doctor) ──────────────────────────────────────
export { getMachineName } from "../machine-identity.js";
export function lookupProfile(phrenPath, machine) {
const listed = listMachinesShared(phrenPath);
if (!listed.ok)
return "";
return listed.data[machine] || "";
}
function listProfiles(phrenPath) {
const listed = listProfilesShared(phrenPath);
if (!listed.ok)
return [];
return listed.data.map((profile) => ({ name: profile.name }));
}
export function findProfileFile(phrenPath, profileName) {
const profilesDir = path.join(phrenPath, "profiles");
if (!fs.existsSync(profilesDir))
return null;
for (const f of fs.readdirSync(profilesDir)) {
if (!f.endsWith(".yaml"))
continue;
const data = loadYamlDocument(fs.readFileSync(path.join(profilesDir, f), "utf8"), (text) => yaml.load(text, { schema: yaml.CORE_SCHEMA }));
if (data?.name === profileName)
return path.join(profilesDir, f);
}
return null;
}
export function getProfileProjects(profileFile) {
const data = loadYamlDocument(fs.readFileSync(profileFile, "utf8"), (text) => yaml.load(text, { schema: yaml.CORE_SCHEMA }));
return Array.isArray(data?.projects) ? data.projects : [];
}
function currentPackageVersion() {
try {
const pkgPath = path.join(ROOT, "package.json");
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
return pkg.version || null;
}
catch (err) {
debugLog(`currentPackageVersion: failed to read package.json: ${errorMessage(err)}`);
return null;
}
}
function maybeOfferStarterTemplateUpdate(phrenPath) {
const current = currentPackageVersion();
if (!current)
return;
const prefsPath = installPreferencesFile(phrenPath);
if (!fs.existsSync(prefsPath))
return;
try {
const prefs = JSON.parse(fs.readFileSync(prefsPath, "utf8"));
if (isVersionNewer(current, prefs.installedVersion)) {
log(` Starter template update available: v${prefs.installedVersion} -> v${current}`);
log(` Run \`phren init --apply-starter-update\` to refresh global/AGENTS.md and global skills.`);
}
}
catch (err) {
debugLog(`checkStarterVersionUpdate: failed to read preferences: ${errorMessage(err)}`);
}
}
// ── Machine registration ────────────────────────────────────────────────────
async function registerMachine(phrenPath) {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const ask = (q) => new Promise(r => rl.question(q, r));
log("This machine isn't registered with phren yet.\n");
const machine = (await ask("What should this machine be called? (e.g. work-desktop): ")).trim();
if (!machine) {
rl.close();
throw new Error("Machine name can't be empty.");
}
log("\nAvailable profiles:");
for (const p of listProfiles(phrenPath))
log(` ${p.name}`);
log("");
const profile = (await ask("Which profile? ")).trim();
rl.close();
if (!profile)
throw new Error("Profile name can't be empty.");
if (!findProfileFile(phrenPath, profile))
throw new Error(`No profile named '${profile}' found.`);
const mapResult = setMachineProfile(phrenPath, machine, profile);
if (!mapResult.ok)
throw new Error(mapResult.error);
persistMachineName(machine);
log(`\nRegistered ${machine} with profile ${profile}.`);
return { machine, profile };
}
// ── Sparse checkout ─────────────────────────────────────────────────────────
function setupSparseCheckout(phrenPath, projects) {
try {
execFileSync("git", ["rev-parse", "--git-dir"], { env: nonInteractiveGitEnv(), cwd: phrenPath, stdio: "ignore", timeout: EXEC_TIMEOUT_QUICK_MS });
}
catch (err) {
logger.debug("link", `setupSparseCheckout notAGitRepo: ${errorMessage(err)}`);
return;
}
// Root-level config the CLI needs on disk regardless of profile. Omitting these
// leaves them tracked-but-unmaterialized: the MCP entrypoint can't find the root
// manifest (so MCP never starts) and a missing stores.yaml silently degrades to a
// single implicit store, hiding every team store.
const alwaysInclude = [
"profiles",
"machines.yaml",
"global",
"scripts",
"link.sh",
"README.md",
".gitignore",
ROOT_MANIFEST_FILENAME,
STORES_FILENAME,
];
const paths = [...alwaysInclude, ...projects];
try {
execFileSync("git", ["sparse-checkout", "set", ...paths], { env: nonInteractiveGitEnv(), cwd: phrenPath, stdio: "ignore", timeout: EXEC_TIMEOUT_MS });
execFileSync("git", ["pull", "--ff-only"], { env: nonInteractiveGitEnv(), cwd: phrenPath, stdio: "ignore", timeout: EXEC_TIMEOUT_MS });
}
catch (err) {
debugLog(`setupSparseCheckout: git sparse-checkout or pull failed: ${errorMessage(err)}`);
}
}
// ── Symlink helpers ─────────────────────────────────────────────────────────
/** Add entries to .git/info/exclude so phren-managed symlinks don't pollute git status.
* Skips files already tracked by git to avoid hiding user-owned content. */
function addGitExcludes(projectDir, entries) {
const gitDir = path.join(projectDir, ".git");
if (!fs.existsSync(gitDir))
return;
try {
// Filter out files already tracked by git — exclude only affects untracked files,
// and adding tracked files could confuse users who version-control their own AGENTS.md
let tracked;
try {
const out = execFileSync("git", ["ls-files", "--", ...entries], {
env: nonInteractiveGitEnv(),
cwd: projectDir,
timeout: EXEC_TIMEOUT_QUICK_MS,
encoding: "utf8",
});
tracked = new Set(out.split("\n").map((l) => l.trim()).filter(Boolean));
}
catch {
tracked = new Set();
}
const safe = entries.filter((e) => !tracked.has(e));
if (safe.length === 0)
return;
const excludePath = path.join(gitDir, "info", "exclude");
fs.mkdirSync(path.join(gitDir, "info"), { recursive: true });
const existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : "";
const existingLines = new Set(existing.split("\n").map((l) => l.trim()));
const toAdd = safe.filter((e) => !existingLines.has(e));
if (toAdd.length === 0)
return;
const marker = "# phren-managed";
const needsMarker = !existingLines.has(marker);
const suffix = (needsMarker ? `\n${marker}\n` : "\n") + toAdd.join("\n") + "\n";
fs.appendFileSync(excludePath, suffix);
}
catch {
// git not available or fs issue — silently skip
}
}
function symlinkFile(src, dest, managedRoot) {
try {
const stat = fs.lstatSync(dest);
if (stat.isSymbolicLink()) {
const currentTarget = fs.readlinkSync(dest);
const resolvedTarget = path.resolve(path.dirname(dest), currentTarget);
if (resolvedTarget === path.resolve(src))
return true;
if (!isManagedSymlink(dest, managedRoot)) {
log(` preserve existing symlink: ${dest}`);
return false;
}
fs.unlinkSync(dest);
}
else {
try {
const existing = stat.isFile() ? fs.readFileSync(dest, "utf8") : "";
const isOldGeneratedAgents = path.basename(dest) === "AGENTS.md"
&& existing.includes("<!-- phren:generated-agents -->");
if (stat.isFile() && (existing === fs.readFileSync(src, "utf8") || isOldGeneratedAgents)) {
fs.unlinkSync(dest);
}
else {
const kind = stat.isDirectory() ? "directory" : "file";
log(` preserve existing ${kind}: ${dest}`);
return false;
}
}
catch {
log(` preserve existing file: ${dest}`);
return false;
}
}
}
catch (err) {
if (err.code !== "ENOENT")
throw err;
}
fs.symlinkSync(src, dest);
return true;
}
function addTokenAnnotation(filePath) {
const content = fs.readFileSync(filePath, "utf8");
if (content.startsWith("<!-- tokens:"))
return;
const tokens = Math.round(content.length / 3.5 + (content.match(/\s+/g) || []).length * 0.1);
if (tokens <= 500)
return;
const rounded = Math.round((tokens + 50) / 100) * 100;
atomicWriteText(filePath, `<!-- tokens: ~${rounded} -->\n${content}`);
}
// ── Linking operations ──────────────────────────────────────────────────────
function linkGlobal(phrenPath, tools, caps) {
if (caps.installSkillLinks) {
log(" global skills -> ~/.claude/skills/");
const skillsDir = homePath(".claude", "skills");
syncScopeSkillsToDir(phrenPath, "global", skillsDir);
log(" global skills -> ~/.agents/skills/");
syncScopeSkillsToDir(phrenPath, "global", homePath(".agents", "skills"));
if (tools.has("copilot")) {
const copilotSkillsDir = homePath(".copilot", "skills");
log(" global skills -> ~/.copilot/skills/");
try {
syncScopeSkillsToDir(phrenPath, "global", copilotSkillsDir);
}
catch (err) {
logger.debug("link", `linkGlobal copilotSkills: ${errorMessage(err)}`);
}
}
}
if (caps.linkGlobalClaudeMd) {
const globalClaude = path.join(phrenPath, "global", "AGENTS.md");
if (fs.existsSync(globalClaude)) {
symlinkFile(globalClaude, homePath(".claude", "CLAUDE.md"), phrenPath);
if (tools.has("copilot")) {
try {
const copilotInstrDir = homePath(".github");
fs.mkdirSync(copilotInstrDir, { recursive: true });
symlinkFile(globalClaude, path.join(copilotInstrDir, "copilot-instructions.md"), phrenPath);
}
catch (err) {
logger.debug("link", `linkGlobal copilotInstructions: ${errorMessage(err)}`);
}
}
}
}
if (!caps.installSkillLinks && !caps.linkGlobalClaudeMd) {
log(" global symlinks skipped (management preset does not write into ~/.claude)");
}
}
function linkProject(phrenPath, project, tools, caps) {
const config = readProjectConfig(phrenPath, project);
const ownership = getProjectOwnershipMode(phrenPath, project, config);
const target = findProjectDir(project);
// Presets that opt out of repo mirroring still register per-project MCP
// servers, but never write into the repo itself.
if (!caps.repoMirroring) {
if (target) {
log(` ${project} -> ${target} (repo mirrors disabled by preset)`);
}
else {
log(` ${project} (repo mirrors disabled by preset)`);
}
if (isRecord(config.mcpServers)) {
linkProjectMcpServers(project, config.mcpServers);
}
return;
}
if (!target && ownership === "phren-managed") {
log(` skip ${project} (not found on disk)`);
if (isRecord(config.mcpServers)) {
linkProjectMcpServers(project, config.mcpServers);
}
return;
}
if (ownership !== "phren-managed") {
if (target) {
log(` ${project} -> ${target} (${ownership}, repo mirrors disabled)`);
}
else {
log(` ${project} (${ownership}, repo mirrors disabled)`);
}
if (isRecord(config.mcpServers)) {
linkProjectMcpServers(project, config.mcpServers);
}
return;
}
if (!target)
return;
log(` ${project} -> ${target}`);
const excludeEntries = [];
for (const f of ["AGENTS.md", "REFERENCE.md", FINDINGS_FILENAME]) {
const src = path.join(phrenPath, project, f);
if (fs.existsSync(src)) {
if (symlinkFile(src, path.join(target, f), phrenPath))
excludeEntries.push(f);
if (f === "AGENTS.md") {
if (tools.has("claude")) {
try {
if (symlinkFile(src, path.join(target, "CLAUDE.md"), phrenPath))
excludeEntries.push("CLAUDE.md");
}
catch (err) {
logger.debug("link", `linkProject claudeInstructions: ${errorMessage(err)}`);
}
}
if (tools.has("copilot")) {
try {
const copilotDir = path.join(target, ".github");
fs.mkdirSync(copilotDir, { recursive: true });
symlinkFile(src, path.join(copilotDir, "copilot-instructions.md"), phrenPath);
}
catch (err) {
logger.debug("link", `linkProject copilotInstructions: ${errorMessage(err)}`);
}
}
}
}
}
// CLAUDE-*.md split files
const projectDir = path.join(phrenPath, project);
if (fs.existsSync(projectDir)) {
for (const f of fs.readdirSync(projectDir)) {
if (/^CLAUDE-.+\.md$/.test(f)) {
if (symlinkFile(path.join(projectDir, f), path.join(target, f), phrenPath))
excludeEntries.push(f);
}
}
}
// Token annotation on AGENTS.md
const claudeFile = path.join(phrenPath, project, "AGENTS.md");
if (fs.existsSync(claudeFile)) {
try {
addTokenAnnotation(claudeFile);
}
catch (err) {
logger.debug("link", `linkProject tokenAnnotation: ${errorMessage(err)}`);
}
}
// Project-level skills
const targetSkills = path.join(target, ".claude", "skills");
if (config.skills !== false)
syncScopeSkillsToDir(phrenPath, project, targetSkills);
// Auto-exclude phren-managed files from git status
if (excludeEntries.length > 0)
addGitExcludes(target, excludeEntries);
// Per-project MCP servers
if (isRecord(config.mcpServers)) {
linkProjectMcpServers(project, config.mcpServers);
}
}
/**
* Merge per-project MCP servers into Claude's settings.json.
* Keys are namespaced as "phren__<project>__<name>" so we can identify
* and clean them up without touching user-managed servers.
*/
function linkProjectMcpServers(project, servers) {
const settingsPath = hookConfigPath("claude");
if (!fs.existsSync(settingsPath) && Object.keys(servers).length === 0)
return;
try {
patchJsonFile(settingsPath, (data) => {
const mcpServers = isRecord(data.mcpServers) ? data.mcpServers : (data.mcpServers = {});
// Remove stale entries for this project (keys we previously wrote)
for (const key of Object.keys(mcpServers)) {
if (key.startsWith(`phren__${project}__`))
delete mcpServers[key];
}
// Add current entries
for (const [name, entry] of Object.entries(servers)) {
const key = `phren__${project}__${name}`;
const server = { command: entry.command };
if (Array.isArray(entry.args))
server.args = entry.args;
if (entry.env && typeof entry.env === "object")
server.env = entry.env;
mcpServers[key] = server;
}
});
}
catch (err) {
debugLog(`linkProjectMcpServers: failed for ${project}: ${errorMessage(err)}`);
}
}
/** Remove any phren__<project>__* MCP entries for projects no longer in the active set. */
function pruneStaleProjectMcpServers(activeProjects) {
const settingsPath = hookConfigPath("claude");
if (!fs.existsSync(settingsPath))
return;
try {
patchJsonFile(settingsPath, (data) => {
const mcpServers = isRecord(data.mcpServers) ? data.mcpServers : undefined;
if (!mcpServers)
return;
for (const key of Object.keys(mcpServers)) {
if (!key.startsWith("phren__"))
continue;
// Key format: phren__<project>__<name>
const parts = key.split("__");
if (parts.length < 3)
continue;
const project = parts[1];
if (!activeProjects.includes(project)) {
delete mcpServers[key];
debugLog(`pruneStaleProjectMcpServers: removed stale entry "${key}"`);
}
}
});
}
catch (err) {
debugLog(`pruneStaleProjectMcpServers: failed: ${errorMessage(err)}`);
}
}
// ── Main orchestrator ───────────────────────────────────────────────────────
export async function runLink(phrenPath, opts = {}) {
log("phren link\n");
ensureGovernanceFiles(phrenPath);
// Step 1: Identify machine + profile
let machine = opts.machine ?? getMachineName();
let profile = "";
if (opts.profile) {
profile = opts.profile;
}
else if (opts.register) {
const reg = await registerMachine(phrenPath);
machine = reg.machine;
profile = reg.profile;
}
else {
profile = lookupProfile(phrenPath, machine);
if (!profile) {
const reg = await registerMachine(phrenPath);
machine = reg.machine;
profile = reg.profile;
}
}
if (!profile)
throw new Error(`Could not determine profile for machine '${machine}'.`);
persistMachineName(machine);
// Step 2: Find profile file
const profileFile = findProfileFile(phrenPath, profile);
if (!profileFile)
throw new Error(`Profile '${profile}' not found in profiles/.`);
log(`Machine: ${machine}`);
log(`Profile: ${profile} (${profileFile})\n`);
// Step 3: Read projects
const projects = getProfileProjects(profileFile);
if (!projects.length)
throw new Error(`Profile '${profile}' has no projects listed.`);
// Step 4: Sparse checkout
log("Setting up sparse checkout...");
setupSparseCheckout(phrenPath, projects);
log("");
// Detect installed tools once
const detectedTools = opts.allTools
? new Set(["copilot", "cursor", "codex"])
: detectInstalledTools();
const caps = resolveManagementCapabilities(phrenPath);
// Step 5: Symlink
log("Linking...");
linkGlobal(phrenPath, detectedTools, caps);
for (const p of projects) {
if (p !== "global")
linkProject(phrenPath, p, detectedTools, caps);
}
// Remove stale phren__<project>__* MCP entries for removed projects
pruneStaleProjectMcpServers(projects.filter(p => p !== "global"));
log("");
// Step 6: Configure MCP
log("Configuring MCP...");
const mcpEnabled = opts.mcp ? opts.mcp === "on" : getMcpEnabledPreference(phrenPath);
const hooksEnabled = getHooksEnabledPreference(phrenPath);
setMcpEnabledPreference(phrenPath, mcpEnabled);
log(` MCP mode: ${mcpEnabled ? "ON (recommended)" : "OFF (hooks-only fallback)"}`);
log(` Hooks mode: ${hooksEnabled ? "ON (active)" : "OFF (disabled)"}`);
maybeOfferStarterTemplateUpdate(phrenPath);
const mcpStatusForContext = configureMcpTargets(phrenPath, { mcpEnabled, hooksEnabled, caps });
// Register hooks for Copilot CLI, Cursor, Codex
if (hooksEnabled) {
const hookedTools = configureAllHooks(phrenPath, { tools: detectedTools, installWrappers: caps.installWrappers });
if (hookedTools.length)
log(` Hooks registered: ${hookedTools.join(", ")}`);
}
else {
log(` Hooks registration skipped (hooks-mode is off)`);
}
await reconcileModuleHooks(phrenPath, profile);
// Write phren.SKILL.md
try {
writeSkillMd(phrenPath);
log(` phren.SKILL.md written (agentskills-compatible tools)`);
}
catch (err) {
logger.debug("link", `link writeSkillMd: ${errorMessage(err)}`);
}
log("");
// Step 7: Context file. ~/.phren-context.md and the root MEMORY.md in Claude
// Code's memory directory are home surfaces: only presets with selfHeal
// (managed, or an explicit override) create them, matching repair and doctor.
if (!caps.selfHeal) {
log(" ~/.phren-context.md and Claude memory skipped (preset without self-heal)");
}
else if (opts.task === "debugging") {
writeContextDebugging(machine, profile, mcpStatusForContext, projects, phrenPath);
}
else if (opts.task === "planning") {
writeContextPlanning(machine, profile, mcpStatusForContext, projects, phrenPath);
}
else if (opts.task === "clean") {
writeContextClean(machine, profile, mcpStatusForContext, projects);
}
else {
writeContextDefault(machine, profile, mcpStatusForContext, projects, phrenPath);
}
// Step 8: Memory (read back native changes, then rebuild)
readBackNativeMemory(phrenPath, projects);
if (caps.selfHeal)
rebuildMemory(phrenPath, projects);
log(`\nDone. Profile '${profile}' is active.`);
if (opts.task)
log(`Task mode: ${opts.task}`);
log(`\nWhat's next:`);
log(` Start Claude in your project directory — phren injects context automatically.`);
log(` Run phren-discover after your first week to surface gaps in project knowledge.`);
log(` Run phren-consolidate after working across projects to find shared patterns.`);
}