Created
February 13, 2026 10:27
-
-
Save Misaka-0x447f/f8bd672f52ff5d59579b9615a2f64fec to your computer and use it in GitHub Desktop.
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
| #!/bin/bash | |
| # Wrapper to invoke TypeScript statusline script | |
| exec pnpx tsx "$(dirname "$0")/open-router-statusline.ts" --env-file=.env |
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 npx tsx | |
| /** | |
| * OpenRouter cost tracking statusline for Claude Code | |
| * | |
| * Displays: Provider: model - $cost - cache discount: $saved | |
| * | |
| * Setup: Add to your ~/.claude/settings.json: | |
| * { | |
| * "statusLine": { | |
| * "type": "command", | |
| * "command": "/path/to/statusline.sh" | |
| * } | |
| * } | |
| * | |
| * Requires: ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY set to your OpenRouter API key | |
| */ | |
| import { existsSync, readFileSync, writeFileSync } from 'node:fs'; | |
| interface StatuslineInput { | |
| session_id: string; | |
| transcript_path: string; | |
| } | |
| interface GenerationData { | |
| total_cost: number; | |
| cache_discount: number | null; | |
| provider_name: string; | |
| model: string; | |
| } | |
| interface State { | |
| seen_ids: string[]; | |
| total_cost: number; | |
| total_cache_discount: number; | |
| last_provider: string; | |
| last_model: string; | |
| } | |
| async function fetchGeneration(id: string, apiKey: string): Promise<GenerationData | null> { | |
| try { | |
| const res = await fetch(`https://openrouter.ai/api/v1/generation?id=${id}`, { | |
| headers: { | |
| Authorization: `Bearer ${apiKey}`, | |
| }, | |
| }); | |
| if (!res.ok) { | |
| return null; | |
| } | |
| const json = await res.json(); | |
| const data = json?.data; | |
| if (!data || typeof data.total_cost !== 'number') { | |
| return null; | |
| } | |
| return data; | |
| } catch { | |
| return null; | |
| } | |
| } | |
| function extractGenerationIds(transcriptPath: string): string[] { | |
| try { | |
| const content = readFileSync(transcriptPath, 'utf-8'); | |
| const ids: string[] = []; | |
| for (const line of content.split('\n')) { | |
| if (!line.trim()) { | |
| continue; | |
| } | |
| try { | |
| const entry = JSON.parse(line); | |
| const messageId = entry?.message?.id; | |
| if (typeof messageId === 'string' && messageId.startsWith('gen-')) { | |
| ids.push(messageId); | |
| } | |
| } catch { | |
| // Skip malformed lines | |
| } | |
| } | |
| return [...new Set(ids)]; | |
| } catch { | |
| return []; | |
| } | |
| } | |
| function loadState(statePath: string): State { | |
| const defaultState: State = { | |
| seen_ids: [], | |
| total_cost: 0, | |
| total_cache_discount: 0, | |
| last_provider: '', | |
| last_model: '', | |
| }; | |
| if (!existsSync(statePath)) { | |
| return defaultState; | |
| } | |
| try { | |
| const content = readFileSync(statePath, 'utf-8'); | |
| if (!content.trim()) { | |
| return defaultState; | |
| } | |
| const parsed = JSON.parse(content); | |
| // Validate state shape | |
| if (!Array.isArray(parsed.seen_ids)) { | |
| return defaultState; | |
| } | |
| return { | |
| seen_ids: parsed.seen_ids, | |
| total_cost: typeof parsed.total_cost === 'number' ? parsed.total_cost : 0, | |
| total_cache_discount: | |
| typeof parsed.total_cache_discount === 'number' ? parsed.total_cache_discount : 0, | |
| last_provider: typeof parsed.last_provider === 'string' ? parsed.last_provider : '', | |
| last_model: typeof parsed.last_model === 'string' ? parsed.last_model : '', | |
| }; | |
| } catch { | |
| return defaultState; | |
| } | |
| } | |
| function saveState(statePath: string, state: State): void { | |
| writeFileSync(statePath, JSON.stringify(state, null, 2)); | |
| } | |
| function shortModelName(model: string): string { | |
| return model.replace(/^[^/]+\//, '').replace(/-\d{8}$/, ''); | |
| } | |
| async function main(): Promise<void> { | |
| const apiKey = process.env.openrouterApiKey; | |
| let inputData = ''; | |
| for await (const chunk of process.stdin) { | |
| inputData += chunk; | |
| } | |
| const input = JSON.parse(inputData); | |
| const session_id = input?.session_id; | |
| const transcript_path = input?.transcript_path; | |
| if (typeof session_id !== 'string' || typeof transcript_path !== 'string') { | |
| process.stdout.write('Invalid statusline input'); | |
| return; | |
| } | |
| const statePath = `/tmp/claude-openrouter-cost-${session_id}.json`; | |
| const state = loadState(statePath); | |
| const allIds = extractGenerationIds(transcript_path); | |
| const seenSet = new Set(state.seen_ids); | |
| const newIds = allIds.filter((id) => !seenSet.has(id)); | |
| let fetchSucceeded = 0; | |
| let fetchFailed = 0; | |
| for (const id of newIds) { | |
| const gen = await fetchGeneration(id, apiKey); | |
| if (!gen) { | |
| fetchFailed++; | |
| continue; | |
| } | |
| fetchSucceeded++; | |
| state.total_cost += gen.total_cost ?? 0; | |
| state.total_cache_discount += gen.cache_discount ?? 0; | |
| if (gen.provider_name) { | |
| state.last_provider = gen.provider_name; | |
| } | |
| if (gen.model) { | |
| state.last_model = gen.model; | |
| } | |
| state.seen_ids.push(id); | |
| } | |
| saveState(statePath, state); | |
| const shortModel = shortModelName(state.last_model); | |
| let statusIndicator = ''; | |
| if (newIds.length > 0) { | |
| const green = '\x1b[32m'; | |
| const red = '\x1b[31m'; | |
| const reset = '\x1b[0m'; | |
| const now = new Date(); | |
| const timeString = new Intl.DateTimeFormat('en-GB', { | |
| hour: '2-digit', | |
| minute: '2-digit', | |
| second: '2-digit', | |
| hour12: false | |
| }).format(now); | |
| if (fetchFailed === 0) { | |
| statusIndicator = ` / ${green}${timeString}${reset}`; | |
| } else { | |
| statusIndicator = ` / ${red}${timeString}${reset}`; | |
| } | |
| } | |
| if (state.last_provider) { | |
| process.stdout.write( | |
| `${state.last_provider}: ${shortModel} / $${state.total_cost.toPrecision(4)} / cached $${state.total_cache_discount.toPrecision(3)}${statusIndicator}`, | |
| ); | |
| } else { | |
| process.stdout.write( | |
| `$${state.total_cost.toPrecision(4)} / cached $${state.total_cache_discount.toPrecision(3)}${statusIndicator}`, | |
| ); | |
| } | |
| } | |
| main().catch((err) => { | |
| process.stdout.write(`error: ${err.message}`); | |
| }); |
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
| #!/bin/bash | |
| # Wrapper to invoke TypeScript statusline script | |
| exec pnpx tsx "$(dirname "$0")/statusline.ts" |
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 npx tsx | |
| interface StatuslineInput { | |
| cwd?: string; | |
| workspace?: { current_dir?: string }; | |
| model?: { display_name?: string }; | |
| cost?: { total_cost_usd?: number }; | |
| context_window?: { | |
| total_input_tokens?: number; | |
| total_output_tokens?: number; | |
| }; | |
| } | |
| async function main(): Promise<void> { | |
| let raw = ''; | |
| for await (const chunk of process.stdin) { | |
| raw += chunk; | |
| } | |
| const data: StatuslineInput = JSON.parse(raw); | |
| const cwd = data.workspace?.current_dir ?? data.cwd ?? ''; | |
| const model = data.model?.display_name ?? ''; | |
| const cost = data.cost?.total_cost_usd ?? 0; | |
| const totalIn = data.context_window?.total_input_tokens ?? 0; | |
| const totalOut = data.context_window?.total_output_tokens ?? 0; | |
| const time = new Intl.DateTimeFormat('en-GB', { | |
| hour: '2-digit', | |
| minute: '2-digit', | |
| second: '2-digit', | |
| hour12: false, | |
| }).format(new Date()); | |
| process.stdout.write( | |
| `${cwd} | ${model} | $${cost.toFixed(4)} | ${time} | in:${new Intl.NumberFormat().format(totalIn)} out:${new Intl.NumberFormat().format(totalOut)}`, | |
| ); | |
| } | |
| main().catch((err) => { | |
| process.stdout.write(`error: ${err.message}`); | |
| }); |
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 node | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const PROFILES_DIR = path.join(__dirname, 'alt-profile.d'); | |
| const SETTINGS_FILE = path.join(__dirname, 'settings.json'); | |
| const STATE_FILE = path.join(__dirname, '.profile-state.json'); | |
| /** | |
| * 读取配置文件 | |
| */ | |
| function readSettings() { | |
| try { | |
| const content = fs.readFileSync(SETTINGS_FILE, 'utf-8'); | |
| return JSON.parse(content); | |
| } catch (error) { | |
| console.error(`读取配置文件失败: ${error.message}`); | |
| process.exit(1); | |
| } | |
| } | |
| /** | |
| * 保存配置文件 | |
| */ | |
| function saveSettings(settings) { | |
| try { | |
| fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2)); | |
| } catch (error) { | |
| console.error(`保存配置文件失败: ${error.message}`); | |
| process.exit(1); | |
| } | |
| } | |
| /** | |
| * 读取所有 profiles | |
| */ | |
| function getProfiles() { | |
| try { | |
| if (!fs.existsSync(PROFILES_DIR)) { | |
| console.error(`Profiles 目录不存在: ${PROFILES_DIR}`); | |
| process.exit(1); | |
| } | |
| const files = fs.readdirSync(PROFILES_DIR).filter(f => f.endsWith('.json')); | |
| if (files.length === 0) { | |
| console.error(`Profiles 目录中没有任何 JSON 文件`); | |
| process.exit(1); | |
| } | |
| const profiles = files.map(file => { | |
| try { | |
| const content = fs.readFileSync(path.join(PROFILES_DIR, file), 'utf-8'); | |
| return JSON.parse(content); | |
| } catch (error) { | |
| console.warn(`无法解析 profile 文件 ${file}: ${error.message}`); | |
| return null; | |
| } | |
| }).filter(p => p !== null); | |
| return profiles.sort((a, b) => (a.displayName || '').localeCompare(b.displayName || '')); | |
| } catch (error) { | |
| console.error(`读取 profiles 失败: ${error.message}`); | |
| process.exit(1); | |
| } | |
| } | |
| /** | |
| * 读取当前状态 | |
| */ | |
| function getState() { | |
| try { | |
| if (fs.existsSync(STATE_FILE)) { | |
| const content = fs.readFileSync(STATE_FILE, 'utf-8'); | |
| return JSON.parse(content); | |
| } | |
| return { currentIndex: 0 }; | |
| } catch (error) { | |
| return { currentIndex: 0 }; | |
| } | |
| } | |
| /** | |
| * 保存状态 | |
| */ | |
| function saveState(state) { | |
| try { | |
| fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); | |
| } catch (error) { | |
| console.warn(`无法保存状态: ${error.message}`); | |
| } | |
| } | |
| /** | |
| * 合并 profile 数据到设置 | |
| */ | |
| function mergeProfile(settings, profile) { | |
| const merged = { ...settings }; | |
| if (profile.data && typeof profile.data === 'object') { | |
| // 递归合并 data 到 settings | |
| for (const [key, value] of Object.entries(profile.data)) { | |
| if (typeof value === 'object' && value !== null && typeof merged[key] === 'object') { | |
| // 深度合并对象 | |
| merged[key] = { ...merged[key], ...value }; | |
| } else { | |
| merged[key] = value; | |
| } | |
| } | |
| } | |
| return merged; | |
| } | |
| /** | |
| * 主函数 | |
| */ | |
| function main() { | |
| const profiles = getProfiles(); | |
| const state = getState(); | |
| // 获取当前索引,确保在有效范围内 | |
| let currentIndex = state.currentIndex % profiles.length; | |
| // 计算下一个 profile 的索引 | |
| const nextIndex = (currentIndex + 1) % profiles.length; | |
| const nextProfile = profiles[nextIndex]; | |
| // 读取当前设置 | |
| let settings = readSettings(); | |
| // 合并下一个 profile 的数据 | |
| settings = mergeProfile(settings, nextProfile); | |
| // 保存设置 | |
| saveSettings(settings); | |
| // 保存状态 | |
| saveState({ currentIndex: nextIndex }); | |
| // 输出当前 displayName | |
| console.log(nextProfile.displayName || '(未命名)'); | |
| } | |
| // 运行 | |
| main(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment