Last active
March 30, 2026 12:14
-
-
Save hangox/09cdf644683f7301973d4b48b63a329d to your computer and use it in GitHub Desktop.
Claude Code 自定义状态栏脚本 - 显示项目/Git/模型/费用/配额信息
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bun | |
| /** | |
| * Claude Code 状态行脚本 | |
| * 基于 robbyrussell 主题风格,使用 Bun TypeScript 重写 | |
| * 用法: bun /Users/hangox/.claude/statusline.ts | |
| */ | |
| import { execSync, spawnSync } from "child_process"; | |
| import { existsSync, readFileSync, writeFileSync, statSync } from "fs"; | |
| // ─── 类型定义 ─────────────────────────────────────────────────────────────── | |
| interface StdinData { | |
| cwd?: string; | |
| transcript_path?: string; | |
| model?: { id?: string; display_name?: string }; | |
| context_window?: { | |
| context_window_size?: number; | |
| used_percentage?: number | null; | |
| remaining_percentage?: number | null; | |
| current_usage?: { | |
| input_tokens?: number; | |
| output_tokens?: number; | |
| cache_creation_input_tokens?: number; | |
| cache_read_input_tokens?: number; | |
| } | null; | |
| }; | |
| } | |
| interface OAuthUsage { | |
| five_hour?: { utilization?: number; resets_at?: string }; | |
| seven_day?: { utilization?: number; resets_at?: string }; | |
| } | |
| // ─── ANSI 颜色 ────────────────────────────────────────────────────────────── | |
| const c = { | |
| reset: "\x1b[0m", | |
| green: "\x1b[1;32m", | |
| cyan: "\x1b[36m", | |
| blue: "\x1b[1;34m", | |
| red: "\x1b[31m", | |
| yellow: "\x1b[33m", | |
| gray: "\x1b[90m", | |
| morandiPurple: "\x1b[38;2;155;144;168m", | |
| morandiBlue: "\x1b[38;2;138;150;166m", | |
| }; | |
| // ─── 缓存工具 ──────────────────────────────────────────────────────────────── | |
| function readCache(file: string, maxAge: number): string | null { | |
| try { | |
| if (!existsSync(file)) return null; | |
| const age = (Date.now() - statSync(file).mtimeMs) / 1000; | |
| if (age >= maxAge) return null; | |
| return readFileSync(file, "utf8").trim() || null; | |
| } catch { | |
| return null; | |
| } | |
| } | |
| function writeCache(file: string, content: string): void { | |
| try { | |
| writeFileSync(file, content, "utf8"); | |
| } catch {} | |
| } | |
| // ─── 命令执行(带超时)────────────────────────────────────────────────────── | |
| function runCmd(cmd: string, args: string[], timeoutMs = 5000): string | null { | |
| const result = spawnSync(cmd, args, { | |
| encoding: "utf8", | |
| timeout: timeoutMs, | |
| stdio: ["ignore", "pipe", "ignore"], | |
| }); | |
| if (result.status !== 0 || result.error) return null; | |
| return result.stdout?.trim() || null; | |
| } | |
| // ─── Stdin 读取 ─────────────────────────────────────────────────────────────── | |
| async function readStdin(): Promise<StdinData> { | |
| const chunks: Buffer[] = []; | |
| for await (const chunk of process.stdin) { | |
| chunks.push(chunk as Buffer); | |
| } | |
| const raw = Buffer.concat(chunks).toString("utf8").trim(); | |
| if (!raw) return {}; | |
| try { | |
| return JSON.parse(raw); | |
| } catch { | |
| return {}; | |
| } | |
| } | |
| // ─── 从 transcript 读取实际使用的模型 ────────────────────────────────────── | |
| const MODEL_DISPLAY: Record<string, string> = { | |
| "claude-opus-4-6": "Opus 4.6", | |
| "claude-opus-4-5": "Opus 4.5", | |
| "claude-sonnet-4-6": "Sonnet 4.6", | |
| "claude-sonnet-4-5": "Sonnet 4.5", | |
| "claude-haiku-4-5": "Haiku 4.5", | |
| "claude-haiku-4-5-20251001": "Haiku 4.5", | |
| }; | |
| function modelDisplayName(id: string): string { | |
| return MODEL_DISPLAY[id] ?? id.replace(/^claude-/, ""); | |
| } | |
| function getActualModel(transcriptPath: string | undefined, fallback: string): string { | |
| if (!transcriptPath || !existsSync(transcriptPath)) return fallback; | |
| try { | |
| // 只读末尾 8KB,避免大文件性能问题 | |
| const { size } = statSync(transcriptPath); | |
| const offset = Math.max(0, size - 8192); | |
| const fd = require("fs").openSync(transcriptPath, "r"); | |
| const buf = Buffer.alloc(Math.min(8192, size)); | |
| require("fs").readSync(fd, buf, 0, buf.length, offset); | |
| require("fs").closeSync(fd); | |
| // 从末尾往前找最近一条包含 model 的 assistant 消息 | |
| const lines = buf.toString("utf8").split("\n").reverse(); | |
| for (const line of lines) { | |
| try { | |
| const obj = JSON.parse(line); | |
| if (obj?.type === "assistant") { | |
| const modelId: string | undefined = obj?.message?.model; | |
| if (modelId) return modelDisplayName(modelId); | |
| } | |
| } catch {} | |
| } | |
| } catch {} | |
| return fallback; | |
| } | |
| // ─── Context Window 百分比 ────────────────────────────────────────────────── | |
| function getContextPercent(data: StdinData): number | null { | |
| const cw = data.context_window; | |
| if (!cw) return null; | |
| // 优先使用 Claude Code 直接提供的百分比 | |
| if (cw.used_percentage != null) return Math.round(cw.used_percentage); | |
| // 备选:从 token 数计算 | |
| const size = cw.context_window_size; | |
| const usage = cw.current_usage; | |
| if (!size || !usage) return null; | |
| const total = | |
| (usage.input_tokens ?? 0) + | |
| (usage.output_tokens ?? 0) + | |
| (usage.cache_creation_input_tokens ?? 0) + | |
| (usage.cache_read_input_tokens ?? 0); | |
| return Math.round((total / size) * 100); | |
| } | |
| // ─── ccusage 数据 ───────────────────────────────────────────────────────────── | |
| const CACHE_TODAY = "/tmp/cl_status_today.txt"; | |
| const CACHE_WEEKLY = "/tmp/cl_status_weekly.txt"; | |
| const CACHE_MONTHLY = "/tmp/cl_status_monthly.txt"; | |
| const CACHE_OAUTH = "/tmp/cl_status_oauth.txt"; | |
| const CACHE_OAUTH_FAIL = "/tmp/cl_status_oauth_fail.txt"; | |
| function getDailyData(currentDir: string): { projectCost: number | null; todayCost: number | null } { | |
| const cached = readCache(CACHE_TODAY, 60); | |
| if (cached) { | |
| const [p, t] = cached.split("|"); | |
| return { | |
| projectCost: p && p !== "null" ? parseFloat(p) : null, | |
| todayCost: t && t !== "null" ? parseFloat(t) : null, | |
| }; | |
| } | |
| const today = new Date().toISOString().slice(0, 10).replace(/-/g, ""); | |
| const raw = runCmd("ccusage", ["daily", "--json", "--since", today, "-i"]); | |
| if (!raw) return { projectCost: null, todayCost: null }; | |
| try { | |
| const data = JSON.parse(raw); | |
| const todayCost: number | null = data?.totals?.totalCost ?? null; | |
| const projectName = currentDir.replace(/[/.]/g, "-"); | |
| const projectCost: number | null = data?.projects?.[projectName]?.[0]?.totalCost ?? null; | |
| writeCache(CACHE_TODAY, `${projectCost ?? "null"}|${todayCost ?? "null"}`); | |
| return { projectCost, todayCost }; | |
| } catch { | |
| return { projectCost: null, todayCost: null }; | |
| } | |
| } | |
| function getWeeklyCost(): number | null { | |
| const cached = readCache(CACHE_WEEKLY, 300); | |
| if (cached) return parseFloat(cached); | |
| const raw = runCmd("ccusage", ["weekly", "--json"]); | |
| if (!raw) return null; | |
| try { | |
| const data = JSON.parse(raw); | |
| const cost: number | null = data?.weekly?.at(-1)?.totalCost ?? null; | |
| if (cost !== null) writeCache(CACHE_WEEKLY, String(cost)); | |
| return cost; | |
| } catch { | |
| return null; | |
| } | |
| } | |
| function getMonthlyCost(): number | null { | |
| const cached = readCache(CACHE_MONTHLY, 600); | |
| if (cached) return parseFloat(cached); | |
| const raw = runCmd("ccusage", ["monthly", "--json"]); | |
| if (!raw) return null; | |
| try { | |
| const data = JSON.parse(raw); | |
| const cost: number | null = data?.monthly?.at(-1)?.totalCost ?? null; | |
| if (cost !== null) writeCache(CACHE_MONTHLY, String(cost)); | |
| return cost; | |
| } catch { | |
| return null; | |
| } | |
| } | |
| // ─── OAuth 配额(仅订阅模式)──────────────────────────────────────────────── | |
| function getOAuthToken(): string | null { | |
| // 优先从 macOS Keychain 读取 | |
| try { | |
| const raw = execSync( | |
| 'security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null', | |
| { encoding: "utf8", timeout: 3000 } | |
| ).trim(); | |
| const creds = JSON.parse(raw); | |
| return creds?.claudeAiOauth?.accessToken ?? null; | |
| } catch {} | |
| // 备选:读取凭据文件 | |
| try { | |
| const credFile = `${process.env.HOME}/.claude/.credentials.json`; | |
| if (existsSync(credFile)) { | |
| const creds = JSON.parse(readFileSync(credFile, "utf8")); | |
| return creds?.claudeAiOauth?.accessToken ?? null; | |
| } | |
| } catch {} | |
| return null; | |
| } | |
| async function getOAuthQuota(): Promise<OAuthUsage | null> { | |
| // 成功缓存有效期 6 分钟 | |
| const cached = readCache(CACHE_OAUTH, 360); | |
| if (cached) { | |
| try { | |
| return JSON.parse(cached) as OAuthUsage; | |
| } catch {} | |
| } | |
| // 失败后 6 分钟内不重试,用旧缓存降级 | |
| const recentFail = readCache(CACHE_OAUTH_FAIL, 360); | |
| if (recentFail) { | |
| const stale = readCache(CACHE_OAUTH, 86400); | |
| if (stale) { | |
| try { return JSON.parse(stale) as OAuthUsage; } catch {} | |
| } | |
| return null; | |
| } | |
| const token = getOAuthToken(); | |
| if (!token) return null; | |
| try { | |
| // Haiku probe: 发送 max_tokens=1 的最小请求,从响应 headers 提取 rate limit 数据 | |
| const controller = new AbortController(); | |
| const tid = setTimeout(() => controller.abort(), 8000); | |
| const res = await fetch("https://api.anthropic.com/v1/messages", { | |
| method: "POST", | |
| headers: { | |
| Authorization: `Bearer ${token}`, | |
| "Content-Type": "application/json", | |
| "anthropic-beta": "oauth-2025-04-20", | |
| "anthropic-version": "2023-06-01", | |
| }, | |
| body: JSON.stringify({ | |
| model: "claude-haiku-4-5-20251001", | |
| max_tokens: 1, | |
| messages: [{ role: "user", content: "h" }], | |
| }), | |
| signal: controller.signal, | |
| }); | |
| clearTimeout(tid); | |
| // 从响应 headers 提取 rate limit(即使 API 返回错误也可能有 headers) | |
| const h5Util = res.headers.get("anthropic-ratelimit-unified-5h-utilization"); | |
| const h5Reset = res.headers.get("anthropic-ratelimit-unified-5h-reset"); | |
| const h7Util = res.headers.get("anthropic-ratelimit-unified-7d-utilization"); | |
| const h7Reset = res.headers.get("anthropic-ratelimit-unified-7d-reset"); | |
| if (!h5Util && !h7Util) { | |
| writeCache(CACHE_OAUTH_FAIL, String(Date.now())); | |
| const stale = readCache(CACHE_OAUTH, 86400); | |
| if (stale) { | |
| try { return JSON.parse(stale) as OAuthUsage; } catch {} | |
| } | |
| return null; | |
| } | |
| // 将 utilization 从 0.0-1.0 转换为百分比整数 | |
| const data: OAuthUsage = {}; | |
| if (h5Util) { | |
| data.five_hour = { | |
| utilization: Math.round(parseFloat(h5Util) * 100), | |
| resets_at: h5Reset ? new Date(parseInt(h5Reset) * 1000).toISOString() : undefined, | |
| }; | |
| } | |
| if (h7Util) { | |
| data.seven_day = { | |
| utilization: Math.round(parseFloat(h7Util) * 100), | |
| resets_at: h7Reset ? new Date(parseInt(h7Reset) * 1000).toISOString() : undefined, | |
| }; | |
| } | |
| writeCache(CACHE_OAUTH, JSON.stringify(data)); | |
| return data; | |
| } catch { | |
| writeCache(CACHE_OAUTH_FAIL, String(Date.now())); | |
| const stale = readCache(CACHE_OAUTH, 86400); | |
| if (stale) { | |
| try { return JSON.parse(stale) as OAuthUsage; } catch {} | |
| } | |
| return null; | |
| } | |
| } | |
| // ─── Git 信息 ──────────────────────────────────────────────────────────────── | |
| function getGitInfo(dir: string): string { | |
| const check = spawnSync("git", ["--no-optional-locks", "-C", dir, "rev-parse", "--git-dir"], { | |
| stdio: "ignore", | |
| }); | |
| if (check.status !== 0) return ""; | |
| const branch = | |
| runCmd("git", ["--no-optional-locks", "-C", dir, "branch", "--show-current"]) || "HEAD"; | |
| const hasDiff = | |
| spawnSync("git", ["--no-optional-locks", "-C", dir, "diff", "--quiet"], { stdio: "ignore" }).status !== 0 || | |
| spawnSync("git", ["--no-optional-locks", "-C", dir, "diff", "--cached", "--quiet"], { stdio: "ignore" }).status !== 0; | |
| const branchStr = `${c.blue}git:(${c.red}${branch}${c.blue})${c.reset}`; | |
| return hasDiff ? `${branchStr} ${c.yellow}✗${c.reset}` : branchStr; | |
| } | |
| // ─── 时间格式化(UTC ISO -> 本地 HH:MM)──────────────────────────────────── | |
| function formatResetTime(isoTime: string): string { | |
| try { | |
| return new Date(isoTime).toLocaleTimeString("zh-CN", { | |
| hour: "2-digit", | |
| minute: "2-digit", | |
| hour12: false, | |
| }); | |
| } catch { | |
| return ""; | |
| } | |
| } | |
| // ─── 主函数 ─────────────────────────────────────────────────────────────────── | |
| async function main() { | |
| const input = await readStdin(); | |
| const currentDir = input.cwd ?? process.cwd(); | |
| const fallbackModel = input.model?.display_name ?? ""; | |
| const modelName = getActualModel(input.transcript_path, fallbackModel); | |
| const isApiMode = !!process.env.ANTHROPIC_BASE_URL; | |
| // 并行获取数据(ccusage 为同步调用,OAuth 为异步) | |
| const [daily, oauthData, weeklyCost, monthlyCost] = await Promise.all([ | |
| Promise.resolve(getDailyData(currentDir)), | |
| isApiMode ? Promise.resolve(null) : getOAuthQuota(), | |
| isApiMode ? Promise.resolve(null) : Promise.resolve(getWeeklyCost()), | |
| isApiMode ? Promise.resolve(null) : Promise.resolve(getMonthlyCost()), | |
| ]); | |
| const gitInfo = getGitInfo(currentDir); | |
| const dirName = currentDir.split("/").at(-1) ?? currentDir; | |
| const ctxPct = getContextPercent(input); | |
| // ── 组装各段内容(用 segments 数组,最终 join(" | "))── | |
| const segments: string[] = []; | |
| // 第一段:提示符 + 目录 + git | |
| const prompt = `${c.green}➜${c.reset} ${c.cyan}${dirName}${c.reset}`; | |
| segments.push(gitInfo ? `${prompt} ${gitInfo}` : prompt); | |
| // Context 使用率(≥90% 才变黄,否则灰色) | |
| if (ctxPct !== null) { | |
| const color = ctxPct >= 90 ? c.yellow : c.gray; | |
| segments.push(`${color}ctx:${ctxPct}%${c.reset}`); | |
| } | |
| // 模型名称 | |
| if (modelName) { | |
| segments.push(`${c.gray}${modelName}${c.reset}`); | |
| } | |
| // ── 费用组(今日 / 周 / 月)── | |
| // 今日费用(项目费用 / 总费用 + API 标签) | |
| if (daily.todayCost !== null && daily.todayCost > 0) { | |
| const apiLabel = isApiMode | |
| ? ` (${process.env.ANTHROPIC_BASE_URL!.replace(/^https?:\/\//, "").replace(/\/.*$/, "")})` | |
| : " (订阅)"; | |
| if (daily.projectCost !== null && daily.projectCost > 0) { | |
| segments.push( | |
| `${c.yellow}$${daily.projectCost.toFixed(2)}/$${daily.todayCost.toFixed(2)}${c.gray}${apiLabel}${c.reset}` | |
| ); | |
| } else { | |
| segments.push(`${c.yellow}$${daily.todayCost.toFixed(2)}${c.gray}${apiLabel}${c.reset}`); | |
| } | |
| } | |
| // 周费用 | |
| if (weeklyCost !== null && weeklyCost > 0) { | |
| segments.push(`${c.morandiPurple}周:$${Math.round(weeklyCost)}${c.reset}`); | |
| } | |
| // 月费用 | |
| if (monthlyCost !== null && monthlyCost > 0) { | |
| segments.push(`${c.morandiPurple}月:$${Math.round(monthlyCost)}${c.reset}`); | |
| } | |
| // ── 配额组(5h / 7d,放最后)── | |
| if (!isApiMode && oauthData) { | |
| const fh = oauthData.five_hour; | |
| if (fh?.utilization !== undefined) { | |
| const timeStr = fh.resets_at ? `@${formatResetTime(fh.resets_at)}` : ""; | |
| segments.push(`${c.morandiBlue}5h:${fh.utilization}%${timeStr}${c.reset}`); | |
| } | |
| const sd = oauthData.seven_day; | |
| if (sd?.utilization !== undefined) { | |
| const daysLeft = sd.resets_at | |
| ? ((new Date(sd.resets_at).getTime() - Date.now()) / 86400000).toFixed(1) | |
| : null; | |
| const timeStr = daysLeft ? `@${daysLeft}d` : ""; | |
| segments.push(`${c.morandiBlue}7d:${sd.utilization}%${timeStr}${c.reset}`); | |
| } | |
| } | |
| process.stdout.write(segments.join(" | ")); | |
| } | |
| main().catch(() => process.exit(1)); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment