ccusage-on-cloud-client
Version:
Claude Code usage reporter client for ccusage-on-cloud-server
385 lines (323 loc) • 12.8 kB
JavaScript
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();