Skip to content

Instantly share code, notes, and snippets.

@hangox
Last active March 30, 2026 12:14
Show Gist options
  • Select an option

  • Save hangox/09cdf644683f7301973d4b48b63a329d to your computer and use it in GitHub Desktop.

Select an option

Save hangox/09cdf644683f7301973d4b48b63a329d to your computer and use it in GitHub Desktop.
Claude Code 自定义状态栏脚本 - 显示项目/Git/模型/费用/配额信息
#!/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