@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
474 lines (473 loc) • 19.5 kB
JavaScript
import * as fs from "node:fs";
import * as path from "node:path";
import { findProjectNameCaseInsensitive, getPhrenPath } from "../shared.js";
import { resolveRuntimeProfile } from "../runtime-profile.js";
import { enableTaskFormat, taskFormatStatus, taskFormatMigrationHint, taskFormatCurrentWriters } from "../data/task-format.js";
import { resolveAllStores } from "../store-registry.js";
import { addTask, completeTask, updateTask, reorderTask, pinTask, removeTask, workNextTask, tidyDoneTasks, linkTaskIssue, promoteTask, resolveTaskItem } from "../data/tasks.js";
import { buildTaskIssueBody, createGithubIssueForTask, parseGithubIssueUrl, resolveProjectGithubRepo } from "../task/github.js";
function printTaskUsage() {
console.log("Usage:");
console.log(' phren task list [profile|project] [--responsibility=human|agent] [--readiness=ready|waiting-on-human|waiting-on-task]');
console.log(' phren task format [--json | enable --all-writers-compatible]');
console.log(' phren task add <project> "<text>"');
console.log(' phren task complete <project> "<text>"');
console.log(' phren task remove <project> "<text>"');
console.log(' phren task next [project]');
console.log(' phren task promote <project> "<text>" [--active]');
console.log(' phren task tidy [project] [--keep=<n>] [--dry-run]');
console.log(' phren task link <project> "<text>" --issue <number> [--url <url>]');
console.log(' phren task link <project> "<text>" --unlink');
console.log(' phren task create-issue <project> "<text>" [--repo <owner/name>] [--title "<title>"] [--done]');
console.log(' phren task update <project> "<text>" [--priority=high|medium|low] [--section=Active|Queue|Done] [--context="..."] [--responsibility=human|agent] [--dependencies=JSON]');
console.log(' phren task pin <project> "<text>"');
console.log(' phren task reorder <project> "<text>" --rank=<n>');
}
/** The store whose folder holds the working directory, else the primary store. */
function taskFormatStore(phrenPath) {
const real = (p) => { try {
return fs.realpathSync(p);
}
catch {
return path.resolve(p);
} };
const cwd = real(process.cwd());
const stores = resolveAllStores(phrenPath).filter(s => s.available !== false);
const inside = stores
.map(s => ({ store: s, root: real(s.path) }))
.filter(({ root }) => cwd === root || cwd.startsWith(root + path.sep))
.sort((a, b) => b.root.length - a.root.length)[0]?.store;
return inside ?? stores.find(s => s.role === "primary") ?? { name: "primary", path: phrenPath, role: "primary" };
}
export async function handleTaskNamespace(args) {
const subcommand = args[0];
if (!subcommand || subcommand === "--help" || subcommand === "-h") {
printTaskUsage();
return;
}
if (subcommand === "list") {
// Delegate to the cross-project task view (same as `phren tasks`)
return handleTaskListArgs(args.slice(1), resolveRuntimeProfile(getPhrenPath()));
}
if (subcommand === "format") {
const store = taskFormatStore(getPhrenPath());
const label = `${store.name} (${store.path})`;
if (args.length === 1 || (args.length === 2 && args[1] === "--json")) {
const status = taskFormatStatus(store.path);
if (args[1] === "--json") {
console.log(JSON.stringify({ ...status, store: store.name, path: store.path }));
return;
}
console.log(`Store: ${label}`);
console.log(`Task metadata: ${status.enabled ? `on${status.acknowledgedAt ? ` since ${status.acknowledgedAt.slice(0, 10)}` : ""}` : "off"}`);
console.log("");
console.log("Task metadata records who a task is for (you or an agent) and which tasks it waits on.");
if (status.enabled)
return;
console.log("It is off because an older app or tool could drop it when it rewrites a task.");
console.log(`These versions and later keep it: ${taskFormatCurrentWriters}.`);
console.log("Once every computer and phone that writes this store is current, turn it on:");
console.log(" phren task format enable --all-writers-compatible");
return;
}
if (args.length !== 3 || args[1] !== "enable" || args[2] !== "--all-writers-compatible") {
console.error(taskFormatMigrationHint);
process.exitCode = 1;
return;
}
if (store.role === "readonly") {
console.error(`Store ${label} is read-only on this computer. Turn task metadata on from a computer that writes it.`);
process.exitCode = 1;
return;
}
try {
enableTaskFormat(store.path, true);
console.log(`Task metadata is on for store ${label}. Apps and tools older than ${taskFormatCurrentWriters} must no longer write it.`);
}
catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
return;
}
if (subcommand === "add") {
const project = args[1];
const text = args.slice(2).join(" ");
if (!project || !text) {
console.error('Usage: phren task add <project> "<text>"');
process.exit(1);
}
const result = addTask(getPhrenPath(), project, text);
if (!result.ok) {
console.error(result.error);
process.exit(1);
}
console.log(`Task added: ${result.data.line}`);
return;
}
if (subcommand === "complete") {
const project = args[1];
const match = args.slice(2).join(" ");
if (!project || !match) {
console.error('Usage: phren task complete <project> "<text>"');
process.exit(1);
}
const result = completeTask(getPhrenPath(), project, match);
if (!result.ok) {
console.error(result.error);
process.exit(1);
}
console.log(result.data);
return;
}
if (subcommand === "update") {
const project = args[1];
if (!project) {
printTaskUsage();
process.exit(1);
}
// Collect non-flag args as the match text, flags as updates
const positional = [];
const updates = {};
for (const arg of args.slice(2)) {
if (arg.startsWith("--responsibility=")) {
const value = arg.slice(17);
if (value !== "human" && value !== "agent") {
console.error("Responsibility must be human or agent.");
process.exit(1);
}
updates.responsibility = value;
}
else if (arg.startsWith("--dependencies=")) {
try {
updates.dependencies = JSON.parse(arg.slice(15));
}
catch {
console.error("Dependencies must be a JSON array of storeId/project/stableId objects.");
process.exit(1);
}
}
else if (arg.startsWith("--priority=")) {
updates.priority = arg.slice("--priority=".length);
}
else if (arg.startsWith("--section=")) {
updates.section = arg.slice("--section=".length);
}
else if (arg.startsWith("--context=")) {
updates.context = arg.slice("--context=".length);
}
else if (!arg.startsWith("--")) {
positional.push(arg);
}
}
const match = positional.join(" ");
if (!match) {
printTaskUsage();
process.exit(1);
}
const result = updateTask(getPhrenPath(), project, match, updates);
if (!result.ok) {
console.error(result.error);
process.exit(1);
}
console.log(result.data);
return;
}
if (subcommand === "pin") {
const project = args[1];
const match = args.slice(2).join(" ");
if (!project || !match) {
console.error('Usage: phren task pin <project> "<text>"');
process.exit(1);
}
const result = pinTask(getPhrenPath(), project, match);
if (!result.ok) {
console.error(result.error);
process.exit(1);
}
console.log(result.data);
return;
}
if (subcommand === "reorder") {
const project = args[1];
if (!project) {
printTaskUsage();
process.exit(1);
}
const positional = [];
let rankArg;
for (const arg of args.slice(2)) {
if (arg.startsWith("--rank=")) {
rankArg = arg.slice("--rank=".length);
}
else if (!arg.startsWith("--")) {
positional.push(arg);
}
}
const match = positional.join(" ");
const rank = rankArg ? Number.parseInt(rankArg, 10) : Number.NaN;
if (!match || !rankArg || !Number.isFinite(rank) || rank < 1) {
console.error('Usage: phren task reorder <project> "<text>" --rank=<n>');
process.exit(1);
}
const result = reorderTask(getPhrenPath(), project, match, rank);
if (!result.ok) {
console.error(result.error);
process.exit(1);
}
console.log(result.data);
return;
}
if (subcommand === "remove") {
const project = args[1];
const match = args.slice(2).join(" ");
if (!project || !match) {
console.error('Usage: phren task remove <project> "<text>"');
process.exit(1);
}
const result = removeTask(getPhrenPath(), project, match);
if (!result.ok) {
console.error(result.error);
process.exit(1);
}
console.log(result.data);
return;
}
if (subcommand === "next") {
const project = args[1];
if (!project) {
console.error("Usage: phren task next <project>");
process.exit(1);
}
const result = workNextTask(getPhrenPath(), project);
if (!result.ok) {
console.error(result.error);
process.exit(1);
}
console.log(result.data);
return;
}
if (subcommand === "promote") {
const project = args[1];
if (!project) {
printTaskUsage();
process.exit(1);
}
const positional = [];
let moveToActive = false;
for (const arg of args.slice(2)) {
if (arg === "--active") {
moveToActive = true;
}
else if (!arg.startsWith("--")) {
positional.push(arg);
}
}
const match = positional.join(" ");
if (!match) {
console.error('Usage: phren task promote <project> "<text>" [--active]');
process.exit(1);
}
const result = promoteTask(getPhrenPath(), project, match, moveToActive);
if (!result.ok) {
console.error(result.error);
process.exit(1);
}
console.log(`Promoted task "${result.data.line}" in ${project}${moveToActive ? " (moved to Active)" : ""}.`);
return;
}
if (subcommand === "tidy") {
const project = args[1];
if (!project) {
console.error("Usage: phren task tidy <project> [--keep=<n>] [--dry-run]");
process.exit(1);
}
let keep = 30;
let dryRun = false;
for (const arg of args.slice(2)) {
if (arg.startsWith("--keep=")) {
const n = Number.parseInt(arg.slice("--keep=".length), 10);
if (Number.isFinite(n) && n > 0)
keep = n;
}
else if (arg === "--dry-run") {
dryRun = true;
}
}
const result = tidyDoneTasks(getPhrenPath(), project, keep, dryRun);
if (!result.ok) {
console.error(result.error);
process.exit(1);
}
console.log(result.data);
return;
}
if (subcommand === "link") {
const project = args[1];
if (!project) {
printTaskUsage();
process.exit(1);
}
const positional = [];
let issueArg;
let urlArg;
let unlink = false;
const rest = args.slice(2);
for (let i = 0; i < rest.length; i++) {
const arg = rest[i];
if (arg === "--issue" || arg === "-i") {
issueArg = rest[++i];
}
else if (arg.startsWith("--issue=")) {
issueArg = arg.slice("--issue=".length);
}
else if (arg === "--url") {
urlArg = rest[++i];
}
else if (arg.startsWith("--url=")) {
urlArg = arg.slice("--url=".length);
}
else if (arg === "--unlink") {
unlink = true;
}
else if (!arg.startsWith("--")) {
positional.push(arg);
}
}
const match = positional.join(" ");
if (!match) {
console.error('Usage: phren task link <project> "<text>" --issue <number>');
process.exit(1);
}
if (!unlink && !issueArg && !urlArg) {
console.error("Provide --issue <number> or --url <url> to link, or --unlink to remove the link.");
process.exit(1);
}
if (urlArg) {
const parsed = parseGithubIssueUrl(urlArg);
if (!parsed) {
console.error("--url must be a valid GitHub issue URL.");
process.exit(1);
}
}
const result = linkTaskIssue(getPhrenPath(), project, match, {
github_issue: issueArg,
github_url: urlArg,
unlink: unlink,
});
if (!result.ok) {
console.error(result.error);
process.exit(1);
}
if (unlink) {
console.log(`Removed GitHub link from ${project} task.`);
}
else {
console.log(`Linked ${project} task to ${result.data.githubIssue ? `#${result.data.githubIssue}` : result.data.githubUrl}.`);
}
return;
}
if (subcommand === "create-issue") {
const project = args[1];
if (!project) {
printTaskUsage();
process.exit(1);
}
const positional = [];
let repoArg;
let titleArg;
let markDone = false;
const rest = args.slice(2);
for (let i = 0; i < rest.length; i++) {
const arg = rest[i];
if (arg === "--repo") {
repoArg = rest[++i];
}
else if (arg.startsWith("--repo=")) {
repoArg = arg.slice("--repo=".length);
}
else if (arg === "--title") {
titleArg = rest[++i];
}
else if (arg.startsWith("--title=")) {
titleArg = arg.slice("--title=".length);
}
else if (arg === "--done") {
markDone = true;
}
else if (!arg.startsWith("--")) {
positional.push(arg);
}
}
const match = positional.join(" ");
if (!match) {
console.error('Usage: phren task create-issue <project> "<text>" [--repo <owner/name>] [--title "<title>"] [--done]');
process.exit(1);
}
const phrenPath = getPhrenPath();
const resolved = resolveTaskItem(phrenPath, project, match);
if (!resolved.ok) {
console.error(resolved.error);
process.exit(1);
}
const targetRepo = repoArg || resolveProjectGithubRepo(phrenPath, project);
if (!targetRepo) {
console.error("Could not infer a GitHub repo. Provide --repo <owner/name> or add a GitHub URL to AGENTS.md/summary.md.");
process.exit(1);
}
const created = createGithubIssueForTask({
repo: targetRepo,
title: titleArg?.trim() || resolved.data.line.replace(/\s*\[(high|medium|low)\]\s*$/i, "").trim(),
body: buildTaskIssueBody(project, resolved.data),
});
if (!created.ok) {
console.error(created.error);
process.exit(1);
}
const linked = linkTaskIssue(phrenPath, project, resolved.data.stableId ? `bid:${resolved.data.stableId}` : resolved.data.id, {
github_issue: created.data.issueNumber,
github_url: created.data.url,
});
if (!linked.ok) {
console.error(linked.error);
process.exit(1);
}
if (markDone) {
const completionMatch = linked.data.stableId ? `bid:${linked.data.stableId}` : linked.data.id;
const completed = completeTask(phrenPath, project, completionMatch);
if (!completed.ok) {
console.error(completed.error);
process.exit(1);
}
}
console.log(`Created GitHub issue ${created.data.issueNumber ? `#${created.data.issueNumber}` : created.data.url} for ${project} task.`);
return;
}
console.error(`Unknown task subcommand: ${subcommand}`);
printTaskUsage();
process.exit(1);
}
export async function handleTaskListArgs(args, defaultProfile) {
const { handleTaskView } = await import("./ops.js");
const { z } = await import("zod");
const filter = z.object({ responsibility: z.enum(["human", "agent"]).optional(), readiness: z.enum(["ready", "waiting-on-human", "waiting-on-task"]).optional() }).strict().safeParse(Object.fromEntries(args.filter(a => a.startsWith("--")).map(a => a.slice(2).split("="))));
if (!filter.success)
throw new Error("Invalid task responsibility/readiness filter; use --responsibility=human|agent or --readiness=ready|waiting-on-human|waiting-on-task.");
const profiles = args.filter(a => !a.startsWith("--"));
if (profiles.length > 1)
throw new Error("Pass at most one task profile or project.");
// `phren task list phren` names a project as often as a profile: a name that is a
// project of the active profile lists just that project instead of an empty profile.
const { readTasksAcrossProjects } = await import("../data/access.js");
const project = profiles[0] && readTasksAcrossProjects(getPhrenPath(), defaultProfile).some(doc => doc.project.toLowerCase() === profiles[0].toLowerCase()) ? profiles[0] : undefined;
if (profiles[0] && !project) {
const { listProfiles } = await import("../profile-store.js");
const known = listProfiles(getPhrenPath());
const isProfile = known.ok && known.data.some(p => p.name === profiles[0]);
if (!isProfile && !findProjectNameCaseInsensitive(getPhrenPath(), profiles[0])) {
console.error(`"${profiles[0]}" is neither a profile nor a project.`);
process.exit(1);
}
}
return handleTaskView(project ? defaultProfile : profiles[0] || defaultProfile, filter.data, project);
}