UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

356 lines (355 loc) • 21.9 kB
import { readFile } from "node:fs/promises"; import { serve } from "./server.js"; import { dispatch, health } from "./transport.js"; import { install, rollback, uninstall } from "./install.js"; import { servers } from "./herdr.js"; import { describeTerminal, terminalHealth } from "./health.js"; import { agentHook } from "./agent-hooks.js"; import { askpass } from "./sudo.js"; import { object, provider, socketPath } from "./protocol.js"; import { apnsSetupSteps } from "./push.js"; import { speechKeyFile, speechKeyStatus, writeSpeechKey } from "./speech-key.js"; import { gitHostsFile, listHostTokens, writeHostToken } from "./git-host-auth.js"; import { hostInfo, PROVIDERS } from "./git-hosts.js"; import { clearSpeechModel, clearSpeechVoice, DEFAULT_SPEECH_MODEL, FALLBACK_SPEECH_MODEL, resolveSpeechModel, resolveSpeechRegion, resolveSpeechVoice, SPEECH_REGIONS, speechModelId, speechRegion, speechVoiceFile, voiceId, writeSpeechModel, writeSpeechRegion, writeSpeechVoice } from "./speech-voice.js"; import { AccountUsageReader, captureClaudeUsage } from "./usage.js"; import { acceptComputer, enrollComputer } from "./computers.js"; import { addPeerFromLink, discoverComputers, linkComputer } from "./link.js"; import { ARCHIVE_MAX_FOLDERS, archiveFinishedFanouts, FANOUTS_ARCHIVE_USAGE, parseFanoutArchiveFlags } from "./fanouts.js"; import { harnessInventory } from "./harnesses.js"; import { addClaudeAccount } from "./claude-account-setup.js"; import { setAccountLabel } from "./claude-accounts.js"; import { addClaudeCredit, claudeCredits, removeClaudeCredit } from "./claude-credits.js"; import { ModelCatalog } from "./models.js"; const CREDIT_USAGE = "Usage: phren bridge accounts credit <id> [--amount <usd> [--expires <yyyy-mm-dd>] [--since <yyyy-mm-dd>] [--label <name>] | --remove <credit>]"; const LINK_USAGE = "Usage: phren bridge link <ssh-host> [--name <its name here>] [--as <this computer's name there>] [--back-address <address it dials>] [--yes]"; export async function runBridge(args, version) { switch (args[0]) { case "enroll-computer": { if (args.length === 2) console.log(await enrollComputer(args[1])); else if (args.length === 4 && args[2] === "--accept") { await acceptComputer(args[1], await readFile(args[3], "utf8")); console.log(`Enrolled ${args[1]} for Phren Hook.`); } else throw new Error("Usage: phren bridge enroll-computer <name> [--accept <public-key-file>]"); break; } case "accounts": { if (args[1] === "add" && args[2] === "--") args.splice(2, 1); if (args.length === 1) { for (const harness of (await harnessInventory()).harnesses) { console.log(`${harness.source}: ${harness.installed ? `installed ${harness.version ?? ""}`.trim() : "not installed"}, ${harness.usable ? "usable" : `not usable (${harness.reason ?? "unknown"})`}`); for (const account of harness.accounts ?? []) console.log(` ${account.id} ${account.label} ${account.signedIn ? "signed in" : "not signed in"} ${account.usable ? "usable" : `not usable (${account.reason ?? "unknown"})`}${account.plan ? ` plan ${account.plan}` : ""}`); } } else if (args[1] === "add" && args[2] && !args[2].startsWith("-")) { const at = args.indexOf("--label"); if (at !== -1 && !args[at + 1]) throw new Error("Usage: phren bridge accounts add <slug> [--label <name>]"); const added = await addClaudeAccount(args[2], { label: at === -1 ? undefined : args[at + 1] }); console.log(`${added.created ? "Created" : "Updated"} ${added.dir}${added.linked.length ? `, linked ${added.linked.join(", ")} from the default home` : ""}.`); console.log(`Next: CLAUDE_CONFIG_DIR=${added.dir} claude # finish first-run setup, run /login with this account, then /exit`); console.log(`Check: CLAUDE_CONFIG_DIR=${added.dir} claude auth status --text; phren bridge accounts`); } else if (args[1] === "label" && args.length >= 4) { await setAccountLabel(args[2], args.slice(3).join(" ")); console.log(`Labeled ${args[2]} "${args.slice(3).join(" ").trim()}".`); } else if (args[1] === "credit" && args[2] && !args[2].startsWith("-")) { const flag = (name) => { const at = args.indexOf(name); if (at !== -1 && (!args[at + 1] || args[at + 1].startsWith("--"))) throw new Error(CREDIT_USAGE); return at === -1 ? undefined : args[at + 1]; }; const amount = flag("--amount"), remove = flag("--remove"); if (remove) console.log(await removeClaudeCredit(args[2], remove) ? `Removed credit ${remove} from ${args[2]}.` : `No credit ${remove} on ${args[2]}.`); else if (amount) { const credit = await addClaudeCredit(args[2], { amount, expires: flag("--expires"), since: flag("--since"), label: flag("--label") }); console.log(`Credit ${credit.id} on ${args[2]}: $${credit.amountUSD.toFixed(2)} from ${credit.since.slice(0, 10)}${credit.expiresAt ? `, expires ${credit.expiresAt.slice(0, 10)}` : ""}. Usage shows the estimated balance.`); } else { const credits = claudeCredits(args[2]); if (!credits.length) console.log(`No credits on ${args[2]}. ${CREDIT_USAGE}`); for (const credit of credits) console.log(`${credit.id} $${credit.amountUSD.toFixed(2)} from ${credit.since.slice(0, 10)}${credit.expiresAt ? ` expires ${credit.expiresAt.slice(0, 10)}` : ""}${credit.label ? ` ${credit.label}` : ""}`); } } else throw new Error("Usage: phren bridge accounts [add <slug> [--label <name>] | label <id> <label> | credit <id> [--amount <usd> [--expires <date>] [--since <date>] [--label <name>] | --remove <credit>]]"); break; } case "discover": { const { reachable, checked } = await discoverComputers(); if (!reachable.length) console.log(`No unlinked computers running Phren Hook answered over ssh (checked ${checked.length}: ${checked.join(", ") || "none"}).`); else { console.log("Reachable over ssh, running Phren Hook, and not linked:"); for (const item of reachable) console.log(` ${item.host} (${item.user}) link with: phren bridge link ${item.host}`); } break; } case "link": { const host = args[1]; const flag = (name) => { const index = args.indexOf(name); return index > 1 ? args[index + 1] : undefined; }; if (!host || host.startsWith("-")) throw new Error(LINK_USAGE); if (!args.includes("--yes")) { if (!process.stdin.isTTY) throw new Error(`Linking ${host} lets each computer run agents on the other. Pass --yes to confirm.`); const { createInterface } = await import("node:readline/promises"); const prompt = createInterface({ input: process.stdin, output: process.stdout }); const answer = await prompt.question(`Link this computer and ${host} both ways, so each can list and start agents on the other? [y/N] `); prompt.close(); if (!/^y(es)?$/i.test(answer.trim())) { console.log("Not linked."); return 1; } } const result = await linkComputer(host, { name: flag("--name"), as: flag("--as"), backAddress: flag("--back-address") }); console.log(JSON.stringify(result, null, 2)); return result.reachable && result.remote.reachable ? 0 : 1; } case "add-peer": { // The receiving half of `phren bridge link`, run by the linking computer over ssh. const chunks = []; for await (const chunk of process.stdin) chunks.push(chunk); console.log(JSON.stringify(await addPeerFromLink(Buffer.concat(chunks).toString("utf8")))); break; } case "fanouts": { if (args[1] !== "archive") throw new Error(FANOUTS_ARCHIVE_USAGE); const options = parseFanoutArchiveFlags(args.slice(2)); const dryRun = options.dryRun ?? false; const { moved, deleted } = await archiveFinishedFanouts(process.env, options); const doing = dryRun ? "Would archive" : "Archived"; const removal = dryRun ? "would delete" : "deleted"; console.log(`${doing} ${moved.length} fan-out job(s); ${removal} ${deleted} past the ${ARCHIVE_MAX_FOLDERS}-folder cap.`); break; } case "speech-key": { // The key comes from stdin, never argv, so it stays out of ps and shell history. if (args[1] !== "set" || args.length !== 2) throw new Error(SPEECH_KEY_USAGE); await writeSpeechKey(await readSecret("ElevenLabs API key: ")); console.log(`Stored the ElevenLabs key in ${speechKeyFile()} (mode 600).`); break; } case "git-host": { // Tokens for GitLab and gitboy, which the Hook reads over HTTP; GitHub keeps gh's sign-in. const [, action = "list", domain, kind] = args; if (action === "list" && args.length <= 2) { const hosts = await listHostTokens(); if (hosts === "unsafe") throw new Error(`${gitHostsFile()} is readable by other users; run chmod 600 on it.`); if (!hosts.length) console.log("No GitLab or gitboy tokens stored. Add one with phren bridge git-host set <domain> gitlab|gitboy."); for (const host of hosts) console.log(`${host.domain} ${host.kind}${host.user ? ` @${host.user}` : ""}`); } else if (action === "set" && domain && (kind === "gitlab" || kind === "gitboy") && args.length === 4) { // The token comes from stdin, never argv, so it stays out of ps and shell history. const token = (await readSecret(`${kind === "gitlab" ? "GitLab" : "gitboy"} token for ${domain}: `)).trim(); if (!token) throw new Error("No token given."); const host = hostInfo(kind, { domain: domain.toLowerCase(), webUrl: null, remote: null, source: "none" }); const { user } = await PROVIDERS[kind].whoami(process.cwd(), host, token); await writeHostToken(domain, kind, token, user || undefined); console.log(`Connected ${domain} (${kind})${user ? ` as @${user}` : ""}. Stored in ${gitHostsFile()} (mode 600).`); } else if (action === "remove" && domain && args.length === 3) { await writeHostToken(domain, "gitlab", ""); console.log(`Forgot the token for ${domain}.`); } else throw new Error(GIT_HOST_USAGE); break; } case "speech-voice": { // Read on every spoken reply, so a change needs no Hook restart. const [, action = "show", id] = args; if (action === "set" && id && args.length === 3) { if (!voiceId.safeParse(id).success) throw new Error(`"${id}" is not an ElevenLabs voice id (10 to 40 letters and digits, e.g. S9EGwlCtMF7VXtENq79v).`); console.log(`Talk mode now speaks with ${await writeSpeechVoice(id)} (stored in ${speechVoiceFile()}). Install and update keep it.`); } else if (action === "clear" && args.length === 2) { await clearSpeechVoice(); const next = await resolveSpeechVoice(); console.log(`Cleared the stored voice; talk mode uses ${next.voice} (${next.source}).`); } else if (action === "show" && args.length <= 2) { const current = await resolveSpeechVoice(); console.log(`${current.voice} (${current.source === "setting" ? `stored in ${speechVoiceFile()}` : current.source})`); } else throw new Error(SPEECH_VOICE_USAGE); break; } case "speech-model": { const [, action = "show", id] = args; if (action === "set" && id && args.length === 3) { if (!speechModelId.safeParse(id).success) throw new Error(`"${id}" is not an ElevenLabs model id (e.g. ${DEFAULT_SPEECH_MODEL} or ${FALLBACK_SPEECH_MODEL}).`); const model = await writeSpeechModel(id); const fallback = model === FALLBACK_SPEECH_MODEL ? "" : `, falling back to ${FALLBACK_SPEECH_MODEL} when it fails or is slow`; console.log(`Talk mode now speaks with ${model} (stored in ${speechVoiceFile()})${fallback}. Install and update keep it.`); } else if (action === "clear" && args.length === 2) { await clearSpeechModel(); console.log(`Cleared the stored model; talk mode uses ${DEFAULT_SPEECH_MODEL} (default).`); } else if (action === "show" && args.length <= 2) { const current = await resolveSpeechModel(); console.log(`${current.model} (${current.source === "setting" ? `stored in ${speechVoiceFile()}` : current.source})`); } else throw new Error(SPEECH_MODEL_USAGE); break; } case "speech-region": { const [, region = "show"] = args; if (args.length > 2) throw new Error(SPEECH_REGION_USAGE); if (region === "show") { const current = await resolveSpeechRegion(); console.log(`${current.region}: ${current.origin} (${current.source === "setting" ? `stored in ${speechVoiceFile()}` : current.source})`); } else if (speechRegion.safeParse(region).success) { const stored = await writeSpeechRegion(region); console.log(`Talk mode now reaches ElevenLabs at ${SPEECH_REGIONS[stored]} (${stored}). Install and update keep it.`); } else throw new Error(SPEECH_REGION_USAGE); break; } case "usage-statusline": await captureClaudeUsage(args[1] || ""); break; case "usage": console.log(JSON.stringify(await new AccountUsageReader().read(), null, 2)); break; case "hook": if (await agentHook(provider.parse(args[1])).catch(() => undefined) === "abandoned") process.exit(0); break; // sudo -A runs <bridge>/askpass, which runs this: the password on stdout, or exit 1. case "askpass": return askpass(args[1]); case "serve": { // The subprocess bridge suite supplies this fixture; no agent binaries or // machine-specific catalogue caches should affect its HTTP assertions. const fixture = process.env.NODE_ENV === "test" ? process.env.PHREN_TEST_MODEL_CATALOG : undefined; if (!fixture) { await serve(version); break; } const readModels = async (source) => { const rows = JSON.parse(await readFile(fixture, "utf8")); return Array.isArray(rows[source]) ? rows[source] : []; }; const emptyUsage = (source) => ({ source, windows: [] }); const modelCatalog = new ModelCatalog(() => readModels("codex"), () => readModels("claude"), () => readModels("opencode"), 0, () => readModels("phren")); const accountUsage = new AccountUsageReader(async () => emptyUsage("codex"), Date.now, async () => ({ source: "claude", windows: [{ id: "fixture", name: "Fixture", usedPercent: 0 }] }), async () => emptyUsage("opencode"), async () => undefined, async () => emptyUsage("opencode-go"), async () => emptyUsage("copilot")); await serve(version, { modelCatalog, accountUsage }); break; } case "ssh": await dispatch(process.env.SSH_ORIGINAL_COMMAND || ""); break; // Run by <bridge>/enroll over a password SSH session, never by the forced command. case "enroll-device": return (await import("./device-enroll.js")).runEnrollDevice(args.slice(1), version); case "ssh-scoped": { const { scopedDispatch } = await import("./scoped-gateway.js"); await scopedDispatch(args[1] ?? "", process.env.SSH_ORIGINAL_COMMAND || "", { input: process.stdin, output: process.stdout, socket: socketPath() }); process.stdin.destroy(); break; } case "install": case "update": await install(version, args.includes("--no-service"), args.includes("--force")); break; case "uninstall": await uninstall(); break; case "rollback": await rollback(); break; case "status": console.log(JSON.stringify(await health(), null, 2)); break; case "doctor": { const helper = await health(), muxes = await servers(), terminal = await terminalHealth(); const push = approvalPushCheck(helper), speech = await speechKeyStatus(), voice = await resolveSpeechVoice().catch(() => undefined), model = await resolveSpeechModel(), region = await resolveSpeechRegion(); // Chat needs Herdr or tmux; a plain project shell or agent over SSH does not. console.log(JSON.stringify({ ok: true, helper, herdr: muxes, terminal, checks: { privateSocket: true, protocol: true, independentHelper: true, herdrRunning: terminal.servers.some(server => server.provider === "herdr"), terminal: "SSH PTY; authorize the Phren device key with pty", multiplexer: describeTerminal(terminal), shell: muxes.length > 0 ? "available" : "Neither Herdr nor tmux is available: chat is unavailable, project shells and agents still open over SSH", approvalPush: push.configured ? "configured" : "not configured", speechKey: speech.configured ? "configured" : "not configured", speechKeyDetail: speech.detail, ...(voice ? { speechVoice: `${voice.voice} (${voice.source})` } : {}), speechModel: `${model.model} (${model.source})`, speechRegion: `${region.region} (${region.source})`, }, ...(push.warning ? { warnings: [push.warning] } : {}) }, null, 2)); if (push.warning) console.error(`warning: ${push.warning}`); break; } default: throw new Error("Usage: phren bridge <install|status|doctor|usage|update|rollback|uninstall|enroll-computer|accounts|discover|link|fanouts archive|git-host|speech-key set|speech-voice|speech-model|speech-region>"); } return 0; } /** Doctor's push check, from the running Hook's own capability: a Hook that * loaded apns.json and its key, or one whose phones registered through the * push relay, offers `approvalPush`. */ export function approvalPushCheck(helper) { const capability = object(helper.capabilities).approvalPush; const configured = capability === "direct-apns" || capability === "relay"; return configured ? { configured } : { configured, warning: apnsSetupSteps() }; } const SPEECH_VOICE_USAGE = "Usage: phren bridge speech-voice [show | set <elevenlabs-voice-id> | clear]"; const SPEECH_MODEL_USAGE = "Usage: phren bridge speech-model [show | set <elevenlabs-model-id> | clear]"; const SPEECH_REGION_USAGE = "Usage: phren bridge speech-region [show | us | global]"; const GIT_HOST_USAGE = "Usage: phren bridge git-host [list | set <domain> gitlab|gitboy | remove <domain>] (set reads the token from stdin)"; const SPEECH_KEY_USAGE = "Usage: phren bridge speech-key set (paste the key when asked, or pipe it on stdin)"; /** One line from stdin: piped as is, or typed at a prompt without echo. */ async function readSecret(prompt) { const stdin = process.stdin; if (!stdin.isTTY) { let text = ""; for await (const chunk of stdin) text += chunk; return text; } process.stderr.write(prompt); stdin.setEncoding("utf8"); stdin.setRawMode(true); stdin.resume(); return new Promise((resolve, reject) => { let value = ""; const finish = () => { stdin.off("data", onData); stdin.setRawMode(false); stdin.pause(); process.stderr.write("\n"); }; const onData = (chunk) => { for (const char of chunk) { if (char === "\r" || char === "\n") { finish(); resolve(value); return; } if (char === "\u0003") { finish(); reject(new Error("Cancelled.")); return; } if (char === "\u007f" || char === "\b") value = value.slice(0, -1); else value += char; } }; stdin.on("data", onData); }); }