UNPKG

ccusage-on-cloud-client

Version:

Claude Code usage reporter client for ccusage-on-cloud-server

385 lines (323 loc) 12.8 kB
#!/usr/bin/env node require('dotenv').config(); const axios = require('axios'); const fs = require('fs'); const path = require('path'); const os = require('os'); const { execSync } = require('child_process'); const REQUIRED_SERVER_VERSION = '1.1.0'; // 設定を読み込む関数 function loadConfig() { return { apiUrl: process.env.CCUSAGE_ON_CLOUD_API_URL || 'https://ccusage-on-cloud-server.onrender.com/usage', serverUrl: process.env.CCUSAGE_ON_CLOUD_API_URL?.replace('/usage', '') || 'https://ccusage-on-cloud-server.onrender.com', username: process.env.CCUSAGE_ON_CLOUD_USERNAME || 'default-user', password: process.env.CCUSAGE_ON_CLOUD_PASSWORD || 'default-pass', serverId: process.env.CCUSAGE_ON_CLOUD_SERVER_ID || 'default-server' }; } // 環境変数から設定を取得 const config = loadConfig(); // 一時ディレクトリの作成 const tempDir = path.join(os.tmpdir(), 'ccusage-on-cloud', config.username); const tempProjectDir = path.join(tempDir, 'projects', 'api-data', 'session-001'); // サーバーからデータを取得 async function fetchServerData(isLiveMode = false) { try { // Basic認証用のヘッダーを作成 const auth = Buffer.from(`${config.username}:${config.password}`).toString('base64'); // live modeの場合はパラメータを追加 const url = isLiveMode ? `${config.apiUrl}?live=true` : config.apiUrl; const response = await axios.get(url, { headers: { 'Authorization': `Basic ${auth}` }, timeout: 10000 }); // バージョンチェック if (response.data && response.data.serverVersion) { if (response.data.serverVersion !== REQUIRED_SERVER_VERSION) { console.warn(`\n⚠️ Server version mismatch!`); console.warn(` Client requires: v${REQUIRED_SERVER_VERSION}`); console.warn(` Server running: v${response.data.serverVersion}`); console.warn(` Some features may not work correctly.\n`); } } else if (response.data) { console.warn(`\n⚠️ Server version unknown. Please update the server.\n`); } if (!response.data || !response.data.rawReports || response.data.rawReports.length === 0) { console.log(`📊 No usage data available for ${config.username}`); return null; } return response.data; } catch (error) { if (error.response) { console.error(`❌ Failed to get data from server: ${error.response.status}`); } else { console.error(`❌ Failed to get data from server: ${error.message}`); } return null; } } // サーバーデータをccusageのJSONL形式に変換 function convertToJsonl(serverData) { const jsonlLines = []; // 時刻ごとにデータを集約 const aggregatedByTime = {}; serverData.rawReports.forEach((report) => { if (report.rawJsonl && report.rawJsonl.length > 0) { report.rawJsonl.forEach((rawData) => { try { const data = typeof rawData === 'string' ? JSON.parse(rawData) : rawData; // 時刻IDをキーとして集約 const timeId = data.id || data.startTime; if (!aggregatedByTime[timeId]) { // 最初のデータをベースとして使用(ただしtotalTokensは後で上書き) aggregatedByTime[timeId] = { ...data, servers: [], totalUsage: 0, maxLimit: 0, originalTotalTokens: data.totalTokens // 元の値を保持 }; } // サーバーごとのデータを追加 // デフォルトの制限値は52M(キャッシュトークンを除外した通常制限) const defaultLimit = 52313162; let tokenLimit = data.tokenLimitStatus?.limit || data.tokenLimit || defaultLimit; // 102M以上の場合は履歴の最大値なので、デフォルト制限を使用 if (tokenLimit > 100000000) { tokenLimit = defaultLimit; } // 実際の使用量: inputTokens + outputTokens のみ(キャッシュトークンを除外) const actualUsage = (data.tokenCounts?.inputTokens || 0) + (data.tokenCounts?.outputTokens || 0); aggregatedByTime[timeId].servers.push({ serverID: report.serverID, totalTokens: actualUsage, tokenLimit: tokenLimit }); // 使用量は合計、制限値は最大値 aggregatedByTime[timeId].totalUsage += actualUsage; aggregatedByTime[timeId].maxLimit = Math.max( aggregatedByTime[timeId].maxLimit, tokenLimit ); } catch (e) { console.error('⚠️ Failed to process raw JSONL data:', e); } }); } }); // 集約したデータをJSONL形式に変換 Object.keys(aggregatedByTime).sort().forEach(timeId => { const aggData = aggregatedByTime[timeId]; // ccusageが理解できる形式に変換(必要なフィールドのみ明示的にコピー) const outputData = { id: aggData.id, startTime: aggData.startTime, endTime: aggData.endTime, actualEndTime: aggData.actualEndTime, isActive: aggData.isActive, isGap: aggData.isGap, entries: aggData.entries, tokenCounts: aggData.tokenCounts, totalTokens: aggData.totalUsage, // 修正された使用量を使用 costUSD: aggData.costUSD, models: aggData.models, burnRate: aggData.burnRate, projection: aggData.projection, // トークン制限情報を追加(修正された制限値を使用) tokenLimitStatus: { limit: aggData.maxLimit, percentUsed: aggData.maxLimit > 0 ? (aggData.totalUsage / aggData.maxLimit) * 100 : 0, projectedUsage: aggData.totalUsage, status: aggData.totalUsage > aggData.maxLimit ? "exceeds" : "normal" } }; // servers配列を削除(ccusageには不要) delete outputData.servers; delete outputData.totalUsage; delete outputData.maxLimit; delete outputData.originalTotalTokens; jsonlLines.push(JSON.stringify(outputData)); }); return jsonlLines.join('\n'); } // 一時ディレクトリをセットアップ function setupTempDirectory() { // ディレクトリが存在する場合は削除 if (fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true, force: true }); } // ディレクトリを作成 fs.mkdirSync(tempProjectDir, { recursive: true }); } // send-data機能:ローカルのccusageデータをサーバーに送信 async function sendDataToServer() { try { // ローカルのccusageデータを取得 console.log('📊 Getting local ccusage data...'); const result = execSync('npx ccusage blocks --json', { encoding: 'utf8' }); const blocksData = JSON.parse(result); // サーバー送信用のデータを準備 const config = loadConfig(); const rawJsonl = blocksData.blocks.map(block => JSON.stringify(block)); const payload = { user: config.username, project: process.cwd().split('/').pop() || 'unknown', environment: process.env.NODE_ENV || 'development', serverID: config.serverId, rawJsonl: rawJsonl }; // サーバーに送信 console.log('🚀 Sending data to server...'); const response = await fetch(`${config.serverUrl}/usage`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Basic ${Buffer.from(`${config.username}:${config.password}`).toString('base64')}` }, body: JSON.stringify(payload) }); if (response.ok) { const result = await response.json(); console.log(`✅ Data sent successfully! Server version: ${result.serverVersion || 'unknown'}`); return true; } else { const errorText = await response.text(); console.error(`❌ Failed to send data: ${response.status} ${errorText}`); return false; } } catch (error) { console.error(`❌ Error sending data: ${error.message}`); return false; } } // ccusageコマンドを実行 function runCcusage(command = 'blocks') { try { // 引数を処理 const args = process.argv.slice(2); const ccusageCommand = args.length > 0 ? args.join(' ') : command; // --liveオプションがある場合は特別な処理 if (args.includes('--live')) { // 統合データをリアルタイム表示 return runCcusageLive(ccusageCommand); } // 通常モード:ccusageを実行(CCUSAGE_DATA_DIRを設定) const result = execSync(`npx ccusage ${ccusageCommand}`, { env: { ...process.env, CCUSAGE_DATA_DIR: tempDir }, stdio: 'inherit' }); return result; } catch (error) { console.error(`❌ Failed to run ccusage: ${error.message}`); process.exit(1); } } // ライブモードで統合データを表示 function runCcusageLive(ccusageCommand) { const { spawn } = require('child_process'); console.log(`🔄 Starting live mode with server data sync...\n`); // 更新間隔を取得(--refresh-intervalから、なければ30秒) const args = process.argv; const refreshIdx = args.indexOf('--refresh-interval'); const refreshInterval = (refreshIdx !== -1 && args[refreshIdx + 1]) ? parseInt(args[refreshIdx + 1]) * 1000 : 30000; // 定期的にサーバーからデータを取得してJSONLファイルを更新 const updateInterval = setInterval(async () => { try { const serverData = await fetchServerData(true); // live mode if (serverData) { // サーバー側で処理済みデータを変換 const jsonlData = convertToJsonl(serverData); const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const newJsonlFile = path.join(tempProjectDir, `usage-update-${timestamp}.jsonl`); fs.writeFileSync(newJsonlFile, jsonlData); } } catch (error) { console.error(`⚠️ Failed to update data: ${error.message}`); } }, refreshInterval); // ccusageを非同期で起動(統合データを使用) const ccusage = spawn('npx', ['ccusage', ...ccusageCommand.split(' ')], { env: { ...process.env, CCUSAGE_DATA_DIR: tempDir // 統合データを使用 }, stdio: 'inherit' }); // クリーンアップ処理 ccusage.on('exit', (code) => { clearInterval(updateInterval); setTimeout(() => { if (fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true, force: true }); } }, 1000); process.exit(code); }); // シグナルハンドリング process.on('SIGINT', () => { clearInterval(updateInterval); ccusage.kill('SIGINT'); }); process.on('SIGTERM', () => { clearInterval(updateInterval); ccusage.kill('SIGTERM'); }); } // メイン処理 async function main() { try { // send-dataコマンドの場合は直接実行 const args = process.argv.slice(2); if (args.includes('send-data')) { await sendDataToServer(); return; } console.log(`🔍 Fetching usage data from server...`); // サーバーからデータを取得(ライブモードの場合はlive=trueパラメータ) const mainArgs = process.argv.slice(2); const argString = mainArgs.join(' '); const isLiveMode = argString.includes('--live'); const serverData = await fetchServerData(isLiveMode); if (!serverData) { process.exit(1); } // 一時ディレクトリをセットアップ setupTempDirectory(); // JSONL形式に変換 const jsonlData = convertToJsonl(serverData); // タイムスタンプ付きのファイル名を生成 const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const jsonlFile = path.join(tempProjectDir, `usage-${timestamp}.jsonl`); // JSONLファイルを書き込み fs.writeFileSync(jsonlFile, jsonlData); console.log(`✅ Created temporary usage data at: ${jsonlFile}`); console.log(`📊 Running ccusage with server data...\n`); // ファイルが確実に存在することを確認 if (!fs.existsSync(jsonlFile)) { throw new Error(`JSONL file was not created: ${jsonlFile}`); } // ccusageを実行 runCcusage(); // 一時ファイルをクリーンアップ if (!process.argv.includes('--live')) { setTimeout(() => { if (fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true, force: true }); } }, 1000); } } catch (error) { console.error(`💥 Error: ${error.message}`); process.exit(1); } } // 実行 main();