@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
350 lines (349 loc) • 19 kB
JavaScript
import { mcpResponse, resolveStoreForProject } from "./types.js";
import { z } from "zod";
import * as fs from "fs";
import * as path from "path";
import { isValidProjectName, errorMessage, safeProjectPath } from "../utils.js";
import { readFindings, readTasks, resolveTaskFilePath, TASKS_FILENAME, FINDINGS_FILENAME } from "../data/access.js";
import { debugLog, findArchivedProjectNameCaseInsensitive, findProjectNameCaseInsensitive, normalizeProjectNameForCreate } from "../shared.js";
import { logger } from "../logger.js";
const importPayloadSchema = z.object({
project: z.string(),
overwrite: z.boolean().optional(),
summary: z.string().optional(),
agentsMd: z.string().optional(),
claudeMd: z.string().optional(),
taskRaw: z.string().optional(),
findingsRaw: z.string().optional(),
learnings: z
.array(z.object({
text: z.string(),
}).passthrough())
.optional(),
task: z
.object({
Active: z.array(z.object({ line: z.string(), checked: z.boolean().optional(), context: z.string().optional(), priority: z.string().optional(), pinned: z.boolean().optional(), id: z.string().optional(), githubIssue: z.number().optional(), githubUrl: z.string().optional() }).passthrough()).optional(),
Queue: z.array(z.object({ line: z.string(), checked: z.boolean().optional(), context: z.string().optional(), priority: z.string().optional(), pinned: z.boolean().optional(), id: z.string().optional(), githubIssue: z.number().optional(), githubUrl: z.string().optional() }).passthrough()).optional(),
Done: z.array(z.object({ line: z.string(), checked: z.boolean().optional(), context: z.string().optional(), priority: z.string().optional(), pinned: z.boolean().optional(), id: z.string().optional(), githubIssue: z.number().optional(), githubUrl: z.string().optional() }).passthrough()).optional(),
})
.partial()
.optional(),
}).passthrough();
export function register(server, ctx) {
const { phrenPath, withWriteQueue, rebuildIndex } = ctx;
server.registerTool("export_project", {
title: "◆ phren · export",
description: "Export a project's data (findings, task, summary) as portable JSON for sharing or backup.",
inputSchema: z.object({
project: z.string().describe("Project name to export."),
}),
}, async ({ project: projectInput }) => {
// Resolve store-qualified project names (e.g. "team-shared/web")
let resolvedPhrenPath;
let project;
try {
const resolved = resolveStoreForProject(ctx, projectInput, "read");
resolvedPhrenPath = resolved.phrenPath;
project = resolved.project;
}
catch (err) {
return mcpResponse({ ok: false, error: err instanceof Error ? err.message : String(err) });
}
if (!isValidProjectName(project))
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
const projectDir = safeProjectPath(resolvedPhrenPath, project);
if (!projectDir || !fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) {
return mcpResponse({ ok: false, error: `Project "${project}" not found.` });
}
const exported = { project, exportedAt: new Date().toISOString(), version: 1 };
const summaryPath = safeProjectPath(projectDir, "summary.md");
if (summaryPath && fs.existsSync(summaryPath))
exported.summary = fs.readFileSync(summaryPath, "utf8");
const learningsResult = readFindings(resolvedPhrenPath, project);
if (learningsResult.ok)
exported.learnings = learningsResult.data;
const findingsPath = safeProjectPath(projectDir, FINDINGS_FILENAME);
if (findingsPath && fs.existsSync(findingsPath))
exported.findingsRaw = fs.readFileSync(findingsPath, "utf8");
const taskResult = readTasks(resolvedPhrenPath, project);
if (taskResult.ok) {
exported.task = taskResult.data.items;
const taskRawPath = resolveTaskFilePath(resolvedPhrenPath, project);
if (taskRawPath && fs.existsSync(taskRawPath))
exported.taskRaw = fs.readFileSync(taskRawPath, "utf8");
}
const agentsPath = safeProjectPath(projectDir, "AGENTS.md");
const legacyPath = safeProjectPath(projectDir, "CLAUDE.md");
const instructionsPath = agentsPath && fs.existsSync(agentsPath) ? agentsPath : legacyPath;
if (instructionsPath && fs.existsSync(instructionsPath)) {
const instructions = fs.readFileSync(instructionsPath, "utf8");
exported.agentsMd = instructions;
exported.claudeMd = instructions; // Deprecated export alias for older clients.
}
// An export is only useful whole, so it is exempt from the response cap.
return mcpResponse({ ok: true, message: `Exported project "${project}".`, data: exported }, { unbounded: true });
});
server.registerTool("import_project", {
title: "◆ phren · import",
description: "Import project data from a previously exported JSON payload. Creates the project directory if needed.",
inputSchema: z.object({
data: z.string().describe("JSON string from a previous export_project call."),
}),
}, async ({ data: rawData }) => {
return withWriteQueue(async () => {
let decoded;
try {
decoded = JSON.parse(rawData);
}
catch (err) {
logger.debug("data", `import_project jsonParse: ${errorMessage(err)}`);
return mcpResponse({ ok: false, error: "Invalid JSON input." });
}
const parsedResult = importPayloadSchema.safeParse(decoded);
if (!parsedResult.success) {
return mcpResponse({ ok: false, error: `Invalid import payload: ${parsedResult.error.issues[0]?.message ?? "schema validation failed"}` });
}
const parsed = parsedResult.data;
// Warn about unknown fields silently discarded by .passthrough()
const knownTopLevel = new Set(["project", "overwrite", "summary", "agentsMd", "claudeMd", "learnings", "task", "taskRaw", "exportedAt", "version", "findingsRaw"]);
const unknownFields = Object.keys(decoded).filter(k => !knownTopLevel.has(k));
if (unknownFields.length > 0) {
debugLog(`import_project: unknown fields will be ignored: ${unknownFields.join(", ")}`);
}
const projectName = normalizeProjectNameForCreate(parsed.project);
if (!isValidProjectName(projectName)) {
return mcpResponse({ ok: false, error: `Invalid project name: "${parsed.project}"` });
}
const existingProject = findProjectNameCaseInsensitive(phrenPath, projectName);
if (existingProject && existingProject !== projectName) {
return mcpResponse({
ok: false,
error: `Project "${existingProject}" already exists with different casing. Refusing to import "${projectName}" because it would split the same project on case-sensitive filesystems.`,
});
}
const projectDir = safeProjectPath(phrenPath, projectName);
if (!projectDir) {
return mcpResponse({ ok: false, error: `Invalid project name: "${parsed.project}"` });
}
const overwrite = parsed.overwrite === true;
if (fs.existsSync(projectDir) && !overwrite) {
return mcpResponse({
ok: false,
error: `Project "${projectName}" already exists. Re-run with "overwrite": true to replace it.`,
});
}
const stagingRoot = fs.mkdtempSync(path.join(phrenPath, `.phren-import-${projectName}-`));
const stagedProjectDir = path.join(stagingRoot, projectName);
const imported = [];
let backupDir = null;
const cleanupDir = (dir) => {
if (fs.existsSync(dir))
fs.rmSync(dir, { recursive: true, force: true });
};
const buildFindingsContent = () => {
if (!parsed.learnings || parsed.learnings.length === 0)
return null;
const date = new Date().toISOString().slice(0, 10);
const lines = [`# ${projectName} Findings`, "", `## ${date}`, ""];
for (const item of parsed.learnings) {
lines.push(`- ${item.text}`);
}
lines.push("");
return lines.join("\n");
};
const buildTaskContent = () => {
// Prefer the raw task string (lossless: preserves priority/pinned/stable IDs)
if (typeof parsed.taskRaw === "string")
return parsed.taskRaw;
if (!parsed.task)
return null;
const sections = ["Active", "Queue", "Done"];
const lines = [`# ${projectName} tasks`, ""];
for (const section of sections) {
lines.push(`## ${section}`, "");
const items = parsed.task[section];
if (items) {
for (const item of items) {
const prefix = item.checked || section === "Done" ? "- [x] " : "- [ ] ";
const priorityTag = item.priority ? ` [${item.priority}]` : "";
lines.push(`${prefix}${item.line}${priorityTag}`);
if (item.context)
lines.push(` Context: ${item.context}`);
if (item.githubIssue || item.githubUrl) {
const githubRef = item.githubIssue && item.githubUrl
? `#${item.githubIssue} ${item.githubUrl}`
: item.githubIssue
? `#${item.githubIssue}`
: item.githubUrl;
lines.push(` GitHub: ${githubRef}`);
}
}
}
lines.push("");
}
return lines.join("\n");
};
try {
fs.mkdirSync(stagedProjectDir, { recursive: true });
if (parsed.summary) {
fs.writeFileSync(path.join(stagedProjectDir, "summary.md"), parsed.summary);
imported.push("summary.md");
}
const instructions = parsed.agentsMd ?? parsed.claudeMd;
if (instructions) {
fs.writeFileSync(path.join(stagedProjectDir, "AGENTS.md"), instructions);
imported.push("AGENTS.md");
}
const findingsContent = typeof parsed.findingsRaw === "string" ? parsed.findingsRaw : buildFindingsContent();
if (findingsContent) {
fs.writeFileSync(path.join(stagedProjectDir, FINDINGS_FILENAME), findingsContent);
imported.push(FINDINGS_FILENAME);
}
const taskContent = buildTaskContent();
if (taskContent) {
fs.writeFileSync(path.join(stagedProjectDir, TASKS_FILENAME), taskContent);
imported.push(TASKS_FILENAME);
}
backupDir = overwrite ? path.join(phrenPath, `${projectName}.import-backup-${Date.now()}`) : null;
try {
if (overwrite && fs.existsSync(projectDir)) {
fs.renameSync(projectDir, backupDir);
}
fs.renameSync(stagedProjectDir, projectDir);
cleanupDir(stagingRoot);
}
catch (error) {
if (backupDir && fs.existsSync(backupDir) && !fs.existsSync(projectDir)) {
fs.renameSync(backupDir, projectDir);
}
cleanupDir(stagingRoot);
return mcpResponse({
ok: false,
error: error instanceof Error ? `Failed to finalize import: ${error.message}` : "Failed to finalize import.",
errorCode: "INTERNAL_ERROR",
});
}
}
catch (error) {
cleanupDir(stagingRoot);
return mcpResponse({
ok: false,
error: error instanceof Error ? `Failed to stage import: ${error.message}` : "Failed to stage import.",
errorCode: "INTERNAL_ERROR",
});
}
// Wrap rebuildIndex in a try/catch so that indexing failures trigger backup restore.
try {
await rebuildIndex();
}
catch (indexError) {
// Index rebuild failed — restore backup if we replaced the project dir
if (!overwrite) {
// Non-overwrite case: no backup to restore — remove the orphaned project dir
try {
fs.rmSync(projectDir, { recursive: true });
}
catch { /* best-effort */ }
}
else if (backupDir) {
try {
if (fs.existsSync(backupDir) && !fs.existsSync(projectDir)) {
fs.renameSync(backupDir, projectDir);
}
else if (fs.existsSync(backupDir)) {
fs.rmSync(projectDir, { recursive: true, force: true });
fs.renameSync(backupDir, projectDir);
}
}
catch (err) {
logger.debug("data", `import_project backupRestore: ${errorMessage(err)}`);
}
}
return mcpResponse({
ok: false,
error: indexError instanceof Error ? `Index rebuild failed after import: ${indexError.message}` : "Index rebuild failed after import.",
errorCode: "INTERNAL_ERROR",
});
}
// Backup is only deleted after successful rebuild so we can restore on failure
if (backupDir && fs.existsSync(backupDir)) {
try {
fs.rmSync(backupDir, { recursive: true, force: true });
}
catch (err) {
logger.debug("data", `import_project backupCleanup: ${errorMessage(err)}`);
}
}
return mcpResponse({
ok: true,
message: `Imported project "${projectName}": ${imported.join(", ")}`,
data: { project: projectName, files: imported, overwrite },
});
});
});
server.registerTool("manage_project", {
title: "◆ phren · manage project",
description: "Archive or unarchive a project. Archive moves it out of the active index without deleting data (renamed with .archived suffix). Unarchive restores it.",
inputSchema: z.object({
project: z.string().describe("Project name."),
action: z.enum(["archive", "unarchive"]).describe("Action to perform."),
}),
}, async ({ project, action }) => {
if (!isValidProjectName(project))
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
const resolved = resolveStoreForProject(ctx, project);
return withWriteQueue(async () => {
const activeProject = findProjectNameCaseInsensitive(resolved.phrenPath, project);
const archivedProject = findArchivedProjectNameCaseInsensitive(resolved.phrenPath, project);
if (action === "archive") {
if (!activeProject) {
return mcpResponse({ ok: false, error: `Project "${project}" not found.` });
}
const projectDir = path.join(resolved.phrenPath, activeProject);
const archiveDir = path.join(resolved.phrenPath, `${activeProject}.archived`);
if (!fs.existsSync(projectDir)) {
return mcpResponse({ ok: false, error: `Project "${project}" not found.` });
}
if (fs.existsSync(archiveDir)) {
return mcpResponse({ ok: false, error: `Archive "${project}.archived" already exists. Unarchive or remove it first.` });
}
fs.renameSync(projectDir, archiveDir);
try {
await rebuildIndex();
}
catch (err) {
fs.renameSync(archiveDir, projectDir);
return mcpResponse({ ok: false, error: `Index rebuild failed after archive rename, rolled back: ${errorMessage(err)}` });
}
return mcpResponse({
ok: true,
message: `Archived project "${project}". Data preserved at ${archiveDir}.`,
data: { project, archivePath: archiveDir },
});
}
// unarchive
if (activeProject) {
return mcpResponse({ ok: false, error: `Project "${activeProject}" already exists as an active project.` });
}
if (!archivedProject) {
const entries = fs.readdirSync(resolved.phrenPath).filter((e) => e.endsWith(".archived"));
const available = entries.map((e) => e.replace(/\.archived$/, ""));
return mcpResponse({ ok: false, error: `No archive found for "${project}".`, data: { availableArchives: available } });
}
const projectDir = path.join(resolved.phrenPath, archivedProject);
const archiveDir = path.join(resolved.phrenPath, `${archivedProject}.archived`);
fs.renameSync(archiveDir, projectDir);
try {
await rebuildIndex();
}
catch (err) {
fs.renameSync(projectDir, archiveDir);
return mcpResponse({ ok: false, error: `Index rebuild failed after unarchive rename, rolled back: ${errorMessage(err)}` });
}
return mcpResponse({
ok: true,
message: `Unarchived project "${archivedProject}". It is now active again.`,
data: { project: archivedProject, path: projectDir },
});
}); // end withWriteQueue
});
}