@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
89 lines (88 loc) • 4.07 kB
JavaScript
import { realpathSync } from "node:fs";
import { readFile, stat } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import { BridgeError } from "./protocol.js";
import { herdrRoot } from "./herdr.js";
import { phrenStoreRoot } from "./transcripts.js";
const PROJECT_NAME = /^[a-z0-9][a-z0-9-]*$/;
const SEARCH_ROOTS = ["", "Sites", "Projects", "projects", "Code", "code", "dev", "src", "repos", "workspace"];
function realHome() {
try {
return realpathSync.native(homedir());
}
catch {
return path.resolve(homedir());
}
}
export async function locateProject(project, activity, env = process.env) {
if (!PROJECT_NAME.test(project) || project.length > 100)
throw new BridgeError(400, "Invalid project name.");
const home = realHome();
const found = [];
const seen = new Set();
const offer = async (directory, source, lastSeen) => {
let dir = path.resolve(directory);
try {
if (!(await stat(dir)).isDirectory())
return;
// On a case-insensitive disk ~/projects and ~/Projects are one folder;
// the real path (native, so it carries the on-disk case) dedupes them.
dir = realpathSync.native(dir);
}
catch {
return; /* not on this computer */
}
// A folder that contains the home folder holds every project, so it is
// never one: a project named after the user ("sam") must not be offered
// /home/sam because every journal path runs through it.
if (dir === path.parse(dir).root || home === dir || home.startsWith(dir + path.sep))
return;
if (seen.has(dir))
return;
seen.add(dir);
found.push({ directory: dir, source, ...(lastSeen ? { lastSeen } : {}) });
};
// Paths from journals and Herdr use whichever separator the computer
// does; split on both so Windows folders are trimmed like POSIX ones.
const segments = (directory) => directory.split(/[\\/]/);
const namesProject = (directory) => segments(directory).includes(project);
const trimmed = (directory) => {
const parts = segments(directory);
return parts.slice(0, parts.lastIndexOf(project) + 1).join(path.sep);
};
// 1. Folders an agent session ran in, newest first — the strongest signal
// that this is the folder the person means.
for (const event of [...activity].reverse()) {
const directory = typeof event.directory === "string" ? event.directory : undefined;
if (directory && path.isAbsolute(directory) && namesProject(directory)) {
// Trim to the project segment so a session started in a subfolder
// still opens the project itself.
await offer(trimmed(directory), "activity", typeof event.at === "string" ? event.at : undefined);
}
}
// 2. Workspaces Herdr has saved.
try {
const session = JSON.parse(await readFile(path.join(herdrRoot(), "session.json"), "utf8"));
for (const match of JSON.stringify(session).matchAll(/"cwd":\s*"((?:\\.|[^"\\])*)"/g)) {
const directory = JSON.parse(`"${match[1]}"`);
if (path.isAbsolute(directory) && namesProject(directory)) {
await offer(trimmed(directory), "herdr");
}
}
}
catch { /* no saved session */ }
// 3. phren's own registration, when it points at this machine.
try {
const config = await readFile(path.join(phrenStoreRoot(env), project, "phren.project.yaml"), "utf8");
const match = /^sourcePath:\s*(.+)$/m.exec(config);
if (match)
await offer(match[1].trim().replace(/^['"]|['"]$/g, ""), "phren");
}
catch { /* unregistered */ }
// 4. The roots phren's locator searches.
for (const root of [...(env.PROJECTS_DIR ? [env.PROJECTS_DIR] : []), ...SEARCH_ROOTS.map(r => path.join(home, r))]) {
await offer(path.join(root, project), "search");
}
return found.slice(0, 8);
}