@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
1,025 lines (1,024 loc) • 46.3 kB
JavaScript
import { nonInteractiveGitEnv } from "../utils-helpers.js";
import { moduleEnabled, moduleSnapshot } from "../modules/runtime.js";
import { skillEnabled, reconcileStarterSkills, starterInstructions } from "../modules/provision.js";
/**
* Governance files, root file migration, verification, starter templates, bootstrap.
*/
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
import * as yaml from "js-yaml";
import { loadYamlDocument } from "../phren-core.js";
import { atomicWriteText, debugLog, hookConfigPath, EXEC_TIMEOUT_QUICK_MS, readRootManifest, sessionsDir, runtimeHealthFile, isRecord, } from "../shared.js";
import { ensurePrivateDir, homePath } from "../phren-paths.js";
import { isLiveForeignPhrenRoot, phrenRootFromGlobalClaudeLink } from "./guard-globals.js";
import { resolveWorktreeParent } from "../git-worktree.js";
import { listProfiles, resolveActiveProfile, setMachineProfile } from "../profile-store.js";
import { getMachineName } from "../machine-identity.js";
import { execFileSync } from "child_process";
import { GOVERNANCE_SCHEMA_VERSION, } from "../shared/governance.js";
import { errorMessage } from "../utils.js";
import { ROOT, STARTER_DIR, VERSION, resolveEntryScript, commandVersion, versionAtLeast, nearestWritableTarget } from "./shared.js";
import { readInstallPreferences } from "./preferences.js";
import { getManagementPreset, resolveManagementCapabilities, } from "./management-preset.js";
import { TASKS_FILENAME } from "../data/tasks.js";
import { writeSkillMd } from "../link/skills.js";
import { syncScopeSkillsToDir } from "../skill/files.js";
import { isCopilotInstalled } from "../hooks.js";
import { logger } from "../logger.js";
import { migrateStoreAgentInstructions } from "../agent-instructions.js";
import { bootstrapFromExisting, ensureProjectScaffold, inferInitScaffoldFromRepo, } from "../core/project-registry.js";
export { bootstrapFromExisting, ensureProjectScaffold, inferInitScaffoldFromRepo, };
const LEGACY_SAMPLE_PROJECTS = new Set(["my-api", "my-frontend"]);
function normalizeProjects(raw) {
if (!Array.isArray(raw))
return [];
return raw.map((entry) => String(entry));
}
/**
* The sample names are also ordinary repo names, so the name alone is not
* evidence. phren stopped shipping the sample directories: an entry with no
* project directory in the store is the leftover sample, one with a directory
* is a real project.
*/
function profileLooksRealProject(phrenPath, project) {
if (project === "global" || !LEGACY_SAMPLE_PROJECTS.has(project))
return true;
return fs.existsSync(path.join(phrenPath, project));
}
function pruneLegacySampleProjectsFromProfiles(phrenPath) {
const profilesDir = path.join(phrenPath, "profiles");
if (!fs.existsSync(profilesDir))
return { filesUpdated: 0, removed: 0 };
let filesUpdated = 0;
let removed = 0;
for (const file of fs.readdirSync(profilesDir)) {
if (!file.endsWith(".yaml"))
continue;
const fullPath = path.join(profilesDir, file);
try {
const parsed = loadYamlDocument(fs.readFileSync(fullPath, "utf8"), (text) => yaml.load(text, { schema: yaml.CORE_SCHEMA }));
if (!isRecord(parsed))
continue;
const originalProjects = normalizeProjects(parsed.projects);
const nextProjects = originalProjects.filter((project) => profileLooksRealProject(phrenPath, project));
if (nextProjects.length === originalProjects.length)
continue;
removed += originalProjects.length - nextProjects.length;
const nextData = { ...parsed, projects: nextProjects };
atomicWriteText(fullPath, yaml.dump(nextData, { lineWidth: 1000 }));
filesUpdated++;
}
catch (err) {
debugLog(`pruneLegacySampleProjectsFromProfiles failed for ${fullPath}: ${errorMessage(err)}`);
}
}
return { filesUpdated, removed };
}
function claudeProjectKeyForHome(home) {
return home.replace(/[/\\:]/g, "-").replace(/^-/, "");
}
export function resolvePreferredHomeDir(phrenPath) {
const scoreAgentFootprint = (candidate) => {
let score = 0;
if (fs.existsSync(path.join(candidate, ".claude")))
score += 1;
if (fs.existsSync(path.join(candidate, ".claude", "settings.json")))
score += 2;
if (fs.existsSync(path.join(candidate, ".claude", "projects")))
score += 4;
if (fs.existsSync(path.join(candidate, ".phren-context.md")))
score += 3;
return score;
};
const resolvedHome = process.env.HOME?.trim() ? path.resolve(process.env.HOME) : undefined;
const resolvedUserProfile = process.env.USERPROFILE?.trim() ? path.resolve(process.env.USERPROFILE) : undefined;
// In devcontainers and WSL, HOME can be an ephemeral shim while USERPROFILE is
// the stable agent home. Prefer USERPROFILE when both differ and USERPROFILE
// already has an agent footprint.
if (resolvedHome && resolvedUserProfile && resolvedHome !== resolvedUserProfile) {
if (scoreAgentFootprint(resolvedUserProfile) > 0)
return resolvedUserProfile;
}
// An explicit HOME/USERPROFILE is authoritative. Only fall back to
// os.homedir() and the store's parent when neither is set — otherwise a
// caller that deliberately points HOME elsewhere (the test suite, a
// sandbox) loses the footprint contest to the developer's real home and
// we write symlinks into it.
const candidates = [
resolvedHome,
resolvedUserProfile,
...(resolvedHome || resolvedUserProfile
? []
: [path.resolve(os.homedir()), path.resolve(path.dirname(phrenPath))]),
].filter((entry) => Boolean(entry && entry.trim()));
const unique = [...new Set(candidates)];
let bestPath = null;
let bestScore = 0;
for (const candidate of unique) {
const score = scoreAgentFootprint(candidate);
if (score > bestScore) {
bestScore = score;
bestPath = candidate;
}
}
if (bestPath)
return bestPath;
return unique[0] ?? os.homedir();
}
function ensureGeneratedContextFile(home) {
const contextFile = path.join(home, ".phren-context.md");
if (fs.existsSync(contextFile))
return false;
atomicWriteText(contextFile, [
"<!-- phren-managed -->",
"# phren context",
"Machine/profile context will be refreshed on the next link/init pass.",
"<!-- phren-managed -->",
"",
].join("\n"));
return true;
}
function ensureGeneratedRootMemory(home) {
const memoryFile = path.join(home, ".claude", "projects", claudeProjectKeyForHome(home), "memory", "MEMORY.md");
if (fs.existsSync(memoryFile))
return false;
atomicWriteText(memoryFile, [
"# Root Memory",
"",
"## Machine Context",
"Read `~/.phren-context.md` for profile, active projects, and sync metadata.",
"",
"<!-- phren:projects:start -->",
"<!-- Auto-generated by phren init/doctor repair. -->",
"",
"## Active Projects",
"",
"| Project | What | Memory |",
"|---------|------|--------|",
"",
"<!-- phren:projects:end -->",
"",
].join("\n"));
return true;
}
function ensureGlobalStarterAssets(phrenPath) {
const created = [];
const starterGlobal = path.join(STARTER_DIR, "global");
if (!fs.existsSync(starterGlobal))
return created;
const targetGlobalDir = path.join(phrenPath, "global");
fs.mkdirSync(targetGlobalDir, { recursive: true });
const starterClaude = path.join(starterGlobal, "AGENTS.md");
const targetClaude = path.join(targetGlobalDir, "AGENTS.md");
if (fs.existsSync(starterClaude) && !fs.existsSync(targetClaude)) {
fs.copyFileSync(starterClaude, targetClaude);
created.push("global/AGENTS.md");
}
const starterSkillsDir = path.join(starterGlobal, "skills");
const targetSkillsDir = path.join(targetGlobalDir, "skills");
fs.mkdirSync(targetSkillsDir, { recursive: true });
if (fs.existsSync(starterSkillsDir)) {
for (const entry of fs.readdirSync(starterSkillsDir, { withFileTypes: true })) {
if (!skillEnabled(phrenPath, entry.name))
continue;
const source = path.join(starterSkillsDir, entry.name);
const target = path.join(targetSkillsDir, entry.name);
if (entry.isFile() && entry.name.endsWith(".md")) {
if (fs.existsSync(target))
continue;
fs.copyFileSync(source, target);
created.push(path.join("global", "skills", entry.name));
}
else if (entry.isDirectory() && fs.existsSync(path.join(source, "SKILL.md"))) {
if (fs.existsSync(target))
continue;
copyDirRecursive(source, target);
created.push(path.join("global", "skills", entry.name) + path.sep);
}
}
}
return created;
}
function copyDirRecursive(src, dest) {
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
const s = path.join(src, entry.name);
const d = path.join(dest, entry.name);
if (entry.isDirectory())
copyDirRecursive(s, d);
else if (entry.isFile())
fs.copyFileSync(s, d);
}
}
function ensureRuntimeAssets(phrenPath) {
const created = [];
// Both directories hold per-user private data (credential store, debug and
// hook-error logs, session transcripts) and are gitignored by the store
// template. ensurePrivateDir creates them 0700 and tightens an existing
// 0755 one left behind by an older install.
const runtimeDir = path.join(phrenPath, ".runtime");
const runtimeExisted = fs.existsSync(runtimeDir);
ensurePrivateDir(runtimeDir);
if (!runtimeExisted)
created.push(".runtime/");
const sessions = sessionsDir(phrenPath);
const sessionsExisted = fs.existsSync(sessions);
ensurePrivateDir(sessions);
if (!sessionsExisted)
created.push(".sessions/");
return created;
}
function ensureDefaultFeatureFlags(phrenPath, preset) {
const created = [];
const envPath = path.join(phrenPath, ".env");
const header = "# phren feature flags — generated by init\n";
let content = fs.existsSync(envPath) ? fs.readFileSync(envPath, "utf8") : header;
let changed = !fs.existsSync(envPath);
const lines = content.split("\n");
// Manual preset opts out of prompt-time auto-capture by default.
const autoCaptureDefault = preset === "manual" ? "0" : "1";
const hasAutoCaptureFlag = lines.some((line) => line.trimStart().startsWith("PHREN_FEATURE_AUTO_CAPTURE="));
if (!hasAutoCaptureFlag) {
if (!content.endsWith("\n"))
content += "\n";
content += `PHREN_FEATURE_AUTO_CAPTURE=${autoCaptureDefault}\n`;
changed = true;
created.push(`.env:PHREN_FEATURE_AUTO_CAPTURE=${autoCaptureDefault}`);
}
if (changed) {
atomicWriteText(envPath, content);
}
return created;
}
function ensureGeneratedSkillArtifacts(phrenPath, preferredHome) {
const created = [];
const homeClaudeDir = path.join(preferredHome, ".claude");
const globalSkillsDir = path.join(homeClaudeDir, "skills");
const manifestPath = path.join(homeClaudeDir, "skill-manifest.json");
const commandsPath = path.join(homeClaudeDir, "skill-commands.json");
const hadManifest = fs.existsSync(manifestPath);
const hadCommands = fs.existsSync(commandsPath);
try {
syncScopeSkillsToDir(phrenPath, "global", globalSkillsDir);
if (!hadManifest && fs.existsSync(manifestPath))
created.push("~/.claude/skill-manifest.json");
if (!hadCommands && fs.existsSync(commandsPath))
created.push("~/.claude/skill-commands.json");
}
catch (err) {
debugLog(`ensureGeneratedSkillArtifacts: global skill mirror sync failed: ${errorMessage(err)}`);
}
// The standard user skill directory is how Codex discovers folder skills.
const nativeSkillsDir = path.join(preferredHome, ".agents", "skills");
const hadNativeSkillsDir = fs.existsSync(nativeSkillsDir);
try {
syncScopeSkillsToDir(phrenPath, "global", nativeSkillsDir);
if (!hadNativeSkillsDir)
created.push("~/.agents/skills/");
}
catch (err) {
debugLog(`ensureGeneratedSkillArtifacts: native skill mirror sync failed: ${errorMessage(err)}`);
}
const copilotSkillsDir = path.join(preferredHome, ".copilot", "skills");
const hadCopilotSkillsDir = fs.existsSync(copilotSkillsDir);
if (isCopilotInstalled()) {
try {
syncScopeSkillsToDir(phrenPath, "global", copilotSkillsDir);
if (!hadCopilotSkillsDir && fs.existsSync(copilotSkillsDir))
created.push("~/.copilot/skills/");
}
catch (err) {
debugLog(`ensureGeneratedSkillArtifacts: copilot skill mirror sync failed: ${errorMessage(err)}`);
}
}
const skillMdPath = path.join(phrenPath, "phren.SKILL.md");
const hadSkillMd = fs.existsSync(skillMdPath);
try {
writeSkillMd(phrenPath);
if (!hadSkillMd && fs.existsSync(skillMdPath))
created.push("phren.SKILL.md");
}
catch (err) {
debugLog(`ensureGeneratedSkillArtifacts: writeSkillMd failed: ${errorMessage(err)}`);
}
return created;
}
export function ensureGitignoreEntry(repoRoot, entry) {
const gitignorePath = path.join(repoRoot, ".gitignore");
const normalizedEntry = entry.trim();
if (!normalizedEntry)
return false;
let content = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, "utf8") : "";
const lines = content.split("\n").map((line) => line.trim());
if (lines.includes(normalizedEntry))
return false;
if (content && !content.endsWith("\n"))
content += "\n";
content += `${normalizedEntry}\n`;
atomicWriteText(gitignorePath, content);
return true;
}
export function upsertProjectEnvVar(repoRoot, key, value) {
const envPath = path.join(repoRoot, ".env");
const normalizedKey = key.trim();
if (!normalizedKey)
return false;
const nextLine = `${normalizedKey}=${value}`;
let content = fs.existsSync(envPath) ? fs.readFileSync(envPath, "utf8") : "";
const lineRe = new RegExp(`^\\s*${normalizedKey}=.*$`, "m");
if (lineRe.test(content)) {
const updated = content.replace(lineRe, nextLine);
if (updated === content)
return false;
atomicWriteText(envPath, updated);
return true;
}
if (content && !content.endsWith("\n"))
content += "\n";
content += `${nextLine}\n`;
atomicWriteText(envPath, content);
return true;
}
export function repairPreexistingInstall(phrenPath, opts) {
// Resolve the effective management capabilities. Callers that are applying a
// not-yet-persisted preset (e.g. init) pass caps explicitly; the every-session
// self-heal path resolves from stored preferences.
const caps = opts?.caps ?? resolveManagementCapabilities(phrenPath);
const preset = opts?.preset ?? getManagementPreset(phrenPath);
moduleSnapshot(phrenPath);
reconcileStarterSkills(phrenPath, STARTER_DIR);
const createdGovernanceAssets = ensureGovernanceFiles(phrenPath);
const migratedInstructions = migrateStoreAgentInstructions(phrenPath);
const createdGlobalAssets = [...migratedInstructions, ...ensureGlobalStarterAssets(phrenPath)];
const createdRuntimeAssets = [...createdGovernanceAssets, ...ensureRuntimeAssets(phrenPath)];
const createdFeatureDefaults = ensureDefaultFeatureFlags(phrenPath, preset);
const profileRepair = pruneLegacySampleProjectsFromProfiles(phrenPath);
const preferredHome = resolvePreferredHomeDir(phrenPath);
// Structural surfaces (home skills symlinks + ~/.claude/CLAUDE.md) are only
// created/self-healed under presets that opt into them.
const createdSkillArtifacts = caps.installSkillLinks
? ensureGeneratedSkillArtifacts(phrenPath, preferredHome)
: [];
const repairedGlobalSymlink = caps.linkGlobalClaudeMd ? repairGlobalClaudeSymlink(phrenPath) : false;
return {
profileFilesUpdated: profileRepair.filesUpdated,
removedLegacyProjects: profileRepair.removed,
// Both live in the user's home, so they are self-heal surfaces: `assisted`
// and `manual` do not re-create them.
createdContextFile: caps.selfHeal ? ensureGeneratedContextFile(preferredHome) : false,
createdRootMemory: caps.selfHeal ? ensureGeneratedRootMemory(preferredHome) : false,
createdGlobalAssets,
createdRuntimeAssets,
createdFeatureDefaults,
createdSkillArtifacts,
repairedGlobalSymlink,
};
}
/**
* Re-create the ~/.claude/CLAUDE.md symlink if the source exists but the link
* is missing or broken.
*
* The ownership test used to be `target.includes(".phren") ||
* target.endsWith("global/AGENTS.md")`, which treats *any* live phren root's
* global file as fair game to unlink. That is how a run with
* `PHREN_PATH=/tmp/…` — a smoke test, or phren's own web UI — leaves the
* user's real `~/.claude/CLAUDE.md` pointing into a temp directory that then
* disappears, and every later Claude session starts with no global context.
*
* assertNoGlobalWiringConflict does not save us here: it guards `phren init`,
* and this function is reached from repairPreexistingInstall(), which runs on
* every SessionStart hook (cli/session-start.ts), from the web UI
* (ui/server.ts) and from `phren doctor` (link/doctor.ts) — none of which
* consult the guard. So the check has to live at the write site.
*
* New rule: a symlink into a *different* root that still looks live is left
* alone. Stale wiring (the root is gone, or was never a root) is still
* repaired, which is the case this function exists for.
*/
function repairGlobalClaudeSymlink(phrenPath) {
const src = path.join(phrenPath, "global", "AGENTS.md");
if (!fs.existsSync(src))
return false;
const dest = homePath(".claude", "CLAUDE.md");
try {
const stat = fs.lstatSync(dest);
if (stat.isSymbolicLink()) {
const target = path.resolve(path.dirname(dest), fs.readlinkSync(dest));
if (target === path.resolve(src))
return false; // already correct
const owningRoot = phrenRootFromGlobalClaudeLink(target);
if (owningRoot && isLiveForeignPhrenRoot(owningRoot, phrenPath)) {
debugLog(`refusing to repoint ~/.claude/CLAUDE.md: still owned by live phren root ${owningRoot}`);
return false;
}
// Stale phren wiring — safe to replace. `.includes(".phren")` stays for
// links that are not shaped like <root>/global/AGENTS.md.
if (owningRoot || target.includes(".phren"))
fs.unlinkSync(dest);
else
return false; // not ours, don't touch
}
else {
return false; // regular file exists, don't clobber
}
}
catch (err) {
if (err.code !== "ENOENT")
return false;
}
try {
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.symlinkSync(src, dest);
debugLog(`repaired global AGENTS.md symlink: ${dest} -> ${src}`);
return true;
}
catch (err) {
debugLog(`failed to repair global AGENTS.md symlink: ${errorMessage(err)}`);
return false;
}
}
function isExpectedVerifyFailure(phrenPath, check) {
if (check.ok)
return false;
const prefs = readInstallPreferences(phrenPath);
// git-remote failure is only expected when the user chose local-only (no clone URL)
if (check.name === "git-remote")
return prefs.syncIntent !== "sync";
if (check.name === "mcp-config" && prefs.mcpEnabled === false)
return true;
if (check.name === "hooks-registered" && prefs.hooksEnabled === false)
return true;
return false;
}
export function getVerifyOutcomeNote(phrenPath, checks) {
const failures = checks.filter((check) => !check.ok);
if (failures.length === 0)
return null;
const expectedFailures = failures.filter((check) => isExpectedVerifyFailure(phrenPath, check));
if (expectedFailures.length === 0)
return null;
if (expectedFailures.length === failures.length) {
return "Setup looks usable in local-only / hooks-only mode; remaining issues are optional sync or MCP checks.";
}
return "Some reported issues are optional for your chosen install mode; review git-remote / MCP failures separately from hard failures.";
}
export function getHookEntrypointCheck(deps = {}) {
const pathExists = deps.pathExists ?? fs.existsSync;
const versionReader = deps.versionReader ?? commandVersion;
const distIndex = resolveEntryScript();
const localEntrypointOk = pathExists(distIndex);
const hookEntrypointOk = localEntrypointOk || Boolean(versionReader("npx", ["--version"]));
const detail = localEntrypointOk
? "Hook entrypoint available via local dist/index.js"
: hookEntrypointOk
? "Hook entrypoint available via npx fallback"
: "Hook entrypoint missing and npx unavailable, hooks will fail";
return {
name: "hook-entrypoint",
ok: hookEntrypointOk,
detail,
fix: hookEntrypointOk ? undefined : "Rebuild phren: `npm run build` or reinstall the package, and ensure npm/npx is available for hook fallbacks",
};
}
function gitRemoteStatus(phrenPath) {
try {
execFileSync("git", ["-C", phrenPath, "rev-parse", "--is-inside-work-tree"], {
env: nonInteractiveGitEnv(),
stdio: ["ignore", "ignore", "ignore"],
timeout: EXEC_TIMEOUT_QUICK_MS,
});
}
catch {
return { ok: false, detail: "phren path is not a git repository" };
}
let remote;
try {
remote = execFileSync("git", ["-C", phrenPath, "remote", "get-url", "origin"], {
env: nonInteractiveGitEnv(),
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: EXEC_TIMEOUT_QUICK_MS,
}).trim();
if (!remote)
return { ok: false, detail: "git origin remote not configured" };
}
catch {
return { ok: false, detail: "git origin remote not configured" };
}
// Connectivity test: verify the remote is reachable (10s timeout)
try {
execFileSync("git", ["-C", phrenPath, "ls-remote", "--exit-code", "origin"], {
env: nonInteractiveGitEnv(),
stdio: ["ignore", "ignore", "ignore"],
timeout: 10_000,
});
return { ok: true, detail: `origin=${remote}` };
}
catch {
return { ok: false, detail: `origin=${remote} (configured but unreachable)` };
}
}
function copyStarterFile(phrenPath, src, dest) {
fs.mkdirSync(path.dirname(dest), { recursive: true });
if (!fs.existsSync(dest)) {
fs.copyFileSync(src, dest);
return dest;
}
const existing = fs.readFileSync(dest);
const incoming = fs.readFileSync(src);
if (existing.equals(incoming)) {
return null;
}
const relative = path.relative(phrenPath, dest);
const stagingDir = path.join(phrenPath, ".runtime", "starter-updates", path.dirname(relative));
fs.mkdirSync(stagingDir, { recursive: true });
const currentPath = path.join(stagingDir, `${path.basename(dest)}.current`);
const stagedPath = path.join(stagingDir, `${path.basename(dest)}.new`);
fs.copyFileSync(dest, currentPath);
fs.copyFileSync(src, stagedPath);
return stagedPath;
}
export function applyStarterTemplateUpdates(phrenPath) {
const updates = [];
const starterGlobal = path.join(STARTER_DIR, "global");
if (!fs.existsSync(starterGlobal))
return updates;
const starterClaude = path.join(starterGlobal, "AGENTS.md");
const targetClaude = path.join(phrenPath, "global", "AGENTS.md");
if (fs.existsSync(starterClaude)) {
const written = copyStarterFile(phrenPath, starterClaude, targetClaude);
if (written)
updates.push(path.relative(phrenPath, written));
}
const starterSkillsDir = path.join(starterGlobal, "skills");
const targetSkillsDir = path.join(phrenPath, "global", "skills");
if (fs.existsSync(starterSkillsDir)) {
fs.mkdirSync(targetSkillsDir, { recursive: true });
for (const f of fs.readdirSync(starterSkillsDir, { withFileTypes: true })) {
if (!skillEnabled(phrenPath, f.name))
continue;
const src = path.join(starterSkillsDir, f.name);
const dest = path.join(targetSkillsDir, f.name);
if (f.isFile() && f.name.endsWith(".md")) {
const written = copyStarterFile(phrenPath, src, dest);
if (written)
updates.push(path.relative(phrenPath, written));
}
else if (f.isDirectory() && fs.existsSync(path.join(src, "SKILL.md"))) {
for (const inner of walkSkillFolder(src)) {
const written = copyStarterFile(phrenPath, path.join(src, inner), path.join(dest, inner));
if (written)
updates.push(path.relative(phrenPath, written));
}
}
}
}
return updates;
}
function walkSkillFolder(root, prefix = "") {
const out = [];
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
const rel = path.join(prefix, entry.name);
const abs = path.join(root, entry.name);
if (entry.isDirectory())
out.push(...walkSkillFolder(abs, rel));
else if (entry.isFile())
out.push(rel);
}
return out;
}
export function ensureGovernanceFiles(phrenPath) {
const created = [];
const govDir = path.join(phrenPath, ".config");
if (!fs.existsSync(govDir))
created.push(".config/");
fs.mkdirSync(govDir, { recursive: true });
const sv = GOVERNANCE_SCHEMA_VERSION;
const policy = path.join(govDir, "retention-policy.json");
const workflow = path.join(govDir, "workflow-policy.json");
const indexPolicy = path.join(govDir, "index-policy.json");
const runtimeHealth = runtimeHealthFile(phrenPath);
if (!fs.existsSync(policy)) {
atomicWriteText(policy, JSON.stringify({
schemaVersion: sv,
ttlDays: 120,
retentionDays: 365,
autoAcceptThreshold: 0.75,
minInjectConfidence: 0.35,
decay: { d30: 1.0, d60: 0.85, d90: 0.65, d120: 0.45 },
}, null, 2) + "\n");
created.push(".config/retention-policy.json");
}
if (!fs.existsSync(workflow)) {
atomicWriteText(workflow, JSON.stringify({
schemaVersion: sv,
lowConfidenceThreshold: 0.7,
riskySections: ["Stale", "Conflicts"],
taskMode: "auto",
}, null, 2) + "\n");
created.push(".config/workflow-policy.json");
}
if (!fs.existsSync(indexPolicy)) {
atomicWriteText(indexPolicy, JSON.stringify({
schemaVersion: sv,
includeGlobs: ["**/*.md", "**/skills/**/*.md", ".claude/skills/**/*.md"],
excludeGlobs: ["**/.git/**", "**/node_modules/**", "**/dist/**", "**/build/**"],
includeHidden: false,
}, null, 2) + "\n");
created.push(".config/index-policy.json");
}
if (!fs.existsSync(runtimeHealth)) {
atomicWriteText(runtimeHealth, JSON.stringify({ schemaVersion: sv }, null, 2) + "\n");
created.push(".runtime/runtime-health.json");
}
else {
try {
const current = JSON.parse(fs.readFileSync(runtimeHealth, "utf8"));
if (current && typeof current === "object" && !Array.isArray(current)) {
const existingSchema = typeof current.schemaVersion === "number" ? current.schemaVersion : 0;
if (existingSchema < sv) {
atomicWriteText(runtimeHealth, JSON.stringify({ ...current, schemaVersion: sv }, null, 2) + "\n");
}
}
}
catch (err) {
debugLog(`ensureGovernanceFiles: malformed runtime health file, leaving untouched: ${errorMessage(err)}`);
}
}
return created;
}
const TEMPLATES_DIR = path.join(ROOT, "starter", "templates");
export function listTemplates() {
if (!fs.existsSync(TEMPLATES_DIR))
return [];
return fs.readdirSync(TEMPLATES_DIR, { withFileTypes: true })
.filter(d => d.isDirectory())
.map(d => d.name)
.sort();
}
export function applyTemplate(projectDir, templateName, projectName) {
const templateDir = path.join(TEMPLATES_DIR, templateName);
if (!fs.existsSync(templateDir))
return false;
fs.mkdirSync(projectDir, { recursive: true });
function copyTemplateDir(srcDir, destDir) {
fs.mkdirSync(destDir, { recursive: true });
for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
if (entry.name === TASKS_FILENAME && !moduleEnabled(path.dirname(projectDir), "tasks"))
continue;
const src = path.join(srcDir, entry.name);
const dest = path.join(destDir, entry.name);
if (entry.isDirectory()) {
copyTemplateDir(src, dest);
}
else {
let content = fs.readFileSync(src, "utf8");
if (entry.name === "AGENTS.md")
content = starterInstructions(path.dirname(projectDir), content);
content = content.replace(/\{\{project\}\}/g, projectName);
content = content.replace(/\{\{date\}\}/g, new Date().toISOString().slice(0, 10));
atomicWriteText(dest, content);
}
}
}
copyTemplateDir(templateDir, projectDir);
return true;
}
export function ensureLocalGitRepo(phrenPath) {
// Check if phrenPath already has its own git repo (not just being inside a parent)
try {
const topLevel = execFileSync("git", ["-C", phrenPath, "rev-parse", "--show-toplevel"], {
env: nonInteractiveGitEnv(),
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: EXEC_TIMEOUT_QUICK_MS,
}).trim();
// Normalize both paths: resolve symlinks (macOS /var→/private/var) and
// on Windows resolve 8.3 short names (RUNNER~1→runneradmin) + case-insensitive
const realpath = process.platform === "win32" ? fs.realpathSync.native : fs.realpathSync;
let resolvedTopLevel = realpath(path.resolve(topLevel));
let resolvedPhrenPath = realpath(path.resolve(phrenPath));
if (process.platform === "win32") {
resolvedTopLevel = resolvedTopLevel.toLowerCase();
resolvedPhrenPath = resolvedPhrenPath.toLowerCase();
}
if (resolvedTopLevel === resolvedPhrenPath) {
// phrenPath IS the repo root — it has its own git repo
return { ok: true, initialized: false, detail: "existing git repo" };
}
// phrenPath is inside a parent repo — skip nested init
logger.warn("init", `Skipping git init: ${resolvedPhrenPath} is inside existing repo ${resolvedTopLevel}`);
return { ok: true, initialized: false, detail: `skipped: inside existing repo ${resolvedTopLevel}` };
}
catch {
// Not inside any git repo — fall through to initialization below.
}
try {
try {
execFileSync("git", ["-C", phrenPath, "init", "--initial-branch=main"], {
env: nonInteractiveGitEnv(),
stdio: ["ignore", "ignore", "ignore"],
timeout: EXEC_TIMEOUT_QUICK_MS,
});
}
catch {
execFileSync("git", ["-C", phrenPath, "init"], {
env: nonInteractiveGitEnv(),
stdio: ["ignore", "ignore", "ignore"],
timeout: EXEC_TIMEOUT_QUICK_MS,
});
try {
execFileSync("git", ["-C", phrenPath, "branch", "-M", "main"], {
env: nonInteractiveGitEnv(),
stdio: ["ignore", "ignore", "ignore"],
timeout: EXEC_TIMEOUT_QUICK_MS,
});
}
catch {
// Older git versions may not support renaming immediately here.
}
}
return { ok: true, initialized: true, detail: "initialized local git repo" };
}
catch (err) {
return { ok: false, initialized: false, detail: `git init failed: ${errorMessage(err)}` };
}
}
export function updateMachinesYaml(phrenPath, machine, profile) {
const machinesFile = path.join(phrenPath, "machines.yaml");
if (!fs.existsSync(machinesFile))
return;
const machineName = (machine?.trim() || getMachineName()).trim();
if (!machineName)
return;
const activeProfile = resolveActiveProfile(phrenPath, profile);
const profileName = profile?.trim() || (activeProfile.ok ? (activeProfile.data || "") : "") || "personal";
let hasExistingMapping = false;
try {
const loaded = loadYamlDocument(fs.readFileSync(machinesFile, "utf8"), (text) => yaml.load(text, { schema: yaml.CORE_SCHEMA }));
if (loaded && typeof loaded === "object" && !Array.isArray(loaded)) {
hasExistingMapping = Object.prototype.hasOwnProperty.call(loaded, machineName);
}
}
catch (err) {
// Not "no existing mapping": setMachineProfile refuses to rewrite the file,
// and the user needs to hear why the mapping did not change.
logger.debug("setup", `updateMachinesYaml parse: ${errorMessage(err)}`);
console.warn(` ${machinesFile} could not be parsed; machine mappings left unchanged. Fix it by hand (check for git conflict markers), then run 'phren init' again.`);
return;
}
// Passive init/link refreshes should keep an existing mapping; explicit overrides can remap.
if (hasExistingMapping && !machine && !profile)
return;
const mapping = setMachineProfile(phrenPath, machineName, profileName);
if (!mapping.ok)
logger.debug("setup", `updateMachinesYaml setMachineProfile: ${mapping.error}`);
}
/**
* Detect if a directory looks like a project that should be bootstrapped.
* Returns the path if it qualifies, null otherwise.
* A directory qualifies if it:
* - Is not the home directory or phren directory
* - Has an AGENTS.md, legacy CLAUDE.md, .claude/CLAUDE.md, or .git directory
*
* A git worktree resolves to the repository it belongs to. Without this a
* throwaway agent worktree under `.claude/worktrees/<codename>` looks like its
* own repo (it has a `.git` entry) and gets offered as a new project.
*/
export function detectProjectDir(dir, phrenPath) {
const home = os.homedir();
const tmpRoot = path.resolve(os.tmpdir());
const resolvedPhrenPath = path.resolve(phrenPath);
const worktree = resolveWorktreeParent(dir);
let current = worktree && fs.existsSync(worktree.repoRoot)
? worktree.repoRoot
: path.resolve(dir);
while (true) {
// Never treat the shared OS temp root itself as a project. Tools may drop
// global instruction files there, which would otherwise hijack detection
// for arbitrary temp subdirectories.
if (current === tmpRoot)
return null;
if (current === home || current === resolvedPhrenPath)
return null;
if (current.startsWith(resolvedPhrenPath + path.sep))
return null;
const hasClaude = fs.existsSync(path.join(current, "CLAUDE.md")) ||
fs.existsSync(path.join(current, ".claude", "CLAUDE.md"));
const hasAgents = fs.existsSync(path.join(current, "AGENTS.md"));
const hasGit = fs.existsSync(path.join(current, ".git"));
if (hasClaude || hasAgents || hasGit)
return current;
const parent = path.dirname(current);
if (parent === current || parent === home)
break;
current = parent;
}
return null;
}
/**
* Check if a project name is already tracked in any profile.
*/
export function isProjectTracked(phrenPath, projectName, profile) {
const profiles = listProfiles(phrenPath);
if (profiles.ok) {
if (profile) {
return profiles.data.some((entry) => entry.name === profile && entry.projects.includes(projectName));
}
return profiles.data.some((entry) => entry.projects.includes(projectName));
}
const projDir = path.join(phrenPath, projectName);
return fs.existsSync(projDir);
}
export function runPostInitVerify(phrenPath) {
const checks = [];
const prefs = readInstallPreferences(phrenPath);
const manifest = readRootManifest(phrenPath);
const gitVersion = commandVersion("git");
// The CLI is running on the node we are checking, so read the version in
// process instead of paying for another `node --version` spawn (expensive on
// Windows, and init already runs enough processes).
const nodeVersion = process.version;
checks.push({
name: "git-installed",
ok: Boolean(gitVersion),
detail: gitVersion || "git not found in PATH",
fix: gitVersion ? undefined : "Install git and re-run `phren init`.",
});
checks.push({
name: "node-version",
ok: versionAtLeast(nodeVersion, 22, 19),
detail: nodeVersion || "node not found in PATH",
fix: versionAtLeast(nodeVersion, 22, 19) ? undefined : "Install Node.js 22.19+ before using phren.",
});
if (manifest?.installMode === "project-local") {
checks.push({
name: "workspace-root",
ok: Boolean(manifest.workspaceRoot && fs.existsSync(manifest.workspaceRoot)),
detail: manifest.workspaceRoot ? `workspace root: ${manifest.workspaceRoot}` : "workspaceRoot missing from phren.root.yaml",
fix: manifest.workspaceRoot ? undefined : "Re-run `phren init --mode project-local` to repair the root manifest.",
});
checks.push({
name: "hooks-registered",
ok: prefs.hooksEnabled === false,
detail: "hooks are unsupported in project-local mode",
fix: prefs.hooksEnabled === false ? undefined : "Run `phren hooks-mode off` and keep hooks disabled in project-local mode.",
});
const workspaceMcp = manifest.workspaceRoot ? path.join(manifest.workspaceRoot, ".vscode", "mcp.json") : "";
let workspaceMcpOk = false;
try {
if (workspaceMcp && fs.existsSync(workspaceMcp)) {
const cfg = JSON.parse(fs.readFileSync(workspaceMcp, "utf8"));
workspaceMcpOk = Boolean(cfg.servers?.phren);
}
}
catch (err) {
debugLog(`doctor local workspace mcp parse failed: ${errorMessage(err)}`);
}
checks.push({
name: "mcp-config",
ok: prefs.mcpEnabled === false ? true : workspaceMcpOk,
detail: prefs.mcpEnabled === false
? "workspace MCP disabled by preference"
: workspaceMcpOk
? "VS Code workspace MCP registered"
: "VS Code workspace MCP not found in .vscode/mcp.json",
fix: prefs.mcpEnabled === false ? undefined : "Run `phren mcp-mode on` to register the VS Code workspace server.",
});
}
else {
const gitRemote = gitRemoteStatus(phrenPath);
const wantSync = prefs.syncIntent === "sync";
const gitRemoteDetail = gitRemote.ok
? gitRemote.detail
: wantSync
? `${gitRemote.detail} — sync was configured but remote is missing or unreachable`
: `${gitRemote.detail} (optional unless you want cross-machine sync)`;
const gitRemoteFix = gitRemote.ok
? undefined
: wantSync
? `Your clone URL didn't work. Fix: cd ${phrenPath} && git remote add origin <URL> && git push -u origin main`
: "Optional: initialize a repo and add an origin remote for cross-machine sync.";
checks.push({
name: "git-remote",
ok: gitRemote.ok,
detail: gitRemoteDetail,
fix: gitRemoteFix,
});
const settingsPath = hookConfigPath("claude");
const configWritable = nearestWritableTarget(settingsPath);
checks.push({
name: "config-writable",
ok: configWritable,
detail: configWritable ? `writable: ${settingsPath}` : `not writable: ${settingsPath}`,
fix: configWritable ? undefined : "Fix permissions for ~/.claude or its settings.json before enabling hooks/MCP.",
});
let mcpOk = false;
let hooksOk = false;
try {
const cfg = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
mcpOk = Boolean(cfg.mcpServers?.phren);
const hooks = cfg.hooks || {};
const hasPrompt = JSON.stringify(hooks.UserPromptSubmit || []).includes("hook-prompt");
const hasStop = JSON.stringify(hooks.Stop || []).includes("hook-stop");
const hasStart = JSON.stringify(hooks.SessionStart || []).includes("hook-session-start");
hooksOk = hasPrompt && hasStop && hasStart;
}
catch (err) {
debugLog(`doctor: settings.json missing or unreadable: ${errorMessage(err)}`);
}
checks.push({
name: "mcp-config",
ok: mcpOk,
detail: mcpOk
? "MCP server registered in Claude settings"
: prefs.mcpEnabled === false
? "MCP server not found in ~/.claude/settings.json (expected while MCP mode is OFF)"
: "MCP server not found in ~/.claude/settings.json",
fix: mcpOk
? undefined
: prefs.mcpEnabled === false
? "Optional: run `phren mcp-mode on` or `phren init` if you want MCP enabled."
: "Run `phren init` to register the MCP server",
});
checks.push({
name: "hooks-registered",
ok: hooksOk,
detail: hooksOk
? "All lifecycle hooks registered"
: prefs.hooksEnabled === false
? "One or more hooks missing from ~/.claude/settings.json (expected while hooks mode is OFF)"
: "One or more hooks missing from ~/.claude/settings.json",
fix: hooksOk
? undefined
: prefs.hooksEnabled === false
? "Optional: run `phren hooks-mode on` or `phren init` if you want hooks enabled."
: "Run `phren init` to install or refresh hooks",
});
}
const globalClaude = path.join(phrenPath, "global", "AGENTS.md");
const globalOk = fs.existsSync(globalClaude);
checks.push({
name: "global-claude",
ok: globalOk,
detail: globalOk ? "global/AGENTS.md exists" : "global/AGENTS.md missing",
fix: globalOk ? undefined : "Run `phren init` to create starter files",
});
const govDir = path.join(phrenPath, ".config");
const govOk = fs.existsSync(govDir);
checks.push({
name: "config",
ok: govOk,
detail: govOk ? ".config/ config directory exists" : ".config/ config directory missing",
fix: govOk ? undefined : "Run `phren init` to create governance config",
});
const installedPrefs = readInstallPreferences(phrenPath);
const installedVersion = installedPrefs.installedVersion;
const versionOk = !govOk || installedVersion === VERSION;
checks.push({
name: "installed-version",
ok: versionOk,
detail: installedVersion
? (versionOk ? `install metadata matches running version (${VERSION})` : `install metadata is ${installedVersion}, runtime is ${VERSION}`)
: "install metadata missing installedVersion",
fix: versionOk ? undefined : "Run `phren update` or `phren init` to refresh install metadata.",
});
let ftsOk = false;
try {
const entries = fs.readdirSync(phrenPath, { withFileTypes: true });
ftsOk = entries.some(d => d.isDirectory() && !d.name.startsWith("."));
}
catch (err) {
logger.debug("setup", `runPostInitVerify projectScan: ${errorMessage(err)}`);
ftsOk = false;
}
checks.push({
name: "fts-index",
ok: ftsOk,
detail: ftsOk ? "Project directories found for indexing" : "No project directories found in phren path",
fix: ftsOk ? undefined : "Create a project: `cd ~/your-project && phren add`",
});
checks.push(getHookEntrypointCheck());
// Check that the CLI wrapper at ~/.local/bin/phren exists and is executable (soft check — not mandatory)
const cliWrapperPath = homePath(".local", "bin", "phren");
let cliWrapperOk = false;
try {
const stat = fs.statSync(cliWrapperPath);
cliWrapperOk = stat.isFile();
if (cliWrapperOk)
fs.accessSync(cliWrapperPath, fs.constants.X_OK);
}
catch {
cliWrapperOk = false;
}
checks.push({
name: "cli-wrapper",
ok: true, // always pass — wrapper is optional (global install or npx work too)
detail: cliWrapperOk
? `CLI wrapper exists: ${cliWrapperPath}`
: `CLI wrapper not found (optional — use 'npx @phren/cli' instead)`,
});
const ok = checks.every((c) => c.ok);
return { ok, checks };
}