@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
378 lines (377 loc) • 17.7 kB
JavaScript
import * as fs from "fs";
import * as path from "path";
import { expandHomePath, findArchivedProjectNameCaseInsensitive, findProjectNameCaseInsensitive, getPhrenPath, getProjectDirs, normalizeProjectNameForCreate, readRootManifest, } from "../shared.js";
import { isValidProjectName, errorMessage } from "../utils.js";
import { logger } from "../logger.js";
import { TASK_FILE_ALIASES } from "../data/tasks.js";
import { FINDINGS_FILENAME } from "../data/access.js";
import { PROJECT_OWNERSHIP_MODES, parseProjectOwnershipMode, writeProjectConfig, writeProjectHookConfig, } from "../project-config.js";
import { resolveProjectStorePath, parseMcpToggle } from "./namespaces-utils.js";
export async function handleProjectsNamespace(args, profile) {
const subcommand = args[0];
if (!subcommand || subcommand === "list" || subcommand === "--help" || subcommand === "-h") {
if (subcommand === "--help" || subcommand === "-h") {
console.log("Usage:");
console.log(" phren projects list List all projects");
console.log(" phren projects configure <name> Update per-project enrollment settings");
console.log(" flags: --ownership=<mode> --hooks=on|off");
console.log(" phren projects remove <name> Remove a project (asks for confirmation)");
console.log(" phren projects export <name> Export project data as JSON to stdout");
console.log(" phren projects import <file> Import project from a JSON file");
console.log(" phren projects archive <name> Archive a project (removes from active index)");
console.log(" phren projects unarchive <name> Restore an archived project");
return;
}
return handleProjectsList(profile);
}
if (subcommand === "add") {
console.error("`phren projects add` has been removed from the supported workflow.");
console.error("Use `cd ~/your-project && phren add` so enrollment stays path-based.");
process.exit(1);
}
if (subcommand === "remove") {
const manifest = readRootManifest(getPhrenPath());
if (manifest?.installMode === "project-local") {
console.error("projects remove is unsupported in project-local mode. Use `phren uninstall`.");
process.exit(1);
}
const name = args[1];
if (!name) {
console.error("Usage: phren projects remove <name>");
process.exit(1);
}
return handleProjectsRemove(name, profile);
}
if (subcommand === "configure") {
const name = args[1];
if (!name) {
console.error(`Usage: phren projects configure <name> [--ownership=${PROJECT_OWNERSHIP_MODES.join("|")}] [--hooks=on|off]`);
process.exit(1);
}
if (!isValidProjectName(name)) {
console.error(`Invalid project name: "${name}".`);
process.exit(1);
}
if (!fs.existsSync(path.join(getPhrenPath(), name))) {
console.error(`Project "${name}" not found.`);
process.exit(1);
}
const ownershipArg = args.find((arg) => arg.startsWith("--ownership="))?.slice("--ownership=".length);
const hooksArg = args.find((arg) => arg.startsWith("--hooks="))?.slice("--hooks=".length);
const ownership = ownershipArg ? parseProjectOwnershipMode(ownershipArg) : undefined;
const hooksEnabled = parseMcpToggle(hooksArg);
if (!ownershipArg && hooksArg === undefined) {
console.error(`Usage: phren projects configure <name> [--ownership=${PROJECT_OWNERSHIP_MODES.join("|")}] [--hooks=on|off]`);
process.exit(1);
}
if (ownershipArg && !ownership) {
console.error(`Usage: phren projects configure <name> [--ownership=${PROJECT_OWNERSHIP_MODES.join("|")}] [--hooks=on|off]`);
process.exit(1);
}
if (hooksArg !== undefined && hooksEnabled === undefined) {
console.error(`Invalid --hooks value "${hooksArg}". Use on or off.`);
process.exit(1);
}
const updates = [];
if (ownership) {
writeProjectConfig(getPhrenPath(), name, { ownership });
updates.push(`ownership=${ownership}`);
}
if (hooksEnabled !== undefined) {
writeProjectHookConfig(getPhrenPath(), name, { enabled: hooksEnabled });
updates.push(`hooks=${hooksEnabled ? "on" : "off"}`);
}
console.log(`Updated ${name}: ${updates.join(", ")}`);
return;
}
if (subcommand === "export") {
const name = args[1];
if (!name) {
console.error("Usage: phren projects export <name>");
process.exit(1);
}
if (!isValidProjectName(name)) {
console.error(`Invalid project name: "${name}".`);
process.exit(1);
}
const phrenPath = getPhrenPath();
const storePath = resolveProjectStorePath(phrenPath, name);
const projectDir = path.join(storePath, name);
if (!fs.existsSync(projectDir)) {
console.error(`Project "${name}" not found.`);
process.exit(1);
}
const { readFindings, readTasks, resolveTaskFilePath } = await import("../data/access.js");
const exported = { project: name, exportedAt: new Date().toISOString(), version: 1 };
const summaryPath = path.join(projectDir, "summary.md");
if (fs.existsSync(summaryPath))
exported.summary = fs.readFileSync(summaryPath, "utf8");
const learningsResult = readFindings(storePath, name);
if (learningsResult.ok)
exported.learnings = learningsResult.data;
const findingsPath = path.join(projectDir, FINDINGS_FILENAME);
if (fs.existsSync(findingsPath))
exported.findingsRaw = fs.readFileSync(findingsPath, "utf8");
const taskResult = readTasks(storePath, name);
if (taskResult.ok) {
exported.task = taskResult.data.items;
const taskRawPath = resolveTaskFilePath(storePath, name);
if (taskRawPath && fs.existsSync(taskRawPath))
exported.taskRaw = fs.readFileSync(taskRawPath, "utf8");
}
const agentsPath = path.join(projectDir, "AGENTS.md");
const legacyPath = path.join(projectDir, "CLAUDE.md");
const instructionsPath = fs.existsSync(agentsPath) ? agentsPath : legacyPath;
if (fs.existsSync(instructionsPath)) {
const instructions = fs.readFileSync(instructionsPath, "utf8");
exported.agentsMd = instructions;
exported.claudeMd = instructions; // Deprecated export alias for older clients.
}
process.stdout.write(JSON.stringify(exported, null, 2) + "\n");
return;
}
if (subcommand === "import") {
const filePath = args[1];
if (!filePath) {
console.error("Usage: phren projects import <file>");
process.exit(1);
}
const resolvedPath = path.resolve(expandHomePath(filePath));
if (!fs.existsSync(resolvedPath)) {
console.error(`File not found: ${resolvedPath}`);
process.exit(1);
}
let rawData;
try {
rawData = fs.readFileSync(resolvedPath, "utf8");
}
catch (err) {
console.error(`Failed to read file: ${errorMessage(err)}`);
process.exit(1);
}
let decoded;
try {
decoded = JSON.parse(rawData);
}
catch {
console.error("Invalid JSON in file.");
process.exit(1);
}
if (!decoded || typeof decoded !== "object" || !decoded.project) {
console.error("Invalid import payload: missing project field.");
process.exit(1);
}
const { TASKS_FILENAME } = await import("../data/access.js");
const phrenPath = getPhrenPath();
const projectName = normalizeProjectNameForCreate(String(decoded.project));
if (!isValidProjectName(projectName)) {
console.error(`Invalid project name: "${decoded.project}".`);
process.exit(1);
}
const existingProject = findProjectNameCaseInsensitive(phrenPath, projectName);
if (existingProject && existingProject !== projectName) {
console.error(`Project "${existingProject}" already exists with different casing. Refusing to import "${projectName}".`);
process.exit(1);
}
const projectDir = path.join(phrenPath, projectName);
if (fs.existsSync(projectDir)) {
console.error(`Project "${projectName}" already exists. Remove it first or use the MCP tool with overwrite:true.`);
process.exit(1);
}
const imported = [];
const stagingRoot = fs.mkdtempSync(path.join(phrenPath, `.phren-import-${projectName}-`));
const stagedProjectDir = path.join(stagingRoot, projectName);
try {
fs.mkdirSync(stagedProjectDir, { recursive: true });
if (typeof decoded.summary === "string") {
fs.writeFileSync(path.join(stagedProjectDir, "summary.md"), decoded.summary);
imported.push("summary.md");
}
const instructions = typeof decoded.agentsMd === "string" ? decoded.agentsMd : decoded.claudeMd;
if (typeof instructions === "string") {
fs.writeFileSync(path.join(stagedProjectDir, "AGENTS.md"), instructions);
imported.push("AGENTS.md");
}
if (typeof decoded.findingsRaw === "string") {
fs.writeFileSync(path.join(stagedProjectDir, FINDINGS_FILENAME), decoded.findingsRaw);
imported.push(FINDINGS_FILENAME);
}
else if (Array.isArray(decoded.learnings) && decoded.learnings.length > 0) {
const date = new Date().toISOString().slice(0, 10);
const lines = [`# ${projectName} Findings`, "", `## ${date}`, ""];
for (const item of decoded.learnings) {
if (item.text)
lines.push(`- ${item.text}`);
}
lines.push("");
fs.writeFileSync(path.join(stagedProjectDir, FINDINGS_FILENAME), lines.join("\n"));
imported.push(FINDINGS_FILENAME);
}
if (typeof decoded.taskRaw === "string") {
fs.writeFileSync(path.join(stagedProjectDir, TASKS_FILENAME), decoded.taskRaw);
imported.push(TASKS_FILENAME);
}
fs.renameSync(stagedProjectDir, projectDir);
fs.rmSync(stagingRoot, { recursive: true, force: true });
console.log(`Imported project "${projectName}": ${imported.join(", ") || "(no files)"}`);
}
catch (err) {
try {
fs.rmSync(stagingRoot, { recursive: true, force: true });
}
catch { /* best-effort */ }
console.error(`Import failed: ${errorMessage(err)}`);
process.exit(1);
}
return;
}
if (subcommand === "archive" || subcommand === "unarchive") {
const name = args[1];
if (!name) {
console.error(`Usage: phren projects ${subcommand} <name>`);
process.exit(1);
}
if (!isValidProjectName(name)) {
console.error(`Invalid project name: "${name}".`);
process.exit(1);
}
const phrenPath = getPhrenPath();
if (subcommand === "archive") {
const activeProject = findProjectNameCaseInsensitive(phrenPath, name);
const storePath = resolveProjectStorePath(phrenPath, activeProject ?? name);
const projectDir = activeProject ? path.join(storePath, activeProject) : path.join(storePath, name);
const archiveDir = path.join(storePath, `${activeProject ?? name}.archived`);
if (!fs.existsSync(projectDir)) {
console.error(`Project "${name}" not found.`);
process.exit(1);
}
if (fs.existsSync(archiveDir)) {
console.error(`Archive "${name}.archived" already exists. Unarchive or remove it first.`);
process.exit(1);
}
try {
fs.renameSync(projectDir, archiveDir);
console.log(`Archived project "${name}". Data preserved at ${archiveDir}.`);
console.log("Note: the search index will be updated on next search.");
}
catch (err) {
console.error(`Archive failed: ${errorMessage(err)}`);
process.exit(1);
}
}
else {
const activeProject = findProjectNameCaseInsensitive(phrenPath, name);
if (activeProject) {
console.error(`Project "${activeProject}" already exists as an active project.`);
process.exit(1);
}
const archivedProject = findArchivedProjectNameCaseInsensitive(phrenPath, name);
const storePath = resolveProjectStorePath(phrenPath, archivedProject ?? name);
const projectDir = path.join(storePath, archivedProject ?? name);
const archiveDir = path.join(storePath, `${archivedProject ?? name}.archived`);
if (!fs.existsSync(archiveDir)) {
const available = fs.readdirSync(phrenPath)
.filter((e) => e.endsWith(".archived"))
.map((e) => e.replace(/\.archived$/, ""));
if (available.length > 0) {
console.error(`No archive found for "${name}". Available archives: ${available.join(", ")}`);
}
else {
console.error(`No archive found for "${name}".`);
}
process.exit(1);
}
try {
fs.renameSync(archiveDir, projectDir);
console.log(`Unarchived project "${archivedProject ?? name}". It is now active again.`);
console.log("Note: the search index will be updated on next search.");
}
catch (err) {
console.error(`Unarchive failed: ${errorMessage(err)}`);
process.exit(1);
}
}
return;
}
console.error(`Unknown subcommand: ${subcommand}`);
console.error("Usage: phren projects [list|configure|remove|export|import|archive|unarchive]");
process.exit(1);
}
function handleProjectsList(profile) {
const phrenPath = getPhrenPath();
const projectDirs = getProjectDirs(phrenPath, profile);
const projects = projectDirs
.map((dir) => path.basename(dir))
.filter((name) => name !== "global")
.sort();
if (!projects.length) {
console.log("No projects found. Run: cd ~/your-project && phren add");
return;
}
console.log(`\nProjects in ${phrenPath}:\n`);
for (const name of projects) {
const projectDir = path.join(phrenPath, name);
let dirFiles;
try {
dirFiles = new Set(fs.readdirSync(projectDir));
}
catch (err) {
if ((process.env.PHREN_DEBUG))
logger.debug("cli-namespaces", `projects list readdir: ${errorMessage(err)}`);
dirFiles = new Set();
}
const tags = [];
if (dirFiles.has(FINDINGS_FILENAME))
tags.push("findings");
if (TASK_FILE_ALIASES.some((filename) => dirFiles.has(filename)))
tags.push("tasks");
const tagStr = tags.length ? ` [${tags.join(", ")}]` : "";
console.log(` ${name}${tagStr}`);
}
console.log(`\n${projects.length} project(s) total.`);
console.log("Add another project: cd ~/your-project && phren add");
}
async function handleProjectsRemove(name, profile) {
if (!isValidProjectName(name)) {
console.error(`Invalid project name: "${name}".`);
process.exit(1);
}
if (name === "global") {
console.error('Cannot remove the "global" project.');
process.exit(1);
}
const phrenPath = getPhrenPath();
const projectDir = path.join(phrenPath, name);
if (!fs.existsSync(projectDir)) {
console.error(`Project "${name}" not found at ${projectDir}`);
process.exit(1);
}
let fileCount = 0;
const countFiles = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory())
countFiles(path.join(dir, entry.name));
else
fileCount++;
}
};
try {
countFiles(projectDir);
}
catch (err) {
if ((process.env.PHREN_DEBUG))
logger.debug("cli-namespaces", `projects remove countFiles: ${errorMessage(err)}`);
}
const readline = await import("readline");
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const answer = await new Promise((resolve) => {
rl.question(`Remove project "${name}" (${fileCount} file${fileCount === 1 ? "" : "s"})? This cannot be undone. Type the project name to confirm: `, (input) => { rl.close(); resolve(input.trim()); });
});
if (answer !== name) {
console.log("Aborted.");
return;
}
fs.rmSync(projectDir, { recursive: true, force: true });
console.log(`Removed project "${name}".`);
console.log(`If this project was in a profile, remove it from profiles/${profile || "personal"}.yaml manually.`);
}