ccreport 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +127 -0
- package/dist/calculations.d.ts +83 -0
- package/dist/calculations.d.ts.map +1 -0
- package/dist/calculations.js +378 -0
- package/dist/calculations.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +946 -0
- package/dist/index.js.map +1 -0
- package/package.json +46 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,946 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, readdirSync, statSync, existsSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
import { execSync } from 'child_process';
|
|
6
|
+
import { aggregateUsage, getNextWeeklyReset, getSessionResetTime, percent, formatTokens, } from './calculations.js';
|
|
7
|
+
// 設定
|
|
8
|
+
const CLAUDE_DIR = join(homedir(), '.claude');
|
|
9
|
+
const PROJECTS_DIR = join(CLAUDE_DIR, 'projects');
|
|
10
|
+
// コマンドライン引数を解析
|
|
11
|
+
const args = process.argv.slice(2);
|
|
12
|
+
const command = args[0] && !args[0].startsWith('-') ? args[0] : '';
|
|
13
|
+
// フラグ解析
|
|
14
|
+
function hasFlag(name) {
|
|
15
|
+
return args.includes(`--${name}`) || args.includes(`-${name.charAt(0)}`);
|
|
16
|
+
}
|
|
17
|
+
function getArgValue(name) {
|
|
18
|
+
const arg = args.find((a) => a.startsWith(`--${name}=`));
|
|
19
|
+
return arg ? arg.split('=')[1] : null;
|
|
20
|
+
}
|
|
21
|
+
const flags = {
|
|
22
|
+
help: hasFlag('help') || args.includes('-h'),
|
|
23
|
+
json: hasFlag('json'),
|
|
24
|
+
verbose: hasFlag('verbose') || args.includes('-v'),
|
|
25
|
+
};
|
|
26
|
+
// API URL(環境変数 or コマンドライン引数)
|
|
27
|
+
const DEFAULT_API_URL = 'https://ccusage-dashboard.ippei-matsuda.workers.dev/api/sync-usage';
|
|
28
|
+
const API_URL = process.env.SYNC_API_URL || getArgValue('api-url') || DEFAULT_API_URL;
|
|
29
|
+
const USER_ID = process.env.SYNC_USER_ID || getArgValue('user-id') || process.env.USER || 'default';
|
|
30
|
+
// ヘルプ表示
|
|
31
|
+
function showHelp() {
|
|
32
|
+
console.log(`
|
|
33
|
+
ccreport - Claude Code使用量レポーター
|
|
34
|
+
|
|
35
|
+
使い方:
|
|
36
|
+
ccreport [コマンド] [オプション]
|
|
37
|
+
|
|
38
|
+
コマンド:
|
|
39
|
+
(なし) 公式API + OTelの両方を表示
|
|
40
|
+
usage 公式Usage APIのみ表示(/usageと同じ)
|
|
41
|
+
usage --push 公式Usage APIデータをダッシュボードに同期
|
|
42
|
+
push OTelデータをダッシュボードに同期
|
|
43
|
+
cost コストレポートを表示
|
|
44
|
+
legacy 旧方式で使用量を表示(JSONL読み取り)
|
|
45
|
+
legacy push 旧方式でダッシュボードに同期
|
|
46
|
+
|
|
47
|
+
オプション:
|
|
48
|
+
-v, --verbose 詳細表示
|
|
49
|
+
--json JSON形式で出力
|
|
50
|
+
--push データをダッシュボードに同期
|
|
51
|
+
--otel-dir=DIR OTelデータディレクトリ(デフォルト: ./data)
|
|
52
|
+
--api-url=URL 送信先APIのURL
|
|
53
|
+
--user-id=ID ユーザーID
|
|
54
|
+
-h, --help このヘルプを表示
|
|
55
|
+
|
|
56
|
+
例:
|
|
57
|
+
ccreport # 公式API + OTelの両方を表示
|
|
58
|
+
ccreport usage # 公式使用率のみ表示(Claude Code /usageと同じ)
|
|
59
|
+
ccreport usage --push # 公式使用率をダッシュボードに同期
|
|
60
|
+
ccreport push # OTelデータをダッシュボードに同期
|
|
61
|
+
ccreport cost # コストレポートを表示
|
|
62
|
+
ccreport legacy # 旧方式で使用量表示
|
|
63
|
+
ccreport -v # 詳細付きで表示
|
|
64
|
+
`);
|
|
65
|
+
}
|
|
66
|
+
// JSONLファイルを再帰的に探す
|
|
67
|
+
function findJsonlFiles(dir, files = []) {
|
|
68
|
+
if (!existsSync(dir))
|
|
69
|
+
return files;
|
|
70
|
+
const entries = readdirSync(dir);
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
const fullPath = join(dir, entry);
|
|
73
|
+
const stat = statSync(fullPath);
|
|
74
|
+
if (stat.isDirectory()) {
|
|
75
|
+
findJsonlFiles(fullPath, files);
|
|
76
|
+
}
|
|
77
|
+
else if (entry.endsWith('.jsonl')) {
|
|
78
|
+
files.push(fullPath);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return files;
|
|
82
|
+
}
|
|
83
|
+
// JSONLファイルを解析
|
|
84
|
+
function parseJsonlFile(filePath) {
|
|
85
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
86
|
+
const lines = content.trim().split('\n').filter((line) => line.trim());
|
|
87
|
+
const entries = [];
|
|
88
|
+
for (const line of lines) {
|
|
89
|
+
try {
|
|
90
|
+
entries.push(JSON.parse(line));
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// 無効な行はスキップ
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return entries;
|
|
97
|
+
}
|
|
98
|
+
// OTelデータディレクトリ(デフォルト: ~/.claude-otel/data)
|
|
99
|
+
const DEFAULT_OTEL_DIR = join(homedir(), '.claude-otel', 'data');
|
|
100
|
+
const OTEL_DATA_DIR = process.env.OTEL_DATA_DIR || getArgValue('otel-dir') || DEFAULT_OTEL_DIR;
|
|
101
|
+
// ========================================
|
|
102
|
+
// 公式Usage API関連
|
|
103
|
+
// ========================================
|
|
104
|
+
// 公式Usage API設定
|
|
105
|
+
const OFFICIAL_API_CONFIG = {
|
|
106
|
+
apiEndpoint: 'https://api.anthropic.com/api/oauth/usage',
|
|
107
|
+
betaHeader: 'oauth-2025-04-20',
|
|
108
|
+
userAgent: process.env.CLAUDE_CODE_UA || 'claude-code/2.0.32',
|
|
109
|
+
};
|
|
110
|
+
// OAuthトークンを取得(複数ソース対応)
|
|
111
|
+
function getOAuthToken() {
|
|
112
|
+
// 方法1: ~/.claude/.credentials.json から読み取り(推奨)
|
|
113
|
+
try {
|
|
114
|
+
const credentialsPath = join(homedir(), '.claude', '.credentials.json');
|
|
115
|
+
if (existsSync(credentialsPath)) {
|
|
116
|
+
const content = readFileSync(credentialsPath, 'utf-8');
|
|
117
|
+
const credentials = JSON.parse(content);
|
|
118
|
+
// 直接形式: { accessToken, expiresAt }
|
|
119
|
+
if (credentials.accessToken) {
|
|
120
|
+
return {
|
|
121
|
+
token: credentials.accessToken,
|
|
122
|
+
expiresAt: credentials.expiresAt,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
// ネスト形式: { claudeAiOauth: { accessToken, expiresAt } }
|
|
126
|
+
if (credentials.claudeAiOauth?.accessToken) {
|
|
127
|
+
return {
|
|
128
|
+
token: credentials.claudeAiOauth.accessToken,
|
|
129
|
+
expiresAt: credentials.claudeAiOauth.expiresAt,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// ファイル読み取り失敗、Keychainにフォールバック
|
|
136
|
+
}
|
|
137
|
+
// 方法2: macOS Keychainから取得(フォールバック)
|
|
138
|
+
if (process.platform === 'darwin') {
|
|
139
|
+
try {
|
|
140
|
+
const result = execSync('security find-generic-password -s "Claude Code-credentials" -w', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
141
|
+
const credentials = JSON.parse(result.trim());
|
|
142
|
+
// 直接形式: { accessToken, expiresAt }
|
|
143
|
+
if (credentials.accessToken) {
|
|
144
|
+
return {
|
|
145
|
+
token: credentials.accessToken,
|
|
146
|
+
expiresAt: credentials.expiresAt,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
// ネスト形式: { claudeAiOauth: { accessToken, expiresAt } }
|
|
150
|
+
if (credentials.claudeAiOauth?.accessToken) {
|
|
151
|
+
return {
|
|
152
|
+
token: credentials.claudeAiOauth.accessToken,
|
|
153
|
+
expiresAt: credentials.claudeAiOauth.expiresAt,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// Keychain読み取り失敗
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
// トークンの有効期限チェック(3600秒 = 1時間、5分前にプロアクティブに期限切れとみなす)
|
|
164
|
+
function isTokenExpired(expiresAt) {
|
|
165
|
+
const now = Date.now();
|
|
166
|
+
const bufferMs = 5 * 60 * 1000; // 5分のバッファ
|
|
167
|
+
return now >= (expiresAt - bufferMs);
|
|
168
|
+
}
|
|
169
|
+
// utilizationの値域検証(0-100の整数%)
|
|
170
|
+
function validateUtilization(value, field) {
|
|
171
|
+
if (typeof value !== 'number') {
|
|
172
|
+
if (flags.verbose)
|
|
173
|
+
console.error(`Warning: ${field} is not a number: ${value}`);
|
|
174
|
+
return 0;
|
|
175
|
+
}
|
|
176
|
+
if (value < 0 || value > 100) {
|
|
177
|
+
if (flags.verbose)
|
|
178
|
+
console.error(`Warning: ${field} out of expected range (0-100): ${value}`);
|
|
179
|
+
}
|
|
180
|
+
return Math.round(value);
|
|
181
|
+
}
|
|
182
|
+
// レスポンスの検証と正規化
|
|
183
|
+
function validateAndNormalizeResponse(raw) {
|
|
184
|
+
if (!raw || typeof raw !== 'object')
|
|
185
|
+
return null;
|
|
186
|
+
const data = raw;
|
|
187
|
+
// 必須フィールドの存在確認
|
|
188
|
+
if (!data.five_hour || !data.seven_day) {
|
|
189
|
+
console.error('Unexpected API response structure');
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
const fiveHour = data.five_hour;
|
|
193
|
+
const sevenDay = data.seven_day;
|
|
194
|
+
return {
|
|
195
|
+
five_hour: {
|
|
196
|
+
utilization: validateUtilization(fiveHour.utilization, 'five_hour.utilization'),
|
|
197
|
+
resets_at: typeof fiveHour.resets_at === 'string' ? fiveHour.resets_at : null,
|
|
198
|
+
},
|
|
199
|
+
seven_day: {
|
|
200
|
+
utilization: validateUtilization(sevenDay.utilization, 'seven_day.utilization'),
|
|
201
|
+
resets_at: typeof sevenDay.resets_at === 'string' ? sevenDay.resets_at : null,
|
|
202
|
+
},
|
|
203
|
+
seven_day_opus: data.seven_day_opus ? {
|
|
204
|
+
utilization: validateUtilization(data.seven_day_opus.utilization, 'seven_day_opus.utilization'),
|
|
205
|
+
resets_at: data.seven_day_opus.resets_at ?? null,
|
|
206
|
+
} : null,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
// 公式Usage APIを呼び出し
|
|
210
|
+
async function fetchOfficialUsage() {
|
|
211
|
+
const credentials = getOAuthToken();
|
|
212
|
+
if (!credentials) {
|
|
213
|
+
return {
|
|
214
|
+
success: false,
|
|
215
|
+
error: 'TOKEN_NOT_FOUND',
|
|
216
|
+
message: 'OAuth token not found. Please login to Claude Code first.',
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
if (isTokenExpired(credentials.expiresAt)) {
|
|
220
|
+
return {
|
|
221
|
+
success: false,
|
|
222
|
+
error: 'TOKEN_EXPIRED',
|
|
223
|
+
message: 'OAuth token expired. Please restart Claude Code to refresh.',
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
const response = await fetch(OFFICIAL_API_CONFIG.apiEndpoint, {
|
|
228
|
+
method: 'GET',
|
|
229
|
+
headers: {
|
|
230
|
+
'Authorization': `Bearer ${credentials.token}`,
|
|
231
|
+
'Accept': 'application/json',
|
|
232
|
+
'Content-Type': 'application/json',
|
|
233
|
+
'anthropic-beta': OFFICIAL_API_CONFIG.betaHeader,
|
|
234
|
+
'User-Agent': OFFICIAL_API_CONFIG.userAgent,
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
if (response.status === 401 || response.status === 403) {
|
|
238
|
+
return {
|
|
239
|
+
success: false,
|
|
240
|
+
error: 'AUTH_ERROR',
|
|
241
|
+
message: 'Authentication failed. Please re-login to Claude Code.',
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
if (response.status === 429) {
|
|
245
|
+
return {
|
|
246
|
+
success: false,
|
|
247
|
+
error: 'RATE_LIMITED',
|
|
248
|
+
message: 'Rate limited. Please wait a few minutes and try again.',
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
if (response.status === 404) {
|
|
252
|
+
return {
|
|
253
|
+
success: false,
|
|
254
|
+
error: 'API_DEPRECATED',
|
|
255
|
+
message: 'Official API may have changed. Falling back to OTel data.',
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
if (!response.ok) {
|
|
259
|
+
return {
|
|
260
|
+
success: false,
|
|
261
|
+
error: 'API_ERROR',
|
|
262
|
+
message: `API error: ${response.status}`,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
const data = await response.json();
|
|
266
|
+
const validated = validateAndNormalizeResponse(data);
|
|
267
|
+
if (!validated) {
|
|
268
|
+
return {
|
|
269
|
+
success: false,
|
|
270
|
+
error: 'INVALID_RESPONSE',
|
|
271
|
+
message: 'Invalid API response structure',
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
return {
|
|
275
|
+
success: true,
|
|
276
|
+
data: validated,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
return {
|
|
281
|
+
success: false,
|
|
282
|
+
error: 'NETWORK_ERROR',
|
|
283
|
+
message: 'Network error. Please check your connection.',
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
// 公式使用率を表示
|
|
288
|
+
function showOfficialUsage(usage) {
|
|
289
|
+
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
290
|
+
// 時刻フォーマット
|
|
291
|
+
const formatTime = (isoString) => {
|
|
292
|
+
if (!isoString)
|
|
293
|
+
return 'N/A';
|
|
294
|
+
const d = new Date(isoString);
|
|
295
|
+
const hour = d.getHours();
|
|
296
|
+
const min = String(d.getMinutes()).padStart(2, '0');
|
|
297
|
+
const ampm = hour >= 12 ? 'pm' : 'am';
|
|
298
|
+
const h12 = hour % 12 || 12;
|
|
299
|
+
return `${h12}:${min}${ampm}`;
|
|
300
|
+
};
|
|
301
|
+
const formatDateLong = (isoString) => {
|
|
302
|
+
if (!isoString)
|
|
303
|
+
return 'N/A';
|
|
304
|
+
const d = new Date(isoString);
|
|
305
|
+
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
306
|
+
return `${months[d.getMonth()]} ${d.getDate()}, ${formatTime(isoString)}`;
|
|
307
|
+
};
|
|
308
|
+
console.log('\n📊 Claude Code Usage (Official API)');
|
|
309
|
+
console.log('─'.repeat(40));
|
|
310
|
+
// セッション(5時間)
|
|
311
|
+
console.log(`\nSession (5h): ${usage.five_hour.utilization}% used`);
|
|
312
|
+
console.log(` Resets at: ${formatTime(usage.five_hour.resets_at)} (${tz})`);
|
|
313
|
+
// 週間
|
|
314
|
+
console.log(`\nWeekly (1W): ${usage.seven_day.utilization}% used`);
|
|
315
|
+
console.log(` Resets at: ${formatDateLong(usage.seven_day.resets_at)} (${tz})`);
|
|
316
|
+
// Opus(存在する場合)
|
|
317
|
+
if (usage.seven_day_opus) {
|
|
318
|
+
console.log(`\nOpus (1W): ${usage.seven_day_opus.utilization}% used`);
|
|
319
|
+
if (usage.seven_day_opus.resets_at) {
|
|
320
|
+
console.log(` Resets at: ${formatDateLong(usage.seven_day_opus.resets_at)} (${tz})`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
console.log('\n📡 Data source: Official Anthropic API');
|
|
324
|
+
}
|
|
325
|
+
// 公式使用率をダッシュボードに同期
|
|
326
|
+
async function pushOfficialUsage() {
|
|
327
|
+
console.log('📤 Fetching official usage from Anthropic API...\n');
|
|
328
|
+
const result = await fetchOfficialUsage();
|
|
329
|
+
if (!result.success) {
|
|
330
|
+
console.error(`❌ ${result.message}`);
|
|
331
|
+
process.exit(1);
|
|
332
|
+
}
|
|
333
|
+
const usage = result.data;
|
|
334
|
+
// 表示
|
|
335
|
+
console.log(`📊 Session (5h): ${usage.five_hour.utilization}%`);
|
|
336
|
+
console.log(`📊 Weekly (1W): ${usage.seven_day.utilization}%`);
|
|
337
|
+
if (usage.seven_day_opus) {
|
|
338
|
+
console.log(`📊 Opus (1W): ${usage.seven_day_opus.utilization}%`);
|
|
339
|
+
}
|
|
340
|
+
console.log('');
|
|
341
|
+
// Workerに送信
|
|
342
|
+
const officialApiUrl = API_URL.replace('/api/sync-usage', '/api/sync-official-usage');
|
|
343
|
+
try {
|
|
344
|
+
const response = await fetch(officialApiUrl, {
|
|
345
|
+
method: 'POST',
|
|
346
|
+
headers: {
|
|
347
|
+
'Content-Type': 'application/json',
|
|
348
|
+
'X-User-Id': USER_ID,
|
|
349
|
+
},
|
|
350
|
+
body: JSON.stringify({
|
|
351
|
+
fiveHour: usage.five_hour,
|
|
352
|
+
sevenDay: usage.seven_day,
|
|
353
|
+
sevenDayOpus: usage.seven_day_opus,
|
|
354
|
+
syncedAt: new Date().toISOString(),
|
|
355
|
+
}),
|
|
356
|
+
});
|
|
357
|
+
if (response.ok) {
|
|
358
|
+
console.log('✓ Official usage synced to dashboard');
|
|
359
|
+
console.log('\n🎉 Done! View at: https://ccusage-dashboard.ippei-matsuda.workers.dev');
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
const text = await response.text();
|
|
363
|
+
console.error(`❌ Sync failed: ${response.status} - ${text}`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
catch (e) {
|
|
367
|
+
const error = e;
|
|
368
|
+
console.error(`❌ Sync failed: ${error.message}`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
// OTelメトリクスファイルを解析
|
|
372
|
+
function parseOtelMetrics(filePath) {
|
|
373
|
+
if (!existsSync(filePath))
|
|
374
|
+
return [];
|
|
375
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
376
|
+
const lines = content.trim().split('\n').filter((line) => line.trim());
|
|
377
|
+
const results = [];
|
|
378
|
+
for (const line of lines) {
|
|
379
|
+
try {
|
|
380
|
+
const data = JSON.parse(line);
|
|
381
|
+
if (data.resourceMetrics) {
|
|
382
|
+
results.push(...data.resourceMetrics);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
catch {
|
|
386
|
+
// 無効な行はスキップ
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return results;
|
|
390
|
+
}
|
|
391
|
+
// OTelログファイルを解析
|
|
392
|
+
function parseOtelLogs(filePath) {
|
|
393
|
+
if (!existsSync(filePath))
|
|
394
|
+
return [];
|
|
395
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
396
|
+
const lines = content.trim().split('\n').filter((line) => line.trim());
|
|
397
|
+
const results = [];
|
|
398
|
+
for (const line of lines) {
|
|
399
|
+
try {
|
|
400
|
+
const data = JSON.parse(line);
|
|
401
|
+
if (data.resourceLogs) {
|
|
402
|
+
results.push(...data.resourceLogs);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
// 無効な行はスキップ
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return results;
|
|
410
|
+
}
|
|
411
|
+
// 属性値を取得するヘルパー
|
|
412
|
+
function getAttrValue(attrs, key) {
|
|
413
|
+
const attr = attrs.find((a) => a.key === key);
|
|
414
|
+
if (!attr)
|
|
415
|
+
return undefined;
|
|
416
|
+
return attr.value.stringValue ?? attr.value.doubleValue ?? (attr.value.intValue ? parseInt(attr.value.intValue) : undefined);
|
|
417
|
+
}
|
|
418
|
+
// OTelデータを集約
|
|
419
|
+
function aggregateOtelData() {
|
|
420
|
+
const metricsPath = join(OTEL_DATA_DIR, 'metrics.json');
|
|
421
|
+
const logsPath = join(OTEL_DATA_DIR, 'logs.json');
|
|
422
|
+
if (flags.verbose) {
|
|
423
|
+
console.error(`Reading OTel data from: ${OTEL_DATA_DIR}`);
|
|
424
|
+
}
|
|
425
|
+
const resourceMetrics = parseOtelMetrics(metricsPath);
|
|
426
|
+
const resourceLogs = parseOtelLogs(logsPath);
|
|
427
|
+
if (resourceMetrics.length === 0 && resourceLogs.length === 0) {
|
|
428
|
+
console.error('No OTel data found. Make sure the collector is running and data exists.');
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
const result = {
|
|
432
|
+
sessionCount: 0,
|
|
433
|
+
tokens: { input: 0, output: 0, cacheRead: 0, cacheCreation: 0, total: 0 },
|
|
434
|
+
cost: { total: 0, byModel: {} },
|
|
435
|
+
activeTime: 0,
|
|
436
|
+
apiRequests: [],
|
|
437
|
+
collectedAt: new Date().toISOString(),
|
|
438
|
+
};
|
|
439
|
+
// メトリクスを集約
|
|
440
|
+
for (const rm of resourceMetrics) {
|
|
441
|
+
for (const sm of rm.scopeMetrics) {
|
|
442
|
+
for (const metric of sm.metrics) {
|
|
443
|
+
const dataPoints = metric.sum?.dataPoints || metric.gauge?.dataPoints || [];
|
|
444
|
+
for (const dp of dataPoints) {
|
|
445
|
+
const value = dp.asDouble ?? dp.asInt ?? 0;
|
|
446
|
+
const type = getAttrValue(dp.attributes, 'type');
|
|
447
|
+
const model = getAttrValue(dp.attributes, 'model');
|
|
448
|
+
switch (metric.name) {
|
|
449
|
+
case 'claude_code.session.count':
|
|
450
|
+
result.sessionCount = Math.max(result.sessionCount, value);
|
|
451
|
+
break;
|
|
452
|
+
case 'claude_code.token.usage':
|
|
453
|
+
if (type === 'input')
|
|
454
|
+
result.tokens.input += value;
|
|
455
|
+
else if (type === 'output')
|
|
456
|
+
result.tokens.output += value;
|
|
457
|
+
else if (type === 'cacheRead')
|
|
458
|
+
result.tokens.cacheRead += value;
|
|
459
|
+
else if (type === 'cacheCreation')
|
|
460
|
+
result.tokens.cacheCreation += value;
|
|
461
|
+
break;
|
|
462
|
+
case 'claude_code.cost.usage':
|
|
463
|
+
result.cost.total += value;
|
|
464
|
+
if (model) {
|
|
465
|
+
result.cost.byModel[model] = (result.cost.byModel[model] || 0) + value;
|
|
466
|
+
}
|
|
467
|
+
break;
|
|
468
|
+
case 'claude_code.active_time.total':
|
|
469
|
+
result.activeTime += value;
|
|
470
|
+
break;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
result.tokens.total = result.tokens.input + result.tokens.output + result.tokens.cacheRead + result.tokens.cacheCreation;
|
|
477
|
+
// ログからAPIリクエストを抽出
|
|
478
|
+
for (const rl of resourceLogs) {
|
|
479
|
+
for (const sl of rl.scopeLogs) {
|
|
480
|
+
for (const log of sl.logRecords) {
|
|
481
|
+
const eventName = getAttrValue(log.attributes, 'event.name');
|
|
482
|
+
if (eventName === 'api_request') {
|
|
483
|
+
result.apiRequests.push({
|
|
484
|
+
timestamp: getAttrValue(log.attributes, 'event.timestamp') || '',
|
|
485
|
+
model: getAttrValue(log.attributes, 'model') || '',
|
|
486
|
+
inputTokens: getAttrValue(log.attributes, 'input_tokens') || 0,
|
|
487
|
+
outputTokens: getAttrValue(log.attributes, 'output_tokens') || 0,
|
|
488
|
+
cacheReadTokens: getAttrValue(log.attributes, 'cache_read_tokens') || 0,
|
|
489
|
+
cacheCreationTokens: getAttrValue(log.attributes, 'cache_creation_tokens') || 0,
|
|
490
|
+
costUsd: getAttrValue(log.attributes, 'cost_usd') || 0,
|
|
491
|
+
durationMs: getAttrValue(log.attributes, 'duration_ms') || 0,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
if (flags.verbose) {
|
|
498
|
+
console.error(`Parsed ${resourceMetrics.length} metric batches, ${resourceLogs.length} log batches`);
|
|
499
|
+
console.error(`Sessions: ${result.sessionCount}, Tokens: ${result.tokens.total}, Cost: $${result.cost.total.toFixed(4)}`);
|
|
500
|
+
}
|
|
501
|
+
return result;
|
|
502
|
+
}
|
|
503
|
+
// OTelデータを表示
|
|
504
|
+
function showOtelData(data) {
|
|
505
|
+
// 制限値(Pro: 5h=14.4M, 週間=22.8M)
|
|
506
|
+
const LIMIT_5H = 14_400_000;
|
|
507
|
+
const LIMIT_WEEK = 22_800_000;
|
|
508
|
+
// トークン使用量
|
|
509
|
+
const totalTokens = data.tokens.total;
|
|
510
|
+
const sessionPct = percent(totalTokens, LIMIT_5H);
|
|
511
|
+
const weeklyPct = percent(totalTokens, LIMIT_WEEK);
|
|
512
|
+
console.log('\n📊 OpenTelemetry Metrics');
|
|
513
|
+
console.log('─'.repeat(40));
|
|
514
|
+
// サマリー
|
|
515
|
+
console.log(`\nSessions: ${data.sessionCount}`);
|
|
516
|
+
console.log(`Total Tokens: ${formatTokens(totalTokens)}`);
|
|
517
|
+
console.log(`Total Cost: $${data.cost.total.toFixed(4)}`);
|
|
518
|
+
console.log(`Active Time: ${data.activeTime.toFixed(1)}s`);
|
|
519
|
+
console.log(`API Requests: ${data.apiRequests.length}`);
|
|
520
|
+
// 使用率
|
|
521
|
+
console.log(`\n📈 Usage vs Limits (Pro plan)`);
|
|
522
|
+
console.log(`Session (5h): ${sessionPct}% (${formatTokens(totalTokens)} / ${formatTokens(LIMIT_5H)})`);
|
|
523
|
+
console.log(`Weekly: ${weeklyPct}% (${formatTokens(totalTokens)} / ${formatTokens(LIMIT_WEEK)})`);
|
|
524
|
+
// トークン内訳
|
|
525
|
+
console.log(`\n🔢 Token Breakdown`);
|
|
526
|
+
console.log(`Input: ${formatTokens(data.tokens.input)}`);
|
|
527
|
+
console.log(`Output: ${formatTokens(data.tokens.output)}`);
|
|
528
|
+
console.log(`Cache Read: ${formatTokens(data.tokens.cacheRead)}`);
|
|
529
|
+
console.log(`Cache Create: ${formatTokens(data.tokens.cacheCreation)}`);
|
|
530
|
+
// モデル別コスト
|
|
531
|
+
if (Object.keys(data.cost.byModel).length > 0) {
|
|
532
|
+
console.log(`\n💰 Cost by Model`);
|
|
533
|
+
for (const [model, cost] of Object.entries(data.cost.byModel)) {
|
|
534
|
+
console.log(` ${model}: $${cost.toFixed(4)}`);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
// 詳細情報(verbose時のみ)
|
|
538
|
+
if (flags.verbose && data.apiRequests.length > 0) {
|
|
539
|
+
console.log(`\n📝 Recent API Requests (last 5)`);
|
|
540
|
+
const recent = data.apiRequests.slice(-5);
|
|
541
|
+
for (const req of recent) {
|
|
542
|
+
console.log(` ${req.model}: in=${req.inputTokens}, out=${req.outputTokens}, cost=$${req.costUsd.toFixed(4)}`);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
console.log(`\n📅 Collected: ${data.collectedAt}`);
|
|
546
|
+
}
|
|
547
|
+
// OTelデータを送信
|
|
548
|
+
async function pushOtelData() {
|
|
549
|
+
console.log('📤 Pushing OTel data to dashboard...\n');
|
|
550
|
+
const data = aggregateOtelData();
|
|
551
|
+
if (!data) {
|
|
552
|
+
console.error('✗ Failed to get OTel data');
|
|
553
|
+
process.exit(1);
|
|
554
|
+
}
|
|
555
|
+
console.log(`📊 Sessions: ${data.sessionCount}`);
|
|
556
|
+
console.log(`📊 Tokens: ${formatTokens(data.tokens.total)} (in:${formatTokens(data.tokens.input)}, out:${formatTokens(data.tokens.output)}, cache:${formatTokens(data.tokens.cacheRead)})`);
|
|
557
|
+
console.log(`📊 Cost: $${data.cost.total.toFixed(4)}`);
|
|
558
|
+
console.log(`📊 Active time: ${data.activeTime.toFixed(1)}s`);
|
|
559
|
+
console.log(`📊 API requests: ${data.apiRequests.length}`);
|
|
560
|
+
console.log('');
|
|
561
|
+
const otelApiUrl = API_URL.replace('/api/sync-usage', '/api/sync-otel');
|
|
562
|
+
try {
|
|
563
|
+
const response = await fetch(otelApiUrl, {
|
|
564
|
+
method: 'POST',
|
|
565
|
+
headers: {
|
|
566
|
+
'Content-Type': 'application/json',
|
|
567
|
+
'X-User-Id': USER_ID,
|
|
568
|
+
},
|
|
569
|
+
body: JSON.stringify(data),
|
|
570
|
+
});
|
|
571
|
+
if (response.ok) {
|
|
572
|
+
console.log('✓ OTel data synced');
|
|
573
|
+
console.log('\n🎉 Done! View at: https://ccusage-dashboard.ippei-matsuda.workers.dev');
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
const text = await response.text();
|
|
577
|
+
console.error(`✗ OTel sync failed: ${response.status} - ${text}`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
catch (e) {
|
|
581
|
+
const error = e;
|
|
582
|
+
console.error(`✗ OTel sync failed: ${error.message}`);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
// ccusageからデータを取得
|
|
586
|
+
function fetchCcusageData(startDate) {
|
|
587
|
+
try {
|
|
588
|
+
const result = execSync(`npx -y ccusage daily -s ${startDate} -j`, {
|
|
589
|
+
encoding: 'utf-8',
|
|
590
|
+
timeout: 60000,
|
|
591
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
592
|
+
});
|
|
593
|
+
return JSON.parse(result);
|
|
594
|
+
}
|
|
595
|
+
catch {
|
|
596
|
+
return null;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
// 日付をYYYYMMDD形式に変換
|
|
600
|
+
function formatDateYMD(date) {
|
|
601
|
+
const y = date.getFullYear();
|
|
602
|
+
const m = String(date.getMonth() + 1).padStart(2, '0');
|
|
603
|
+
const d = String(date.getDate()).padStart(2, '0');
|
|
604
|
+
return `${y}${m}${d}`;
|
|
605
|
+
}
|
|
606
|
+
// 日付をYYYY-MM-DD形式に変換
|
|
607
|
+
function formatDateISO(date) {
|
|
608
|
+
const y = date.getFullYear();
|
|
609
|
+
const m = String(date.getMonth() + 1).padStart(2, '0');
|
|
610
|
+
const d = String(date.getDate()).padStart(2, '0');
|
|
611
|
+
return `${y}-${m}-${d}`;
|
|
612
|
+
}
|
|
613
|
+
// コストレポートを生成
|
|
614
|
+
function generateCostReport() {
|
|
615
|
+
const now = new Date();
|
|
616
|
+
const today = formatDateISO(now);
|
|
617
|
+
// 月初日を取得
|
|
618
|
+
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
619
|
+
const startDateStr = formatDateYMD(monthStart);
|
|
620
|
+
if (flags.verbose) {
|
|
621
|
+
console.error(`Fetching ccusage data from ${startDateStr}...`);
|
|
622
|
+
}
|
|
623
|
+
const data = fetchCcusageData(startDateStr);
|
|
624
|
+
if (!data || !data.daily) {
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
// 今日のデータ
|
|
628
|
+
const todayData = data.daily.find((d) => d.date === today);
|
|
629
|
+
const todayTokens = todayData?.totalTokens ?? 0;
|
|
630
|
+
const todayCost = todayData?.totalCost ?? 0;
|
|
631
|
+
// 今週のデータ(月曜日から)
|
|
632
|
+
const dayOfWeek = now.getDay();
|
|
633
|
+
const mondayOffset = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
|
|
634
|
+
const weekStart = new Date(now);
|
|
635
|
+
weekStart.setDate(now.getDate() - mondayOffset);
|
|
636
|
+
weekStart.setHours(0, 0, 0, 0);
|
|
637
|
+
const weekStartStr = formatDateISO(weekStart);
|
|
638
|
+
let weekTokens = 0;
|
|
639
|
+
let weekCost = 0;
|
|
640
|
+
for (const entry of data.daily) {
|
|
641
|
+
if (entry.date >= weekStartStr && entry.date <= today) {
|
|
642
|
+
weekTokens += entry.totalTokens;
|
|
643
|
+
weekCost += entry.totalCost;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
// 今月のデータ
|
|
647
|
+
let monthTokens = 0;
|
|
648
|
+
let monthCost = 0;
|
|
649
|
+
const monthStartStr = formatDateISO(monthStart);
|
|
650
|
+
for (const entry of data.daily) {
|
|
651
|
+
if (entry.date >= monthStartStr) {
|
|
652
|
+
monthTokens += entry.totalTokens;
|
|
653
|
+
monthCost += entry.totalCost;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
return {
|
|
657
|
+
today: { tokens: todayTokens, cost: todayCost, date: today },
|
|
658
|
+
thisWeek: {
|
|
659
|
+
tokens: weekTokens,
|
|
660
|
+
cost: weekCost,
|
|
661
|
+
startDate: weekStartStr,
|
|
662
|
+
endDate: today,
|
|
663
|
+
},
|
|
664
|
+
thisMonth: {
|
|
665
|
+
tokens: monthTokens,
|
|
666
|
+
cost: monthCost,
|
|
667
|
+
month: `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`,
|
|
668
|
+
},
|
|
669
|
+
daily: data.daily,
|
|
670
|
+
generatedAt: now.toISOString(),
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
// 使用量データを取得
|
|
674
|
+
function getUsageData() {
|
|
675
|
+
if (flags.verbose) {
|
|
676
|
+
console.error(`Searching for JSONL files in: ${PROJECTS_DIR}`);
|
|
677
|
+
}
|
|
678
|
+
const jsonlFiles = findJsonlFiles(PROJECTS_DIR);
|
|
679
|
+
if (jsonlFiles.length === 0) {
|
|
680
|
+
console.error('No JSONL files found in ~/.claude/projects/');
|
|
681
|
+
return null;
|
|
682
|
+
}
|
|
683
|
+
if (flags.verbose) {
|
|
684
|
+
console.error(`Found ${jsonlFiles.length} JSONL files`);
|
|
685
|
+
}
|
|
686
|
+
// 全ファイルを解析
|
|
687
|
+
const allEntries = [];
|
|
688
|
+
for (const file of jsonlFiles) {
|
|
689
|
+
const entries = parseJsonlFile(file);
|
|
690
|
+
allEntries.push(...entries);
|
|
691
|
+
}
|
|
692
|
+
if (flags.verbose) {
|
|
693
|
+
console.error(`Parsed ${allEntries.length} entries`);
|
|
694
|
+
}
|
|
695
|
+
// 集計
|
|
696
|
+
const aggregated = aggregateUsage(allEntries);
|
|
697
|
+
return {
|
|
698
|
+
...aggregated,
|
|
699
|
+
syncedAt: new Date().toISOString(),
|
|
700
|
+
fileCount: jsonlFiles.length,
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
// 使用量を表示(Claude Code /usage形式)
|
|
704
|
+
function showUsage(usage) {
|
|
705
|
+
// 制限値(Pro: 5h=14.4M, 週間=22.8M)
|
|
706
|
+
const LIMIT_5H = 14_400_000;
|
|
707
|
+
const LIMIT_WEEK = 22_800_000;
|
|
708
|
+
// ローリング5時間セッション(Claude Code /usage準拠)
|
|
709
|
+
const sessionUsed = usage.rollingSession.totalTokens;
|
|
710
|
+
const sessionPct = percent(sessionUsed, LIMIT_5H);
|
|
711
|
+
// 週間使用量
|
|
712
|
+
const weeklyUsed = usage.limits.lastWeek.totalTokens;
|
|
713
|
+
const weeklyPct = percent(weeklyUsed, LIMIT_WEEK);
|
|
714
|
+
// リセット時刻のフォーマット
|
|
715
|
+
const formatTime = (d) => {
|
|
716
|
+
const hour = d.getHours();
|
|
717
|
+
const min = String(d.getMinutes()).padStart(2, '0');
|
|
718
|
+
const ampm = hour >= 12 ? 'pm' : 'am';
|
|
719
|
+
const h12 = hour % 12 || 12;
|
|
720
|
+
return `${h12}:${min}${ampm}`;
|
|
721
|
+
};
|
|
722
|
+
const formatDateLong = (d) => {
|
|
723
|
+
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
724
|
+
return `${months[d.getMonth()]} ${d.getDate()}, ${formatTime(d)}`;
|
|
725
|
+
};
|
|
726
|
+
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
727
|
+
const sessionResetTime = usage.rollingSession.blockEndTime || getSessionResetTime();
|
|
728
|
+
const weeklyResetTime = getNextWeeklyReset();
|
|
729
|
+
// Claude Code /usage 形式で出力
|
|
730
|
+
console.log(`\nCurrent session: ${sessionPct}% used, Resets ${formatTime(sessionResetTime)} (${tz})`);
|
|
731
|
+
console.log(`Current week (all models): ${weeklyPct}% used, Resets ${formatDateLong(weeklyResetTime)} (${tz})`);
|
|
732
|
+
// 詳細情報(verbose時のみ)
|
|
733
|
+
if (flags.verbose) {
|
|
734
|
+
console.log('\n--- Details ---');
|
|
735
|
+
console.log(`Session tokens: ${formatTokens(sessionUsed)} / ${formatTokens(LIMIT_5H)}`);
|
|
736
|
+
console.log(`Weekly tokens: ${formatTokens(weeklyUsed)} / ${formatTokens(LIMIT_WEEK)}`);
|
|
737
|
+
if (usage.activeBlock && usage.activeBlock.blockStartTime) {
|
|
738
|
+
const elapsed = usage.activeBlock.elapsedMinutes;
|
|
739
|
+
const remaining = usage.activeBlock.remainingMinutes;
|
|
740
|
+
console.log(`\nActive block: ${Math.floor(elapsed / 60)}h ${elapsed % 60}m elapsed, ${Math.floor(remaining / 60)}h ${remaining % 60}m remaining`);
|
|
741
|
+
}
|
|
742
|
+
console.log(`\nTotal messages: ${usage.summary.messageCount.toLocaleString()}`);
|
|
743
|
+
console.log(`Files scanned: ${usage.fileCount}`);
|
|
744
|
+
if (Object.keys(usage.byModel).length > 0) {
|
|
745
|
+
console.log('\nBy Model:');
|
|
746
|
+
for (const [model, data] of Object.entries(usage.byModel)) {
|
|
747
|
+
const total = data.inputTokens + data.outputTokens + data.cacheCreationTokens + data.cacheReadTokens;
|
|
748
|
+
console.log(` ${model}: ${total.toLocaleString()} tokens`);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
// コストレポートを表示
|
|
754
|
+
function showCostReport(report) {
|
|
755
|
+
const formatCost = (cost) => `$${cost.toFixed(2)}`;
|
|
756
|
+
console.log('\n=== Today ===');
|
|
757
|
+
console.log(`Date: ${report.today.date}`);
|
|
758
|
+
console.log(`Tokens: ${formatTokens(report.today.tokens)}`);
|
|
759
|
+
console.log(`Cost: ${formatCost(report.today.cost)}`);
|
|
760
|
+
console.log('\n=== This Week ===');
|
|
761
|
+
console.log(`Period: ${report.thisWeek.startDate} ~ ${report.thisWeek.endDate}`);
|
|
762
|
+
console.log(`Tokens: ${formatTokens(report.thisWeek.tokens)}`);
|
|
763
|
+
console.log(`Cost: ${formatCost(report.thisWeek.cost)}`);
|
|
764
|
+
console.log('\n=== This Month ===');
|
|
765
|
+
console.log(`Month: ${report.thisMonth.month}`);
|
|
766
|
+
console.log(`Tokens: ${formatTokens(report.thisMonth.tokens)}`);
|
|
767
|
+
console.log(`Cost: ${formatCost(report.thisMonth.cost)}`);
|
|
768
|
+
}
|
|
769
|
+
// データをダッシュボードに送信
|
|
770
|
+
async function pushData() {
|
|
771
|
+
console.log('📤 Pushing data to dashboard...\n');
|
|
772
|
+
// 使用量データを取得・送信
|
|
773
|
+
const usage = getUsageData();
|
|
774
|
+
if (!usage) {
|
|
775
|
+
console.error('✗ Failed to get usage data');
|
|
776
|
+
process.exit(1);
|
|
777
|
+
}
|
|
778
|
+
try {
|
|
779
|
+
const response = await fetch(API_URL, {
|
|
780
|
+
method: 'POST',
|
|
781
|
+
headers: {
|
|
782
|
+
'Content-Type': 'application/json',
|
|
783
|
+
'X-User-Id': USER_ID,
|
|
784
|
+
},
|
|
785
|
+
body: JSON.stringify(usage),
|
|
786
|
+
});
|
|
787
|
+
if (response.ok) {
|
|
788
|
+
console.log('✓ Usage data synced');
|
|
789
|
+
}
|
|
790
|
+
else {
|
|
791
|
+
console.error(`✗ Usage sync failed: ${response.status}`);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
catch (e) {
|
|
795
|
+
const error = e;
|
|
796
|
+
console.error(`✗ Usage sync failed: ${error.message}`);
|
|
797
|
+
}
|
|
798
|
+
// コストレポートを取得・送信
|
|
799
|
+
console.log('📊 Generating cost report...');
|
|
800
|
+
const report = generateCostReport();
|
|
801
|
+
if (report) {
|
|
802
|
+
const reportApiUrl = API_URL.replace('/api/sync-usage', '/api/sync-report');
|
|
803
|
+
try {
|
|
804
|
+
const response = await fetch(reportApiUrl, {
|
|
805
|
+
method: 'POST',
|
|
806
|
+
headers: {
|
|
807
|
+
'Content-Type': 'application/json',
|
|
808
|
+
'X-User-Id': USER_ID,
|
|
809
|
+
},
|
|
810
|
+
body: JSON.stringify(report),
|
|
811
|
+
});
|
|
812
|
+
if (response.ok) {
|
|
813
|
+
console.log('✓ Cost report synced');
|
|
814
|
+
}
|
|
815
|
+
else {
|
|
816
|
+
console.error(`✗ Cost report sync failed: ${response.status}`);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
catch (e) {
|
|
820
|
+
const error = e;
|
|
821
|
+
console.error(`✗ Cost report sync failed: ${error.message}`);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
else {
|
|
825
|
+
console.log('⚠ Cost report skipped (ccusage not available)');
|
|
826
|
+
}
|
|
827
|
+
console.log('\n🎉 Done! View at: https://ccusage-dashboard.ippei-matsuda.workers.dev');
|
|
828
|
+
}
|
|
829
|
+
// メイン処理
|
|
830
|
+
async function main() {
|
|
831
|
+
// ヘルプ
|
|
832
|
+
if (flags.help) {
|
|
833
|
+
showHelp();
|
|
834
|
+
process.exit(0);
|
|
835
|
+
}
|
|
836
|
+
// サブコマンドを取得(legacy push対応)
|
|
837
|
+
const subCommand = args[1] && !args[1].startsWith('-') ? args[1] : '';
|
|
838
|
+
// コマンド分岐
|
|
839
|
+
switch (command) {
|
|
840
|
+
case 'usage': {
|
|
841
|
+
// 公式Usage API
|
|
842
|
+
if (hasFlag('push')) {
|
|
843
|
+
await pushOfficialUsage();
|
|
844
|
+
}
|
|
845
|
+
else {
|
|
846
|
+
const result = await fetchOfficialUsage();
|
|
847
|
+
if (!result.success) {
|
|
848
|
+
console.error(`❌ ${result.message}`);
|
|
849
|
+
process.exit(1);
|
|
850
|
+
}
|
|
851
|
+
if (flags.json) {
|
|
852
|
+
console.log(JSON.stringify(result.data, null, 2));
|
|
853
|
+
}
|
|
854
|
+
else {
|
|
855
|
+
showOfficialUsage(result.data);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
break;
|
|
859
|
+
}
|
|
860
|
+
case 'push': {
|
|
861
|
+
// OTelデータを送信(デフォルト)
|
|
862
|
+
await pushOtelData();
|
|
863
|
+
break;
|
|
864
|
+
}
|
|
865
|
+
case 'cost': {
|
|
866
|
+
const report = generateCostReport();
|
|
867
|
+
if (!report) {
|
|
868
|
+
console.error('✗ Failed to generate cost report (ccusage not available)');
|
|
869
|
+
process.exit(1);
|
|
870
|
+
}
|
|
871
|
+
if (flags.json) {
|
|
872
|
+
console.log(JSON.stringify(report, null, 2));
|
|
873
|
+
}
|
|
874
|
+
else {
|
|
875
|
+
showCostReport(report);
|
|
876
|
+
}
|
|
877
|
+
break;
|
|
878
|
+
}
|
|
879
|
+
case 'legacy': {
|
|
880
|
+
// 旧方式(JSONL読み取り)
|
|
881
|
+
if (subCommand === 'push') {
|
|
882
|
+
// legacy push: 旧方式で送信
|
|
883
|
+
await pushData();
|
|
884
|
+
}
|
|
885
|
+
else {
|
|
886
|
+
// legacy: 旧方式で表示
|
|
887
|
+
const usage = getUsageData();
|
|
888
|
+
if (!usage) {
|
|
889
|
+
process.exit(1);
|
|
890
|
+
}
|
|
891
|
+
if (flags.json) {
|
|
892
|
+
console.log(JSON.stringify(usage, null, 2));
|
|
893
|
+
}
|
|
894
|
+
else {
|
|
895
|
+
showUsage(usage);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
break;
|
|
899
|
+
}
|
|
900
|
+
default: {
|
|
901
|
+
// デフォルト: 公式APIとOTelの両方を表示
|
|
902
|
+
const [officialResult, otelData] = await Promise.all([
|
|
903
|
+
fetchOfficialUsage(),
|
|
904
|
+
Promise.resolve(aggregateOtelData()),
|
|
905
|
+
]);
|
|
906
|
+
const hasOfficial = officialResult.success;
|
|
907
|
+
const hasOtel = otelData !== null;
|
|
908
|
+
// JSON出力
|
|
909
|
+
if (flags.json) {
|
|
910
|
+
const combined = {
|
|
911
|
+
official: hasOfficial ? officialResult.data : null,
|
|
912
|
+
otel: hasOtel ? otelData : null,
|
|
913
|
+
};
|
|
914
|
+
console.log(JSON.stringify(combined, null, 2));
|
|
915
|
+
break;
|
|
916
|
+
}
|
|
917
|
+
// 両方失敗
|
|
918
|
+
if (!hasOfficial && !hasOtel) {
|
|
919
|
+
console.error('\n❌ データを取得できませんでした');
|
|
920
|
+
console.error(`\n公式API: ${officialResult.message}`);
|
|
921
|
+
console.error('\nOTel: データがありません');
|
|
922
|
+
console.error('\n💡 ヒント:');
|
|
923
|
+
console.error(' - Claude Codeにログインしているか確認してください');
|
|
924
|
+
console.error(' - OTelコレクターを起動: cd otel && docker compose up -d');
|
|
925
|
+
process.exit(1);
|
|
926
|
+
}
|
|
927
|
+
// 公式APIを表示
|
|
928
|
+
if (hasOfficial) {
|
|
929
|
+
showOfficialUsage(officialResult.data);
|
|
930
|
+
}
|
|
931
|
+
else {
|
|
932
|
+
console.log('\n⚠️ 公式API: ' + officialResult.message);
|
|
933
|
+
}
|
|
934
|
+
// OTelを表示
|
|
935
|
+
if (hasOtel) {
|
|
936
|
+
showOtelData(otelData);
|
|
937
|
+
}
|
|
938
|
+
else {
|
|
939
|
+
console.log('\n⚠️ OTel: データがありません(コレクターを起動してください)');
|
|
940
|
+
}
|
|
941
|
+
break;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
main().catch(console.error);
|
|
946
|
+
//# sourceMappingURL=index.js.map
|