UNPKG

@phren/cli

Version:

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

223 lines (222 loc) • 10.9 kB
// Claude accounts on this computer: one Claude config home per account. // ~/.claude is the "default" account and is launched without CLAUDE_CONFIG_DIR, // so its keychain item and ~/.claude.json stay where Claude Code put them. // Every ~/.claude-<slug> holding a .claude.json is another account. See docs/accounts.md. import { createHash } from "node:crypto"; import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { mkdir, readFile } from "node:fs/promises"; import { spawn } from "node:child_process"; import path from "node:path"; import { dump, load } from "js-yaml"; import { claudeConfigDir, homeDir } from "../home-paths.js"; import { atomic, bridgeRoot } from "./protocol.js"; import { claudePlan, monthlyAnniversary, subscriptionDate } from "./subscription.js"; export const DEFAULT_ACCOUNT = "default"; const SLUG = /^[a-z0-9][a-z0-9-]{0,31}$/; export function isAccountSlug(value) { return value === DEFAULT_ACCOUNT || SLUG.test(value); } function defaultHome(env) { const configured = Boolean(env.CLAUDE_CONFIG_DIR?.trim()); const dir = claudeConfigDir(env); // Claude keeps .claude.json inside CLAUDE_CONFIG_DIR when set, else beside ~/.claude. return { id: DEFAULT_ACCOUNT, dir, configFile: path.join(configured ? dir : homeDir(env), ".claude.json"), isDefault: true }; } /** Every Claude home on this computer, default first, then by slug. */ export function claudeHomes(env = process.env) { const homes = [defaultHome(env)]; const home = homeDir(env); let names = []; try { names = readdirSync(home); } catch { return homes; } for (const name of names.sort()) { if (!name.startsWith(".claude-")) continue; const id = name.slice(".claude-".length); if (!SLUG.test(id) || id === DEFAULT_ACCOUNT) continue; const dir = path.join(home, name); if (dir === homes[0].dir) continue; try { if (!statSync(dir).isDirectory() || !existsSync(path.join(dir, ".claude.json"))) continue; } catch { continue; } homes.push({ id, dir, configFile: path.join(dir, ".claude.json"), isDefault: false }); } return homes; } export function claudeHome(id, env = process.env) { const wanted = id?.trim() || DEFAULT_ACCOUNT; return claudeHomes(env).find(home => home.id === wanted); } /** The home a transcript or other file lives in, by longest matching directory. */ export function claudeHomeOfPath(file, env = process.env) { const resolved = path.resolve(file); return claudeHomes(env) .filter(home => resolved.startsWith(home.dir + path.sep)) .sort((a, b) => b.dir.length - a.dir.length)[0]; } /** Environment a launch into this home needs; empty for the default home. */ export function claudeLaunchEnv(home) { return home.isDefault ? {} : { CLAUDE_CONFIG_DIR: home.dir }; } /** The config home a running Claude process uses, from its CLAUDE_CONFIG_DIR. */ export function claudeHomeOfEnv(value, env = process.env) { if (!value?.trim()) return claudeHome(DEFAULT_ACCOUNT, env); const dir = path.resolve(value.trim()); return claudeHomes(env).find(home => home.dir === dir); } // ── labels (<bridge>/accounts.yaml) ───────────────────────────────────────── export const accountsFile = () => path.join(bridgeRoot(), "accounts.yaml"); function readLabels() { try { const parsed = load(readFileSync(accountsFile(), "utf8")); const claude = parsed && typeof parsed === "object" ? parsed.claude : undefined; if (!claude || typeof claude !== "object") return {}; return Object.fromEntries(Object.entries(claude) .filter(([id, label]) => isAccountSlug(id) && typeof label === "string" && label.trim().length > 0 && label.length <= 40) .map(([id, label]) => [id, label.trim()])); } catch { return {}; } } export function accountLabel(id, labels = readLabels()) { if (labels[id]) return labels[id]; if (id === DEFAULT_ACCOUNT) return "Claude"; return id.split("-").filter(Boolean).map(part => part.charAt(0).toUpperCase() + part.slice(1)).join(" "); } export async function setAccountLabel(id, label) { if (!isAccountSlug(id)) throw new Error(`"${id}" is not an account name: use lowercase letters, digits and dashes.`); const clean = label.trim(); if (!clean || clean.length > 40 || /[\u0000-\u001f]/.test(clean)) throw new Error("A label is 1 to 40 printable characters."); let parsed = {}; try { const raw = load(await readFile(accountsFile(), "utf8")); if (raw && typeof raw === "object") parsed = raw; } catch { /* First label. */ } const claude = parsed.claude && typeof parsed.claude === "object" ? parsed.claude : {}; parsed.claude = { ...claude, [id]: clean }; await mkdir(bridgeRoot(), { recursive: true, mode: 0o700 }); await atomic(accountsFile(), dump(parsed), 0o600); } const identityCache = new Map(); function oauthIdentity(configFile) { try { const stat = statSync(configFile); if (!stat.isFile() || stat.size > 16_777_216) return {}; const cached = identityCache.get(configFile); if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) return cached; const config = JSON.parse(readFileSync(configFile, "utf8")); const account = config.oauthAccount && typeof config.oauthAccount === "object" ? config.oauthAccount : {}; const id = (value) => typeof value === "string" && /^[A-Za-z0-9-]{1,64}$/.test(value) ? value : undefined; const email = typeof account.emailAddress === "string" && /^[^\s@]{1,64}@[^\s@]{1,190}$/.test(account.emailAddress) ? account.emailAddress : undefined; const fetched = typeof account.profileFetchedAt === "number" ? subscriptionDate(Math.floor(account.profileFetchedAt / 1000)) : undefined; const value = { mtimeMs: stat.mtimeMs, size: stat.size, uuid: id(account.accountUuid), email, organization: id(account.organizationUuid), plan: claudePlan(account), startedAt: subscriptionDate(account.subscriptionCreatedAt), checkedAt: fetched }; identityCache.set(configFile, value); return value; } catch { return {}; } } /** * Stable across computers for one subscription; home-scoped when unknown. * One login can belong to several organizations (a personal plan and a Team * seat), each its own allowance, so the organization is part of the key. */ export function claudeAccountKey(home) { const { uuid, email, organization } = oauthIdentity(home.configFile); const login = uuid ?? email; if (!login) return `claude:home:${home.id}`; return "claude:" + createHash("sha256").update(organization ? `${login}|${organization}` : login).digest("hex").slice(0, 12); } /** The login's email, so the phone can name a card merged across computers. Usage rows only. */ export function claudeAccountEmail(home) { return oauthIdentity(home.configFile).email; } /** A login is recorded but its profile names no plan Phren knows. */ export function claudePlanUnknown(home) { const identity = oauthIdentity(home.configFile); return Boolean(identity.uuid ?? identity.email) && !identity.plan; } /** * The account home's own subscription, independent of the quota snapshot's * age. `checkedAt` is when Claude Code last fetched the profile, so the * newest profile wins when computers disagree about a plan that changed. */ export function claudeAccountSubscription(home, now = new Date(), fallbackPlan) { const { plan: profilePlan, startedAt, checkedAt } = oauthIdentity(home.configFile); const plan = profilePlan ?? fallbackPlan; if (!plan) return undefined; const renewsAt = startedAt && profilePlan ? monthlyAnniversary(startedAt, now) : undefined; return { plan, ...(startedAt && profilePlan ? { startedAt } : {}), ...(renewsAt ? { renewsAt, renewsEstimated: true } : {}), checkedAt: checkedAt ?? now.toISOString() }; } export function claudeAccountRef(home, labels = readLabels()) { return { id: home.id, label: accountLabel(home.id, labels), key: claudeAccountKey(home) }; } export const CODEX_ACCOUNT = { id: DEFAULT_ACCOUNT, label: "Codex", key: "codex" }; /** Only for tests. */ export function clearAccountCaches() { identityCache.clear(); authCache.clear(); } const AUTH_CACHE_MS = 5 * 60_000; const AUTH_TIMEOUT_MS = 10_000; const SIGNED_OUT_CACHE_MS = 30_000; const authCache = new Map(); /** Parse `claude auth status --json`. Never keeps anything but the state and plan. */ export function parseAuthStatus(output) { try { const value = JSON.parse(output); if (value.loggedIn !== true) return { signedIn: false, reason: "Not signed in" }; const plan = typeof value.subscriptionType === "string" && /^[a-z0-9_-]{1,32}$/i.test(value.subscriptionType) ? value.subscriptionType : undefined; return { signedIn: true, ...(plan ? { plan } : {}), ...(value.authMethod === "console" ? { console: true } : {}) }; } catch { return { signedIn: false, unknown: true, reason: "Claude did not report its sign-in state" }; } } const runAuthStatus = home => new Promise(resolve => { let out = "", done = false; const child = spawn("claude", ["auth", "status", "--json"], { cwd: homeDir(), stdio: ["ignore", "pipe", "ignore"], env: { ...process.env, ...claudeLaunchEnv(home), NO_COLOR: "1" }, }); const finish = () => { if (!done) { done = true; clearTimeout(timer); resolve(out); } }; const timer = setTimeout(() => { child.kill("SIGKILL"); finish(); }, AUTH_TIMEOUT_MS); child.stdout.on("data", (chunk) => { if (out.length < 16_384) out += chunk.toString(); }); child.on("error", finish); child.on("close", finish); }); export function claudeAuthStatus(home, run = runAuthStatus, now = Date.now()) { const cached = authCache.get(home.dir); if (cached && now - cached.at < (cached.signedIn === false ? SIGNED_OUT_CACHE_MS : AUTH_CACHE_MS)) return cached.value; const entry = { at: now, value: Promise.resolve({ signedIn: false }) }; // A signed-out answer is kept briefly so a fresh /login shows up within half a minute. entry.value = run(home).then(parseAuthStatus, () => ({ signedIn: false, unknown: true, reason: "Claude did not report its sign-in state" })) .then(status => { entry.signedIn = status.unknown ? false : status.signedIn; return status; }); authCache.set(home.dir, entry); return entry.value; }