UNPKG

@phren/cli

Version:

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

405 lines (404 loc) • 19.1 kB
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; }