@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
121 lines (120 loc) • 6.33 kB
JavaScript
import { createInterface } from "node:readline/promises";
import { parseArgs } from "node:util";
import { clearProjectAuthority, confirmAuthority, confirmInputSchema, listConfirmations, projectAuthority, projectEntrySchema, readAuthority, releaseAction, setProjectAuthority, } from "./authority.js";
const USAGE = [
"Usage: phren authority [list]",
" phren authority show <project>",
" phren authority set <project> [--default go|ask] [--go <actions>] [--ask <actions>] [--max-permission-mode <mode>|none] [--note <text>]",
" phren authority clear <project>",
" phren authority confirm <project> <actions> [--minutes <n>]",
"Actions: merge, publish, deploy, app-store, github-admin (comma-separated).",
].join("\n");
/** The harness whose shell this is, when an agent runs the command. */
export function agentShell(env = process.env) {
if (env.CLAUDECODE === "1" || env.CLAUDE_CODE_SESSION_ID)
return "Claude Code";
if (env.CODEX_THREAD_ID || env.CODEX_SESSION_ID)
return "Codex";
if (env.PHREN_FANOUT_JOB || env.PHREN_FANOUT_DIR)
return "a phren fan-out worker";
return undefined;
}
function processTerminal() {
return {
env: process.env,
interactive: !!process.stdin.isTTY && !!process.stdout.isTTY,
ask: async (question) => {
const lines = createInterface({ input: process.stdin, output: process.stderr });
try {
return await lines.question(question);
}
finally {
lines.close();
}
},
};
}
/** Changes are the owner's: refused in an agent's shell or without a
* terminal, and made only after the owner types the project name. */
export async function ownerConfirms(change, project, terminal) {
const agent = agentShell(terminal.env);
if (agent)
throw new Error(`The release authority policy is the owner's. ${agent} can read it (phren authority show ${project}) but not change it; ask the owner to run this in their own terminal or use the phone.`);
if (!terminal.interactive)
throw new Error("Changing the release authority policy needs an interactive terminal: the owner types the project name to confirm.");
const typed = await terminal.ask(`${change}\nType ${project} to confirm: `);
if (typed.trim() !== project)
throw new Error("Not confirmed; nothing changed.");
}
function actionList(value) {
if (value === undefined)
return [];
return value.split(",").map(item => item.trim()).filter(Boolean).map(item => releaseAction.parse(item));
}
/** The entry `set` saves: the current one with the flags applied. */
export function mergedEntry(current, values) {
const entry = { ...current, ...(current?.actions ? { actions: { ...current.actions } } : {}) };
if (values.default !== undefined)
entry.default = values.default;
const go = actionList(values.go), ask = actionList(values.ask);
const overlap = go.filter(action => ask.includes(action));
if (overlap.length)
throw new Error(`${overlap.join(", ")} cannot be both go and ask.`);
if (go.length || ask.length)
entry.actions = { ...entry.actions, ...Object.fromEntries([...go.map(a => [a, "go"]), ...ask.map(a => [a, "ask"])]) };
const mode = values["max-permission-mode"];
if (mode === "none")
delete entry.maxPermissionMode;
else if (mode !== undefined)
entry.maxPermissionMode = mode;
if (values.note === "")
delete entry.note;
else if (values.note !== undefined)
entry.note = values.note;
return entry;
}
export async function runAuthority(args, terminal = processTerminal()) {
const [action = "list", ...rest] = args;
const print = (value) => console.log(JSON.stringify(value, null, 2));
if (action === "list" && !rest.length) {
const policy = await readAuthority();
print({ source: policy.source, ...(policy.updatedAt ? { updatedAt: policy.updatedAt, updatedBy: policy.updatedBy } : {}),
projects: Object.keys(policy.projects).sort().map(name => projectAuthority(policy, name)), confirmations: await listConfirmations() });
return 0;
}
if (action === "show" && rest.length === 1) {
print(projectAuthority(await readAuthority(), rest[0]));
return 0;
}
if (action === "set") {
const { values, positionals } = parseArgs({ args: rest, allowPositionals: true, options: {
default: { type: "string" }, go: { type: "string" }, ask: { type: "string" }, "max-permission-mode": { type: "string" }, note: { type: "string" },
} });
if (positionals.length !== 1)
throw new Error(USAGE);
const project = positionals[0];
const entry = projectEntrySchema.parse(mergedEntry((await readAuthority()).projects[project], values));
// Validated before the owner is asked, so a typo never waits on a confirmation.
const preview = projectAuthority({ projects: { [project]: entry } }, project);
await ownerConfirms(`New policy: ${preview.line}`, project, terminal);
print(await setProjectAuthority({ project, ...entry }, "cli"));
return 0;
}
if (action === "clear" && rest.length === 1) {
await ownerConfirms(`Remove ${rest[0]} from the release authority policy (it becomes go for every release action).`, rest[0], terminal);
print(await clearProjectAuthority({ project: rest[0] }, "cli"));
return 0;
}
if (action === "confirm") {
const { values, positionals } = parseArgs({ args: rest, allowPositionals: true, options: { minutes: { type: "string" } } });
if (positionals.length !== 2)
throw new Error(USAGE);
const [project, actions] = positionals;
const minutes = values.minutes !== undefined ? Number(values.minutes) : undefined;
const input = confirmInputSchema.parse({ project, actions: actionList(actions), ...(minutes !== undefined ? { minutes } : {}) });
await ownerConfirms(`Let one agent dispatch to ${project} do ${input.actions.join(", ")} within ${minutes ?? 30} minutes.`, project, terminal);
print(await confirmAuthority(input, "cli"));
return 0;
}
throw new Error(USAGE);
}