@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
405 lines (404 loc) • 19.1 kB
JavaScript
import * as fs from "fs";
import * as path from "path";
import * as crypto from "crypto";
import * as yaml from "js-yaml";
import { loadYamlDocument } from "./phren-core.js";
import { phrenErr, PhrenError, phrenOk, forwardErr, getProjectDirs, readRootManifest, } from "./shared.js";
import { defaultMachineName, getMachineName } from "./machine-identity.js";
import { errorMessage, isValidProjectName } from "./utils.js";
import { FINDINGS_FILENAME, TASK_FILE_ALIASES } from "./filenames.js";
import { withSafeLock } from "./shared/data-utils.js";
import { logger } from "./logger.js";
import { VALID_FINDING_SENSITIVITY, VALID_PROACTIVITY_LEVELS, VALID_TASK_MODES, VALID_RISKY_SECTIONS, } from "./governance/policy-constants.js";
import { getNonPrimaryStores, getStoreProjectDirs } from "./store-registry.js";
export function resolveActiveProfile(phrenPath, requestedProfile) {
const manifest = readRootManifest(phrenPath);
if (manifest?.installMode === "project-local") {
return phrenOk(undefined);
}
if (requestedProfile) {
const profiles = listProfiles(phrenPath);
if (!profiles.ok)
return forwardErr(profiles);
const exists = profiles.data.some((entry) => entry.name === requestedProfile);
if (!exists)
return phrenErr(`Profile "${requestedProfile}" not found.`, PhrenError.NOT_FOUND);
return phrenOk(requestedProfile);
}
const machines = listMachines(phrenPath);
if (machines.ok) {
const profiles = listProfiles(phrenPath);
if (!profiles.ok)
return phrenOk(undefined);
const candidates = [getMachineName(), defaultMachineName()].filter((value, index, values) => value && values.indexOf(value) === index);
for (const machineName of candidates) {
const mapped = machines.data[machineName];
if (!mapped)
continue;
const exists = profiles.data.some((entry) => entry.name === mapped);
if (exists)
return phrenOk(mapped);
}
}
const profiles = listProfiles(phrenPath);
if (!profiles.ok)
return phrenOk(undefined);
const assumed = profiles.data[0]?.name;
if (assumed && profiles.data.length > 1 && process.env.PHREN_QUIET !== "1") {
const machine = getMachineName();
const key = JSON.stringify([phrenPath, machine, assumed]);
if (!warnedProfileAssumptions.has(key)) {
warnedProfileAssumptions.add(key);
process.stderr.write(`[phren] Machine "${machine}" is not mapped in machines.yaml; assuming profile "${assumed}". Run 'phren profile switch ${assumed}' to pin it.\n`);
}
}
return phrenOk(assumed);
}
const warnedProfileAssumptions = new Set();
/** Explain implicit selection without changing the selected profile. */
export function describeProfileMapping(phrenPath) {
const machine = getMachineName();
const machines = listMachines(phrenPath);
const profiles = listProfiles(phrenPath);
if (machines.ok && profiles.ok) {
for (const name of [machine, defaultMachineName()]) {
const mapped = machines.data[name];
if (mapped && profiles.data.some((entry) => entry.name === mapped))
return { machine, mapped: true };
}
}
return { machine, mapped: false, assumed: profiles.ok ? profiles.data[0]?.name : undefined };
}
export function getDefaultMachineAlias() {
return getMachineName();
}
export function listMachines(phrenPath) {
const machinesPath = path.join(phrenPath, "machines.yaml");
if (!fs.existsSync(machinesPath))
return phrenErr(`machines.yaml not found. Run 'phren init' to set up your phren.`, PhrenError.FILE_NOT_FOUND);
try {
const raw = fs.readFileSync(machinesPath, "utf8");
const parsed = loadYamlDocument(raw, (text) => yaml.load(text, { schema: yaml.CORE_SCHEMA }));
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
return phrenErr(`machines.yaml is empty or not valid YAML. Check the file format or run 'phren doctor --fix'.`, PhrenError.MALFORMED_YAML);
const cleaned = {};
for (const [machine, profile] of Object.entries(parsed)) {
if (typeof machine !== "string" || !machine.trim())
continue;
if (typeof profile !== "string" || !profile.trim())
continue;
cleaned[machine] = profile;
}
return phrenOk(cleaned);
}
catch (err) {
logger.debug("profile-store", `listMachines yaml parse: ${errorMessage(err)}`);
return phrenErr(`Could not parse machines.yaml. Check the file for syntax errors or run 'phren doctor --fix'.`, PhrenError.MALFORMED_YAML);
}
}
function writeMachines(phrenPath, data) {
const machinesPath = path.join(phrenPath, "machines.yaml");
const backupPath = `${machinesPath}.bak`;
const existing = fs.existsSync(machinesPath) ? fs.readFileSync(machinesPath, "utf8") : "";
if (fs.existsSync(machinesPath))
fs.copyFileSync(machinesPath, backupPath);
const ordered = Object.fromEntries(Object.entries(data).sort(([a], [b]) => a.localeCompare(b)));
const headerLines = [];
for (const line of existing.split("\n")) {
if (line.startsWith("#") || line.trim() === "") {
headerLines.push(line);
continue;
}
break;
}
const header = headerLines.length ? `${headerLines.join("\n")}\n` : "";
const tmpPath = `${machinesPath}.tmp-${crypto.randomUUID()}`;
fs.writeFileSync(tmpPath, header + yaml.dump(ordered, { lineWidth: 1000 }));
fs.renameSync(tmpPath, machinesPath);
}
export function setMachineProfile(phrenPath, machine, profile) {
if (!machine || !profile)
return phrenErr(`Both machine name and profile name are required. Example: :machine map my-laptop personal`, PhrenError.EMPTY_INPUT);
const profiles = listProfiles(phrenPath);
if (profiles.ok) {
const exists = profiles.data.some((entry) => entry.name === profile);
if (!exists)
return phrenErr(`Profile "${profile}" does not exist. Check available profiles in the profiles/ directory.`, PhrenError.NOT_FOUND);
}
const machinesPath = path.join(phrenPath, "machines.yaml");
return withSafeLock(machinesPath, () => {
const current = listMachines(phrenPath);
// writeMachines rebuilds the whole file, so rewriting one phren cannot parse
// from an empty map would drop every other machine's mapping. The file is
// git-synced, so conflict markers are the likely cause. A comments-only file
// (the shipped starter) also fails to parse and is fine to write into.
if (!current.ok && current.code !== PhrenError.FILE_NOT_FOUND && hasMachineEntries(machinesPath)) {
return phrenErr(`${machinesPath} could not be parsed, so phren will not rewrite it. Fix the file by hand (check for git conflict markers), then map again.`, PhrenError.MALFORMED_YAML);
}
const data = current.ok ? current.data : {};
data[machine] = profile;
writeMachines(phrenPath, data);
return phrenOk(`Mapped machine ${machine} -> ${profile}.`);
});
}
/** Does machines.yaml hold anything besides comments and blank lines? */
function hasMachineEntries(machinesPath) {
try {
return loadYamlDocument(fs.readFileSync(machinesPath, "utf8"), () => true) === true;
}
catch {
return false;
}
}
function pickEnumVal(value, allowed) {
return typeof value === "string" && allowed.includes(value) ? value : undefined;
}
function pickFiniteNum(value) {
return typeof value === "number" && isFinite(value) ? value : undefined;
}
function parseProfilePolicyDefaults(raw) {
if (!raw || typeof raw !== "object" || Array.isArray(raw))
return undefined;
const data = raw;
const retentionRaw = data.retentionPolicy && typeof data.retentionPolicy === "object" && !Array.isArray(data.retentionPolicy)
? data.retentionPolicy
: undefined;
const decayRaw = retentionRaw?.decay && typeof retentionRaw.decay === "object" && !Array.isArray(retentionRaw.decay)
? retentionRaw.decay
: undefined;
const workflowRaw = data.workflowPolicy && typeof data.workflowPolicy === "object" && !Array.isArray(data.workflowPolicy)
? data.workflowPolicy
: undefined;
const result = {};
const fs_ = pickEnumVal(data.findingSensitivity, VALID_FINDING_SENSITIVITY);
if (fs_)
result.findingSensitivity = fs_;
const pr = pickEnumVal(data.proactivity, VALID_PROACTIVITY_LEVELS);
if (pr)
result.proactivity = pr;
const prf = pickEnumVal(data.proactivityFindings, VALID_PROACTIVITY_LEVELS);
if (prf)
result.proactivityFindings = prf;
const prt = pickEnumVal(data.proactivityTask, VALID_PROACTIVITY_LEVELS);
if (prt)
result.proactivityTask = prt;
const tm = pickEnumVal(data.taskMode, VALID_TASK_MODES);
if (tm)
result.taskMode = tm;
if (retentionRaw) {
const ret = {};
const ttlDays = pickFiniteNum(retentionRaw.ttlDays);
if (ttlDays !== undefined)
ret.ttlDays = ttlDays;
const retentionDays = pickFiniteNum(retentionRaw.retentionDays);
if (retentionDays !== undefined)
ret.retentionDays = retentionDays;
const aat = pickFiniteNum(retentionRaw.autoAcceptThreshold);
if (aat !== undefined)
ret.autoAcceptThreshold = aat;
const mic = pickFiniteNum(retentionRaw.minInjectConfidence);
if (mic !== undefined)
ret.minInjectConfidence = mic;
if (decayRaw) {
const decay = {};
const d30 = pickFiniteNum(decayRaw.d30);
if (d30 !== undefined)
decay.d30 = d30;
const d60 = pickFiniteNum(decayRaw.d60);
if (d60 !== undefined)
decay.d60 = d60;
const d90 = pickFiniteNum(decayRaw.d90);
if (d90 !== undefined)
decay.d90 = d90;
const d120 = pickFiniteNum(decayRaw.d120);
if (d120 !== undefined)
decay.d120 = d120;
if (Object.keys(decay).length > 0)
ret.decay = decay;
}
if (Object.keys(ret).length > 0)
result.retentionPolicy = ret;
}
if (workflowRaw) {
const wf = {};
const lct = pickFiniteNum(workflowRaw.lowConfidenceThreshold);
if (lct !== undefined)
wf.lowConfidenceThreshold = lct;
if (Array.isArray(workflowRaw.riskySections)) {
const rs = workflowRaw.riskySections
.filter((s) => typeof s === "string" && VALID_RISKY_SECTIONS.includes(s));
if (rs.length > 0)
wf.riskySections = rs;
}
if (Object.keys(wf).length > 0)
result.workflowPolicy = wf;
}
return Object.keys(result).length > 0 ? result : undefined;
}
export function getActiveProfileDefaults(phrenPath, profile) {
const profiles = listProfiles(phrenPath);
if (!profiles.ok)
return undefined;
const activeName = profile ?? profiles.data[0]?.name;
if (!activeName)
return undefined;
return profiles.data.find((p) => p.name === activeName)?.defaults;
}
export function listProfiles(phrenPath) {
const profilesDir = path.join(phrenPath, "profiles");
if (!fs.existsSync(profilesDir))
return phrenErr(`No profiles/ directory found. Run 'phren init' to set up your phren.`, PhrenError.FILE_NOT_FOUND);
const files = fs.readdirSync(profilesDir).filter((file) => file.endsWith(".yaml")).sort();
const profiles = [];
for (const file of files) {
const full = path.join(profilesDir, file);
try {
const raw = fs.readFileSync(full, "utf8");
const parsed = loadYamlDocument(raw, (text) => yaml.load(text, { schema: yaml.CORE_SCHEMA }));
const data = parsed && typeof parsed === "object" && !Array.isArray(parsed)
? parsed
: null;
const name = (typeof data?.name === "string" && data.name.trim())
? data.name
: file.replace(/\.yaml$/, "");
const projects = Array.isArray(data?.projects)
? data.projects.map((project) => String(project)).filter(Boolean)
: [];
const description = typeof data?.description === "string" ? data.description : undefined;
const defaults = parseProfilePolicyDefaults(data?.defaults);
profiles.push({ name, ...(description ? { description } : {}), file: full, projects, ...(defaults ? { defaults } : {}) });
}
catch (err) {
logger.debug("profile-store", `listProfiles yamlParse: ${errorMessage(err)}`);
return phrenErr(`profiles/${file}`, PhrenError.MALFORMED_YAML);
}
}
return phrenOk(profiles);
}
/**
* Rewrite a profile's projects, keeping every other key (the `defaults:` policy
* block included) and the file's leading comment, the way writeMachines does.
*/
function writeProfile(file, name, projects, description) {
const backup = `${file}.bak`;
const existing = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
if (existing)
fs.copyFileSync(file, backup);
// listProfiles already refused an unparsable file, so this parse succeeds.
const parsed = existing ? loadYamlDocument(existing, (text) => yaml.load(text, { schema: yaml.CORE_SCHEMA })) : undefined;
const rest = parsed && typeof parsed === "object" && !Array.isArray(parsed)
? { ...parsed }
: {};
delete rest.name;
delete rest.description;
delete rest.projects;
const headerLines = [];
for (const line of existing.split("\n")) {
if (!line.startsWith("#") && line.trim() !== "")
break;
headerLines.push(line);
}
const header = headerLines.length ? `${headerLines.join("\n").replace(/\n+$/, "")}\n` : "";
const normalized = [...new Set(projects)].sort();
const out = yaml.dump({ name, ...(description ? { description } : {}), projects: normalized, ...rest }, { lineWidth: 1000 });
const tmpPath = `${file}.tmp-${crypto.randomUUID()}`;
fs.writeFileSync(tmpPath, header + out);
fs.renameSync(tmpPath, file);
}
export function addProjectToProfile(phrenPath, profile, project) {
if (!isValidProjectName(project))
return phrenErr(`Project name "${project}" is not valid. Use lowercase letters, numbers, and hyphens (e.g. "my-project").`, PhrenError.INVALID_PROJECT_NAME);
const profiles = listProfiles(phrenPath);
if (!profiles.ok)
return forwardErr(profiles);
const current = profiles.data.find((entry) => entry.name === profile);
if (!current)
return phrenErr(`Profile "${profile}" not found.`, PhrenError.NOT_FOUND);
return withSafeLock(current.file, () => {
const refreshed = listProfiles(phrenPath);
if (!refreshed.ok)
return forwardErr(refreshed);
const latest = refreshed.data.find((entry) => entry.name === profile);
if (!latest)
return phrenErr(`Profile "${profile}" not found.`, PhrenError.NOT_FOUND);
const projects = latest.projects.includes(project) ? latest.projects : [...latest.projects, project];
writeProfile(latest.file, latest.name, projects, latest.description);
return phrenOk(`Added ${project} to profile ${profile}.`);
});
}
export function removeProjectFromProfile(phrenPath, profile, project) {
const profiles = listProfiles(phrenPath);
if (!profiles.ok)
return forwardErr(profiles);
const current = profiles.data.find((entry) => entry.name === profile);
if (!current)
return phrenErr(`Profile "${profile}" not found.`, PhrenError.NOT_FOUND);
return withSafeLock(current.file, () => {
const refreshed = listProfiles(phrenPath);
if (!refreshed.ok)
return forwardErr(refreshed);
const latest = refreshed.data.find((entry) => entry.name === profile);
if (!latest)
return phrenErr(`Profile "${profile}" not found.`, PhrenError.NOT_FOUND);
const projects = latest.projects.filter((entry) => entry !== project);
writeProfile(latest.file, latest.name, projects, latest.description);
return phrenOk(`Removed ${project} from profile ${profile}.`);
});
}
function buildProjectCard(dir) {
const name = path.basename(dir);
const summaryFile = path.join(dir, "summary.md");
const claudeFile = path.join(dir, "AGENTS.md");
const summarySource = fs.existsSync(summaryFile)
? fs.readFileSync(summaryFile, "utf8")
: fs.existsSync(claudeFile)
? fs.readFileSync(claudeFile, "utf8")
: "";
const summary = summarySource
.split("\n")
.map((line) => line.trim())
.find((line) => line && !line.startsWith("#")) || "";
const docs = ["AGENTS.md", FINDINGS_FILENAME, "summary.md", "review.md"]
.filter((file) => fs.existsSync(path.join(dir, file)));
const taskFile = TASK_FILE_ALIASES.find((file) => fs.existsSync(path.join(dir, file)));
if (taskFile)
docs.push(taskFile);
return { name, summary, docs };
}
export function listProjectCards(phrenPath, profile) {
const dirs = getProjectDirs(phrenPath, profile);
const cards = dirs.map(buildProjectCard);
const seen = new Set(dirs.map((d) => path.basename(d)));
// Team stores carry their own subscription list (StoreEntry.projects, applied
// by getStoreProjectDirs). The profile's project list scopes the *primary*
// store, so filtering team projects through it hid every one of them — a
// profile never names projects that live in someone else's store.
try {
for (const store of getNonPrimaryStores(phrenPath)) {
if (!fs.existsSync(store.path))
continue;
for (const dir of getStoreProjectDirs(store)) {
const name = path.basename(dir);
if (seen.has(name) || name === "global")
continue;
seen.add(name);
cards.push({ ...buildProjectCard(dir), store: store.name });
}
}
}
catch {
// store-registry not available or error loading, continue with primary only
}
cards.sort((a, b) => a.name.localeCompare(b.name));
// Prepend global as a pinned entry so it's always accessible from the shell.
// getProjectDirs already returns it whenever the profile names it, so drop
// that copy rather than listing global twice.
const globalDir = path.join(phrenPath, "global");
if (fs.existsSync(globalDir)) {
const listed = cards.findIndex((card) => card.name === "global");
if (listed >= 0)
cards.splice(listed, 1);
cards.unshift(buildProjectCard(globalDir));
}
return cards;
}