UNPKG

@phren/cli

Version:

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

208 lines (207 loc) • 10.2 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 { monthlyAnniversary, planName, subscriptionDate } from "./subscription.js"; export const DEFAULT_ACCOUNT = "default"; const SLUG = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/; 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); } // ── identity (.claude.json oauthAccount, no tokens) ───────────────────────── 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 uuid = typeof account.accountUuid === "string" && account.accountUuid ? account.accountUuid : undefined; const email = typeof account.emailAddress === "string" && /^[^\s@]{1,64}@[^\s@]{1,190}$/.test(account.emailAddress) ? account.emailAddress : undefined; const tier = planName(account.organizationRateLimitTier); const billing = typeof account.billingType === "string" && /^(?:claude_)?(?:pro|max|team|enterprise)(?:_subscription)?$/.test(account.billingType) ? planName(account.billingType.replace(/_subscription$/, "")) : undefined; const value = { mtimeMs: stat.mtimeMs, size: stat.size, uuid, email, plan: tier ?? billing, startedAt: subscriptionDate(account.subscriptionCreatedAt) }; identityCache.set(configFile, value); return value; } catch { return {}; } } /** Stable across computers for one subscription; home-scoped when unknown. */ export function claudeAccountKey(home) { const { uuid } = oauthIdentity(home.configFile); return uuid ? "claude:" + createHash("sha256").update(uuid).digest("hex").slice(0, 12) : `claude:home:${home.id}`; } /** 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; } /** The account home's own subscription, independent of the quota snapshot's age. */ export function claudeAccountSubscription(home, now = new Date()) { const { plan, startedAt } = oauthIdentity(home.configFile); if (!plan) return undefined; const renewsAt = startedAt ? monthlyAnniversary(startedAt, now) : undefined; return { plan, ...(startedAt ? { startedAt } : {}), ...(renewsAt ? { renewsAt, renewsEstimated: true } : {}), 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 } : {}) }; } 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; }