@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
195 lines (194 loc) • 11.2 kB
JavaScript
import * as fs from "node:fs";
import { request } from "node:http";
import path from "node:path";
import { SCHEDULE_EVERY, SCHEDULE_HARNESSES, SCHEDULE_SESSIONS, WEEKDAYS, computerMatches, canonicalComputer, newScheduleId, parseSchedule, readScheduleDocument, withScheduleLock, writeScheduleDocument, } from "../bridge/schedules.js";
import { object, socketPath } from "../bridge/protocol.js";
import { listMachines } from "../profile-store.js";
import { getProjectDirs } from "../shared.js";
import { scheduleProblems } from "../bridge/schedule-problems.js";
import { localNames } from "../bridge/computer-names.js";
function option(args, name) {
const index = args.indexOf(name);
if (index < 0)
return undefined;
const value = args[index + 1];
if (!value || value.startsWith("--"))
throw new Error(`${name} needs a value.`);
return value;
}
/** Whether this computer is the one the schedule names. */
function ownsHere(schedule) {
return localNames().some(name => computerMatches(schedule.computer, name));
}
function projectDirectory(store, name) {
const directory = getProjectDirs(store).find(candidate => path.basename(candidate).toLowerCase() === name.toLowerCase());
if (!directory)
throw new Error(`Unknown project "${name}".`);
return directory;
}
function scheduleDescription(schedule) {
if (schedule.every === "interval")
return `every ${schedule.interval}`;
if (schedule.every === "daily")
return `daily at ${schedule.at}`;
if (schedule.every === "weekly")
return `${schedule.days.join(",")} at ${schedule.at}`;
if (schedule.every === "once")
return `once ${schedule.once}`;
return `cron ${schedule.cron}`;
}
async function list(store, projectName) {
const directories = projectName ? [projectDirectory(store, projectName)] : getProjectDirs(store);
let count = 0;
for (const directory of directories) {
const project = path.basename(directory), schedules = (await readScheduleDocument(directory)).schedules;
for (const schedule of schedules) {
count++;
console.log(`${project} ${schedule.id} ${schedule.enabled ? "enabled " : "paused "} ${schedule.name}`);
if (schedule.projects)
console.log(` targets: ${schedule.projects.join(", ")}`);
const problems = await scheduleProblems(schedule, project, { store, owned: ownsHere(schedule) });
for (const problem of problems)
console.log(` ! ${problem}`);
console.log(` ${schedule.computer} · ${schedule.harness}${schedule.model ? `/${schedule.model}` : ""} · ${scheduleDescription(schedule)}${schedule.session === "reuse" ? " · continues one session" : ""}`);
}
}
if (!count)
console.log("No schedules.");
}
async function add(store, args) {
const projectName = args[1];
if (!projectName || projectName.startsWith("--"))
throw new Error("Usage: phren schedule add <project> --name ... --prompt <text>");
const directory = projectDirectory(store, projectName), file = await readScheduleDocument(directory);
const name = option(args, "--name"), harness = option(args, "--harness");
const requestedComputer = option(args, "--computer"), every = option(args, "--every");
if (!name || !harness || !requestedComputer || !every)
throw new Error("--name, --harness, --computer, and --every are required.");
if (!SCHEDULE_HARNESSES.includes(harness))
throw new Error(`--harness must be one of: ${SCHEDULE_HARNESSES.join(", ")}.`);
if (!SCHEDULE_EVERY.includes(every))
throw new Error(`--every must be one of: ${SCHEDULE_EVERY.join(", ")}.`);
const session = option(args, "--session");
if (session !== undefined && !SCHEDULE_SESSIONS.includes(session))
throw new Error(`--session must be one of: ${SCHEDULE_SESSIONS.join(", ")}.`);
const machines = listMachines(store);
if (!machines.ok)
throw new Error(machines.error);
const computer = Object.keys(machines.data).find(machine => canonicalComputer(machine) === canonicalComputer(requestedComputer));
if (!computer)
throw new Error(`Computer "${requestedComputer}" is not a machines.yaml key.`);
const inlinePrompt = option(args, "--prompt"), promptFile = option(args, "--prompt-file");
if (Boolean(inlinePrompt) === Boolean(promptFile))
throw new Error("Choose exactly one of --prompt or --prompt-file.");
const prompt = promptFile ? fs.readFileSync(path.resolve(promptFile), "utf8") : inlinePrompt;
const now = new Date().toISOString();
const schedule = {
id: newScheduleId(file.schedules.map(item => item.id)), name, enabled: true, computer, harness,
...(option(args, "--projects") ? { projects: option(args, "--projects").split(",").map(project => project.trim()) } : {}),
...(option(args, "--model") ? { model: option(args, "--model") } : {}),
...(option(args, "--account") ? { account: option(args, "--account") } : {}), ...(session ? { session } : {}), every, prompt, createdAt: now, updatedAt: now,
...(option(args, "--at") ? { at: option(args, "--at") } : {}),
...(option(args, "--days") ? { days: option(args, "--days").split(",").map(day => day.trim().toLowerCase()) } : {}),
...(option(args, "--interval") ? { interval: option(args, "--interval") } : {}),
...(option(args, "--once") ? { once: option(args, "--once") } : {}),
...(option(args, "--cron") ? { cron: option(args, "--cron") } : {}),
};
if (schedule.days?.some(day => !WEEKDAYS.includes(day)))
throw new Error(`--days accepts: ${WEEKDAYS.join(", ")}.`);
const validated = parseSchedule(schedule);
// Refuse what could never fire: the owning computer is this one, so its source paths can be checked now.
const problems = await scheduleProblems(validated, path.basename(directory), { store, owned: ownsHere(validated) });
if (problems.length)
throw new Error(`This schedule could not run: ${problems.join(" ")}`);
// Append to the file as it is now, under the lock every schedules.yaml writer takes.
await withScheduleLock(directory, async () => {
const current = await readScheduleDocument(directory);
await writeScheduleDocument(directory, [...current.schedules, validated], current.document);
});
console.log(`Added schedule ${validated.id} to ${path.basename(directory)}.`);
}
async function mutate(store, action, projectName, id) {
if (!projectName || !id)
throw new Error(`Usage: phren schedule ${action} <project> <id>`);
const directory = projectDirectory(store, projectName);
await withScheduleLock(directory, async () => {
const file = await readScheduleDocument(directory);
const existing = file.schedules.find(schedule => schedule.id === id);
if (!existing)
throw new Error(`Schedule "${id}" was not found in ${path.basename(directory)}.`);
const schedules = action === "remove" ? file.schedules.filter(schedule => schedule.id !== id)
: file.schedules.map(schedule => schedule.id === id ? { ...schedule, enabled: action === "enable", updatedAt: new Date().toISOString() } : schedule);
await writeScheduleDocument(directory, schedules, file.document);
});
console.log(`${action === "remove" ? "Removed" : action === "enable" ? "Enabled" : "Disabled"} schedule ${id}.`);
}
async function hookPost(route, data) {
return new Promise((resolve, reject) => {
const payload = JSON.stringify(data);
const req = request({ socketPath: socketPath(), path: route, method: "POST", timeout: 60_000,
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) } }, response => {
let text = "";
response.on("data", chunk => { text += chunk.toString(); if (text.length > 8 * 1024 * 1024)
req.destroy(new Error("Phren Hook response is too large.")); });
response.on("end", () => {
let result;
try {
result = object(JSON.parse(text));
}
catch {
reject(new Error("Phren Hook returned an invalid response."));
return;
}
if ((response.statusCode ?? 500) >= 400)
reject(new Error(typeof result.error === "string" ? result.error : "Phren Hook rejected the request."));
else
resolve(result);
});
});
req.on("timeout", () => req.destroy(new Error("Phren Hook did not answer.")));
req.on("error", error => reject(error.code === "ENOENT" || error.code === "ECONNREFUSED"
? new Error("Phren Hook is not running. Start it with `phren bridge install`.") : error));
req.end(payload);
});
}
async function runNow(project, id) {
if (!project || !id)
throw new Error("Usage: phren schedule run <project> <id>");
const result = await hookPost("/v1/schedules/run", { project, id });
const runs = Array.isArray(result.runs) ? result.runs.map(object) : [object(result.run)];
for (const run of runs)
console.log(`${String(run.project)}: ${String(run.status ?? "launched")} (${String(run.id ?? "run recorded")})${run.reason ? ` — ${String(run.reason)}` : ""}`);
if (result.ok === false)
throw new Error("Some schedule targets failed to launch; see the per-project results above.");
}
async function history(args) {
const project = args[1] && !args[1].startsWith("--") ? args[1] : undefined;
const rawLimit = option(args, "--limit"), limit = rawLimit === undefined ? undefined : Number(rawLimit);
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 500))
throw new Error("--limit must be between 1 and 500.");
const result = await hookPost("/v1/schedules/history", { ...(project ? { project } : {}), ...(option(args, "--id") ? { id: option(args, "--id") } : {}), ...(limit ? { limit } : {}) });
const runs = Array.isArray(result.runs) ? result.runs.map(object) : [];
if (!runs.length) {
console.log("No schedule runs.");
return;
}
for (const run of runs)
console.log(`${String(run.startedAt)} ${String(run.status).padEnd(9)} ${String(run.project)}/${String(run.scheduleId)}${run.reason ? ` ${String(run.reason)}` : ""}`);
}
export async function handleScheduleCommand(args, store) {
const action = args[0];
if (!action || action === "list")
await list(store, args[1]);
else if (action === "add")
await add(store, args);
else if (action === "remove" || action === "enable" || action === "disable")
await mutate(store, action, args[1], args[2]);
else if (action === "run")
await runNow(args[1], args[2]);
else if (action === "history")
await history(args);
else
throw new Error("Usage: phren schedule <list|add|remove|enable|disable|run|history>");
return 0;
}