@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
321 lines (320 loc) • 17.2 kB
JavaScript
import { spawn } from "node:child_process";
import { readdir, readFile, stat } from "node:fs/promises";
import { homedir } from "node:os";
import { claudeConfigDir } from "../home-paths.js";
import path from "node:path";
import { BridgeError, object, objects } from "./protocol.js";
import { claudeHome, DEFAULT_ACCOUNT } from "./claude-accounts.js";
import { codexExecutable } from "./codex-binary.js";
/** Only initialize and list. Never a thread, a prompt, or a login. */
export function readCodexModels(executable = codexExecutable()) {
return new Promise(resolve => {
const child = spawn(executable, ["app-server"], { cwd: homedir(), stdio: ["pipe", "pipe", "ignore"] });
let pending = "", bytes = 0, initialized = false, done = false;
const finish = (result = []) => {
if (done)
return;
done = true;
clearTimeout(timer);
child.stdin.destroy();
child.stdout.destroy();
child.kill("SIGTERM");
const kill = setTimeout(() => { if (child.exitCode === null)
child.kill("SIGKILL"); }, 1000);
kill.unref();
child.once("exit", () => clearTimeout(kill));
resolve(result);
};
const timer = setTimeout(() => finish(), 12_000);
const send = (value) => { if (!done)
child.stdin.write(JSON.stringify(value) + "\n"); };
child.on("error", () => finish());
child.on("exit", () => finish());
child.stdin.on("error", () => finish());
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
bytes += Buffer.byteLength(chunk);
if (bytes > 1_048_576) {
finish();
return;
}
pending += chunk;
let newline;
while (!done && (newline = pending.indexOf("\n")) >= 0) {
const line = pending.slice(0, newline);
pending = pending.slice(newline + 1);
try {
const response = object(JSON.parse(line));
if (response.id === 0 && !initialized) {
if (response.error || !response.result) {
finish();
return;
}
initialized = true;
send({ method: "initialized", params: {} });
send({ id: 1, method: "model/list", params: {} });
}
else if (response.id === 1 && initialized) {
finish(response.error ? [] : codexModels(object(response.result)));
}
else if (response.method && response.id !== undefined) {
send({ id: response.id, error: { code: -32601, message: "Unsupported request" } });
}
}
catch {
finish();
}
}
});
send({ id: 0, method: "initialize", params: { clientInfo: { name: "phren_models", title: "Phren Models", version: "1" } } });
});
}
export function codexModels(result) {
return objects(result.data).filter(entry => entry.hidden !== true && typeof entry.id === "string" && entry.id).slice(0, 32).map(entry => ({
id: String(entry.id).slice(0, 100),
name: String(entry.displayName || entry.id).slice(0, 100),
...(typeof entry.description === "string" && entry.description ? { description: entry.description.slice(0, 300) } : {}),
...(entry.isDefault === true ? { isDefault: true } : {}),
...(typeof entry.defaultReasoningEffort === "string" ? { defaultReasoningEffort: entry.defaultReasoningEffort } : {}),
supportedReasoningEfforts: objects(entry.supportedReasoningEfforts).flatMap(value => typeof value.reasoningEffort === "string" ? [value.reasoningEffort] : []),
}));
}
/** The menu used only when Claude Code's own catalogue cannot be read (it has
* never run on this computer, or the cache is unreadable). Normally the phone
* shows the live catalogue below, so a new model appears without anyone
* editing this table. Exported so a parity test can hold the phone's built-in
* offline fallback and the chat fixture to it. */
export const CLAUDE_MENU = [
{ id: "claude-fable-5-1", name: "Fable 5.1", description: "Most intelligent.", isDefault: true },
{ id: "claude-opus-5", name: "Opus 5", description: "Most capable for long work." },
{ id: "claude-sonnet-5", name: "Sonnet 5", description: "Fast and capable." },
{ id: "claude-haiku-4-5-20251001", name: "Haiku 4.5", description: "Fastest and lightest." },
{ id: "claude-fable-5-1[1m]", name: "Fable 5.1 (1M context)", description: "Fable 5.1 with a 1M context window." },
];
/** Claude Code fetches its `/model` catalogue from Anthropic and caches it as
* `<config>/cache/model-catalog/<account>-...-cc.json`, refreshing it itself:
* `catalog.config.models` is the menu in the terminal's order (`section`
* "main" first, older models under "overflow"), each row gated by
* `min_claude_code_version`, and `catalog.state.model` is the selected
* default. Reading that file keeps the phone's picker identical to the
* terminal's with nothing to maintain here. */
export async function readClaudeCatalog(configDir = claudeConfigDir(), installed) {
const directory = path.join(configDir, "cache", "model-catalog");
let newest;
try {
for (const name of await readdir(directory)) {
if (!name.endsWith("-cc.json"))
continue;
const file = path.join(directory, name), at = (await stat(file)).mtimeMs;
if (!newest || at > newest.at)
newest = { file, at };
}
}
catch {
return [];
}
if (!newest)
return [];
let parsed;
try {
parsed = JSON.parse(await readFile(newest.file, "utf8"));
}
catch {
return [];
}
const catalog = object(object(parsed).catalog);
const selected = typeof object(catalog.state).model === "string" ? String(object(catalog.state).model) : undefined;
const rows = objects(object(catalog.config).models).filter(row => typeof row.id === "string" && /^[a-z0-9.[\]-]{1,100}$/i.test(row.id));
const usable = rows.filter(row => !installed || typeof row.min_claude_code_version !== "string" || !olderVersion(installed, row.min_claude_code_version));
const ordered = [...usable.filter(row => row.section !== "overflow"), ...usable.filter(row => row.section === "overflow")];
const models = ordered.map(row => ({
id: String(row.id),
name: typeof row.name === "string" && row.name.trim() ? row.name.trim().slice(0, 100) : claudeName(String(row.id)),
...(typeof row.description === "string" && row.description.trim() ? { description: row.description.trim().slice(0, 300) } : {}),
...(String(row.id) === selected ? { isDefault: true } : {}),
...claudeEfforts(row),
}));
// The terminal offers the selected model with a 1M context window as its
// own row; the catalogue does not list that variant, so add it after the
// main section the way the menu draws it.
const chosen = models.find(model => model.isDefault);
if (chosen && !models.some(model => model.id === `${chosen.id}[1m]`)) {
const main = models.filter(model => ordered.find(row => row.id === model.id)?.section !== "overflow").length;
models.splice(main, 0, { id: `${chosen.id}[1m]`, name: `${chosen.name} (1M context)`, description: `${chosen.name} with a 1M context window.`,
...(chosen.supportedReasoningEfforts ? { supportedReasoningEfforts: chosen.supportedReasoningEfforts } : {}),
...(chosen.defaultReasoningEffort ? { defaultReasoningEffort: chosen.defaultReasoningEffort } : {}) });
}
return models;
}
/** A catalogue row's `thinking.effort_options`, the levels `--effort` takes for that model. */
function claudeEfforts(row) {
const options = objects(object(row.thinking).effort_options).filter(option => typeof option.id === "string" && /^[a-z]{1,16}$/.test(option.id));
if (!options.length)
return {};
const fallback = options.find(option => object(option.badge).message === "Default");
return { supportedReasoningEfforts: options.map(option => String(option.id)),
...(fallback ? { defaultReasoningEffort: String(fallback.id) } : {}) };
}
/** "2.1.279" is older than "2.1.280"; anything unparseable is not. */
function olderVersion(installed, required) {
const parse = (value) => /^(\d+)\.(\d+)\.(\d+)/.exec(value.trim())?.slice(1).map(Number);
const have = parse(installed), need = parse(required);
if (!have || !need)
return false;
for (let index = 0; index < 3; index++)
if (have[index] !== need[index])
return have[index] < need[index];
return false;
}
/** The installed client's version, so a model it cannot run is not offered. */
function claudeVersion(executable = "claude") {
return new Promise(resolve => {
const child = spawn(executable, ["--version"], { cwd: homedir(), stdio: ["ignore", "pipe", "ignore"] });
let out = "";
// A version probe has no work to save. Terminate it even if a wrapper
// ignores SIGTERM, so timed-out catalogue refreshes cannot leak children.
const timer = setTimeout(() => { child.kill("SIGKILL"); resolve(undefined); }, 5_000);
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk) => { if (out.length < 4_096)
out += chunk; });
child.on("error", () => { clearTimeout(timer); resolve(undefined); });
child.on("close", () => { clearTimeout(timer); resolve(/\d+\.\d+\.\d+/.exec(out)?.[0]); });
});
}
/** The live catalogue when Claude Code has one cached on this computer,
* otherwise the offline fallback, copied per call so a caller cannot mutate it. */
export async function readClaudeModels(configDir, installed) {
const version = installed === null ? undefined : installed ?? await claudeVersion();
const live = await readClaudeCatalog(configDir, version).catch(() => []);
return live.length ? live : CLAUDE_MENU.map(model => ({ ...model }));
}
/** `opencode models` prints one `provider/model` id per line. The Go plan's
* models sit under `opencode-go/`, Zen's under `opencode/`, and everything
* else under the gateway it came from; the configured default is marked. */
export async function readOpenCodeModels(executable = "opencode", configDir = path.join(homedir(), ".config/opencode")) {
const listed = await new Promise(resolve => {
const child = spawn(executable, ["models"], { cwd: homedir(), stdio: ["ignore", "pipe", "ignore"] });
let out = "", done = false;
const finish = () => { if (!done) {
done = true;
clearTimeout(timer);
resolve(out);
} };
const timer = setTimeout(() => { child.kill("SIGTERM"); finish(); }, 12_000);
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk) => { if (out.length < 262_144)
out += chunk; });
// "close", not "exit": the process can exit before its stdout is read.
child.on("error", finish);
child.on("close", finish);
});
const ids = [...new Set(listed.split(/\r?\n/).map(line => line.trim()).filter(line => /^[a-z0-9~.-]+\/[^\s]+$/i.test(line)))].slice(0, 400);
if (!ids.length)
return [];
let configured;
for (const file of ["opencode.json", "opencode.jsonc"]) {
try {
const text = await readFile(path.join(configDir, file), "utf8");
const model = /"model"\s*:\s*"([^"]+)"/.exec(text)?.[1];
if (model) {
configured = model.slice(0, 200);
break;
}
}
catch { /* No config means no default to mark. */ }
}
const rank = (id) => id.startsWith("opencode-go/") ? 0 : id.startsWith("opencode/") ? 1 : 2;
return ids.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b)).map(id => {
const provider = id.slice(0, id.indexOf("/")), name = id.slice(id.indexOf("/") + 1);
const description = provider === "opencode-go" ? "OpenCode Go plan." : provider === "opencode" ? (name.endsWith("-free") ? "OpenCode Zen, free." : "OpenCode Zen, per token.") : `Through ${provider}.`;
return { id, name, description, ...(id === configured ? { isDefault: true } : {}) };
});
}
/** phren agent's catalog (`phren-agent models --json`): only providers with
* credentials on this computer, ids as `<provider>/<model>`, the
* auto-detected default marked. The agent package next to this CLI (or this
* checkout's) runs under this Node; otherwise `phren-agent` on PATH. Never the
* npm global lookup, which blocks. */
export async function readPhrenModels(command) {
const found = command ? undefined : (await import("../modules/agent-package.js")).findAgentPackage({ global: false });
const run = command ?? (found ? { file: process.execPath, args: [found.bin, "models", "--json"] } : { file: "phren-agent", args: ["models", "--json"] });
const listed = await new Promise(resolve => {
const child = spawn(run.file, run.args, { cwd: homedir(), stdio: ["ignore", "pipe", "ignore"] });
let out = "", done = false;
const finish = () => { if (!done) {
done = true;
clearTimeout(timer);
resolve(out);
} };
const timer = setTimeout(() => { child.kill("SIGTERM"); finish(); }, 12_000);
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk) => { if (out.length < 262_144)
out += chunk; });
child.on("error", finish);
child.on("close", finish);
});
let rows;
try {
rows = objects(object(JSON.parse(listed)).models);
}
catch {
return [];
}
const text = (value, max) => typeof value === "string" && value.trim() && !/[\x00-\x1f\x7f]/.test(value) ? value.trim().slice(0, max) : undefined;
const models = [];
for (const row of rows.slice(0, 200)) {
const id = text(row.id, 200), name = text(row.name, 120);
if (!id || !name || !/^[a-z0-9-]+\/\S+$/.test(id))
continue;
const description = text(row.description, 120), effort = text(row.defaultReasoningEffort, 20);
const efforts = Array.isArray(row.supportedReasoningEfforts) ? row.supportedReasoningEfforts.map(value => text(value, 20)).filter((value) => !!value) : [];
models.push({ id, name, ...(description ? { description } : {}), ...(row.isDefault === true ? { isDefault: true } : {}),
...(effort ? { defaultReasoningEffort: effort } : {}), ...(efforts.length ? { supportedReasoningEfforts: efforts } : {}) });
}
return models;
}
/** "claude-fable-5-1" reads as "Fable 5.1"; a date suffix is dropped. */
export function claudeName(id) {
const parts = id.replace(/^claude-/, "").replace(/-\d{8}$/, "").replace(/\[1m\]$/, "").split("-");
const family = parts.shift() ?? id;
const version = parts.filter(part => /^\d+$/.test(part)).join(".");
const name = family.charAt(0).toUpperCase() + family.slice(1) + (version ? ` ${version}` : "");
return id.endsWith("[1m]") ? `${name} (1M context)` : name;
}
/** The account a request names is not one on this computer (or has no catalogue for that source). */
export const accountUnavailable = (account) => new BridgeError(404, `"${account}" is not an account on this computer.`, { code: "account_unavailable" });
/** Catalogues change rarely and app-server takes seconds to start. Cached per
* `${source}:${account}`; only Claude has accounts, each reading its own home. */
export class ModelCatalog {
codex;
claude;
opencode;
cacheMs;
phren;
cache = new Map();
constructor(codex = () => readCodexModels(), claude = dir => readClaudeModels(dir), opencode = () => readOpenCodeModels(), cacheMs = 600_000, phren = () => readPhrenModels()) {
this.codex = codex;
this.claude = claude;
this.opencode = opencode;
this.cacheMs = cacheMs;
this.phren = phren;
}
list(source, account) {
const id = account?.trim() || DEFAULT_ACCOUNT;
let dir;
if (source === "claude") {
const home = claudeHome(id);
if (!home)
return Promise.reject(accountUnavailable(id));
dir = home.isDefault ? undefined : home.dir;
}
else if (id !== DEFAULT_ACCOUNT)
return Promise.reject(accountUnavailable(id));
const key = `${source}:${id}`, cached = this.cache.get(key);
if (cached && Date.now() - cached.at < this.cacheMs)
return cached.value;
const value = (source === "codex" ? this.codex() : source === "claude" ? this.claude(dir) : source === "opencode" ? this.opencode() : source === "phren" ? this.phren() : Promise.resolve([])).catch(() => []);
this.cache.set(key, { at: Date.now(), value });
return value;
}
}