@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
220 lines (219 loc) • 8.87 kB
JavaScript
import { activateModules, moduleSnapshot } from "./modules/runtime.js";
import { enableHookForPhone } from "./modules/config.js";
import { disabledCommand } from "./cli-registry.js";
import * as fs from "fs";
import * as path from "path";
import { errorMessage } from "./utils.js";
import { logger } from "./logger.js";
import { defaultPhrenPath, findPhrenPath, ensureFtsCacheRootPrivate, expandHomePath, getPhrenPath, readRootManifest, } from "./shared.js";
import { resolveRuntimeProfile } from "./runtime-profile.js";
import { VERSION } from "./package-metadata.js";
import { TOPIC_ORDER, helpTopicNames, lookupCommand, } from "./cli-registry.js";
import { formatCheatSheet, formatCommand, formatDocTopic, formatFullHelp, formatTopic, } from "./cli-help.js";
function looksLikePhrenRootArg(arg) {
if (!arg || arg.startsWith("-"))
return false;
try {
const resolved = path.resolve(expandHomePath(arg));
return fs.existsSync(resolved) && Boolean(readRootManifest(resolved));
}
catch {
return false;
}
}
function firstPositionalArg(argv) {
return argv.find((arg) => !arg.startsWith("-"));
}
export function resolveTopLevelInvocation(argv) {
const argvCommand = argv[0];
const positional = firstPositionalArg(argv);
if (looksLikePhrenRootArg(positional)) {
return { kind: "mcp", phrenArg: positional };
}
if (argvCommand === "mcp" && argv.length === 1) {
return { kind: "mcp-serve" };
}
if (argvCommand === "--help" || argvCommand === "-h") {
// `phren --help all` reads like `phren help all`, so it does the same.
return argv.length > 1 ? { kind: "manage", argv } : { kind: "help" };
}
if (argvCommand === "--version" || argvCommand === "-v" || argvCommand === "version") {
return { kind: "version" };
}
if (argvCommand === "help") {
return argv.length > 1 ? { kind: "manage", argv } : { kind: "help" };
}
if (argvCommand === "manage" || argvCommand === "mem") {
return { kind: "manage", argv: argv.slice(1).length > 0 ? argv.slice(1) : [] };
}
if (!argvCommand) {
return { kind: "manage", argv: [] };
}
return { kind: "manage", argv };
}
export function printIntegratedHelp() {
console.log(formatCheatSheet(helpSnapshot()));
}
export function printIntegratedVersion() {
console.log(`phren v${VERSION}`);
}
async function flushTopLevelOutput() {
await Promise.all([
new Promise((resolve) => process.stdout.write("", () => resolve())),
new Promise((resolve) => process.stderr.write("", () => resolve())),
]);
}
async function finish(exitCode) {
if (exitCode !== undefined)
process.exitCode = exitCode;
await flushTopLevelOutput();
return true;
}
function buildCliContext() {
return {
phrenPath: () => getPhrenPath(),
profile: () => resolveRuntimeProfile(getPhrenPath()),
};
}
function helpSnapshot() {
try {
return moduleSnapshot(findPhrenPath() ?? defaultPhrenPath());
}
catch {
return undefined;
}
}
async function runHelp(args) {
const snapshot = helpSnapshot();
const target = args[0]?.toLowerCase();
if (!target) {
console.log(formatCheatSheet(helpSnapshot()));
return finish();
}
if (target === "all") {
console.log(formatFullHelp(snapshot));
return finish();
}
// Topic precedence: a name that's both a topic and a command (e.g. "projects",
// "skills") shows the topic view, which lists every command in that group.
// Matches the legacy HELP_TOPICS behavior.
if (TOPIC_ORDER.includes(target)) {
console.log(formatTopic(target, snapshot));
return finish();
}
const unavailable = snapshot && disabledCommand([target, ...args.slice(1)].join(" "), snapshot);
if (unavailable) {
console.log(unavailable);
return finish();
}
const cmdHelp = formatCommand(target, snapshot);
if (cmdHelp !== null) {
console.log(cmdHelp);
return finish();
}
const docHelp = formatDocTopic(target);
if (docHelp !== null) {
console.log(docHelp);
return finish();
}
console.log(`Unknown topic: ${target}\nAvailable: ${helpTopicNames().join(", ")}`);
return finish();
}
export async function runTopLevelCommand(argv, opts = {}) {
const argvCommand = argv[0];
// Health ping. Must run before lookupCommand - `--health` starts with `--`
// so it's never a registered command name.
if (argvCommand === "--health")
return finish();
if (argvCommand === "--help" || argvCommand === "-h" || argvCommand === "help") {
return runHelp(argv.slice(1));
}
// Bare invocation. On a TTY (and unless suppressed), drop into the shell.
// On a non-TTY pipeline, fall through to the unknown-command path below;
// matches legacy behavior so scripts that grep stderr for "Unknown command"
// keep working.
if (!argvCommand) {
if (process.stdin.isTTY && process.stdout.isTTY) {
if (opts.allowDefaultShell === false) {
console.log(formatCheatSheet(helpSnapshot()));
return finish();
}
const shellCmd = lookupCommand("shell");
if (shellCmd)
await shellCmd.run([], buildCliContext());
return finish();
}
return false;
}
const cmd = lookupCommand(argvCommand);
if (!cmd)
return false;
// Hook-only commands never find, activate or track a store.
if (cmd.standalone && !argv.includes("--help") && !argv.includes("-h")) {
try {
return finish(await cmd.run(argv.slice(1), buildCliContext()) ?? undefined);
}
catch (err) {
console.error(errorMessage(err));
return finish(1);
}
}
let snapshot;
try {
const store = findPhrenPath() ?? defaultPhrenPath();
snapshot = ["modules", "init", "quickstart", "verify", "uninstall", "doctor"].includes(cmd.name) || argv.includes("--help") || argv.includes("-h")
? moduleSnapshot(store) : activateModules(store);
const wantsHook = cmd.name === "pair" || (cmd.name === "bridge" && argv[1] === "install");
if (wantsHook && !snapshot.has("hook") && !argv.includes("--help") && !argv.includes("-h")) {
if (enableHookForPhone(store))
console.log("Turned on the Hook module for this store (phren modules disable hook turns it off).");
snapshot = activateModules(store, undefined, true);
}
const unavailable = disabledCommand([cmd.name, ...argv.slice(1)].join(" "), snapshot);
// Without a store every module reads as disabled; "run init" is the fix, not "enable the module".
if (unavailable) {
console.error(findPhrenPath() ? unavailable : "phren is not set up yet. Run: phren init");
return finish(1);
}
}
catch (error) {
if (!["init", "modules", "doctor", "verify", "uninstall", "link"].includes(cmd.name)) {
console.error(errorMessage(error));
return finish(1);
}
}
// Any command can end up building or reading the FTS snapshot cache, which
// is a SQLite export of the whole store landing in os.tmpdir() — /tmp (1777)
// on Linux and WSL. Harden the per-user cache root before the command runs.
ensureFtsCacheRootPrivate();
// Intercept before any handler runs - in particular before namespace
// handlers that used to print their own per-subcommand help text.
// `phren agent` hands every argument to the agent, which prints its own help.
if (cmd.name !== "agent" && (argv.includes("--help") || argv.includes("-h")) || (argv.length === 1 && ["maintain", "config", "bridge"].includes(cmd.name))) {
const out = formatCommand(cmd.name, snapshot);
if (out)
console.log(out);
return finish();
}
// Telemetry. Broadens from the legacy CLI_COMMANDS-only branch - natives
// (add/init/etc.) are now tracked too. Telemetry is opt-in. Hidden internal
// commands (hook-prompt, background-sync, etc.) are skipped: they fire on
// every prompt and would drown the meaningful counts.
if (!cmd.hidden) {
try {
const { trackCliCommand } = await import("./telemetry.js");
trackCliCommand(defaultPhrenPath(), cmd.name);
}
catch (err) {
logger.debug("cli", `trackCliCommand: ${errorMessage(err)}`);
}
}
try {
const code = await cmd.run(argv.slice(1), buildCliContext());
return finish(typeof code === "number" ? code : undefined);
}
catch (err) {
console.error(errorMessage(err));
return finish(1);
}
}