tokenphage 2026.7.2-BETA → 2026.11.1
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 +27 -9
- package/package.json +5 -3
- package/src/auth.js +7 -14
- package/src/hook-reconcile.js +53 -0
- package/src/index.js +14 -32
- package/src/lib/config-schema.js +1 -0
- package/src/lib/paths.js +47 -19
- package/src/lib/retention.js +5 -4
- package/src/parser.js +41 -9
- package/src/parsers/claude.js +3 -1
- package/src/parsers/codex.js +3 -1
- package/src/parsers/opencode.js +166 -0
- package/src/runtime/claude.js +161 -0
- package/src/runtime/codex.js +128 -0
- package/src/runtime/opencode.js +97 -0
- package/src/runtime/read-logs.js +46 -0
- package/src/runtime/timeline.js +117 -0
- package/src/scheduler.js +65 -2
- package/src/sync-flow.js +61 -0
- package/src/sync.js +101 -9
- package/src/ui.js +10 -4
package/README.md
CHANGED
|
@@ -5,22 +5,22 @@
|
|
|
5
5
|
|
|
6
6
|
> 먹어치운 토큰, 이젠 README에 기록하세요!
|
|
7
7
|
|
|
8
|
-
Claude Code
|
|
8
|
+
Claude Code, Codex, opencode의 토큰 사용량을 모아 GitHub README에 붙일 수 있는 배지로 만들어 줍니다.
|
|
9
9
|
|
|
10
10
|
---
|
|
11
11
|
|
|
12
12
|
## 📋 요구 사항
|
|
13
13
|
|
|
14
|
-
- **Node.js
|
|
14
|
+
- **Node.js 22.14 이상**
|
|
15
15
|
- macOS 또는 Windows (자동 동기화 사용 시)
|
|
16
16
|
- GitHub 계정
|
|
17
17
|
|
|
18
18
|
## 🖼️ 이런 배지가 만들어져요
|
|
19
19
|
|
|
20
|
-
GitHub README(이미지를 붙일 수 있는 곳이면 어디든)에 아래 한 줄만 넣으면 됩니다.
|
|
20
|
+
GitHub README(이미지를 붙일 수 있는 곳이면 어디든)에 아래 한 줄만 넣으면 됩니다. `<your-github-name>`을 본인 GitHub 아이디로 바꾸세요.
|
|
21
21
|
|
|
22
22
|
```markdown
|
|
23
|
-
[](https://github.com/TOKENPHAGE)
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
**결과 ↓**
|
|
@@ -32,17 +32,35 @@ GitHub README(이미지를 붙일 수 있는 곳이면 어디든)에 아래 한
|
|
|
32
32
|
</tr>
|
|
33
33
|
<tr>
|
|
34
34
|
<td align="center">
|
|
35
|
-
<a href="https://github.com/TOKENPHAGE
|
|
36
|
-
<code>https://api.tokenphage.com/badge
|
|
35
|
+
<a href="https://github.com/TOKENPHAGE"><img src="https://api.tokenphage.com/badge/kobenlys?theme=gpu&mode=dark" alt="TokenPhage gpu dark 배지"></a><br/>
|
|
36
|
+
<code>[](https://github.com/TOKENPHAGE)</code>
|
|
37
37
|
</td>
|
|
38
38
|
<td align="center">
|
|
39
|
-
<a href="https://github.com/TOKENPHAGE
|
|
40
|
-
<code>https://api.tokenphage.com/badge
|
|
39
|
+
<a href="https://github.com/TOKENPHAGE"><img src="https://api.tokenphage.com/badge/kobenlys?theme=claude&mode=light" alt="TokenPhage claude light 배지"></a><br/>
|
|
40
|
+
<code>[](https://github.com/TOKENPHAGE)</code>
|
|
41
|
+
</td>
|
|
42
|
+
</tr>
|
|
43
|
+
<tr>
|
|
44
|
+
<td align="center" colspan="2"><strong>grass-claude · light</strong></td>
|
|
45
|
+
</tr>
|
|
46
|
+
<tr>
|
|
47
|
+
<td align="center" colspan="2">
|
|
48
|
+
<a href="https://github.com/TOKENPHAGE"><img src="https://api.tokenphage.com/badge/kobenlys?theme=grass-claude" alt="TokenPhage grass-claude light 배지"></a><br/>
|
|
49
|
+
<code>[](https://github.com/TOKENPHAGE)</code>
|
|
50
|
+
</td>
|
|
51
|
+
</tr>
|
|
52
|
+
<tr>
|
|
53
|
+
<td align="center" colspan="2"><strong>grass-claude · dark</strong></td>
|
|
54
|
+
</tr>
|
|
55
|
+
<tr>
|
|
56
|
+
<td align="center" colspan="2">
|
|
57
|
+
<a href="https://github.com/TOKENPHAGE"><img src="https://api.tokenphage.com/badge/kobenlys?theme=grass-claude&mode=dark" alt="TokenPhage grass-claude dark 배지"></a><br/>
|
|
58
|
+
<code>[](https://github.com/TOKENPHAGE)</code>
|
|
41
59
|
</td>
|
|
42
60
|
</tr>
|
|
43
61
|
</table>
|
|
44
62
|
|
|
45
|
-
> 🎨 URL 뒤에 옵션을 붙일 수 있어요 — 테마 `?theme=gpu`(기본)
|
|
63
|
+
> 🎨 URL 뒤에 옵션을 붙일 수 있어요 — 테마 `?theme=gpu`(기본) · `?theme=claude` · `?theme=grass-claude`, 색상 `?mode=light`(기본) 또는 `?mode=dark`.
|
|
46
64
|
|
|
47
65
|
## 📦 설치
|
|
48
66
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenphage",
|
|
3
|
-
"version": "2026.
|
|
3
|
+
"version": "2026.11.1",
|
|
4
4
|
"description": "AI 토큰 사용량 GitHub README 뱃지",
|
|
5
5
|
"author": "kobenlys <kobenlys@gmail.com>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -19,10 +19,12 @@
|
|
|
19
19
|
"scripts": {
|
|
20
20
|
"start": "node src/index.js",
|
|
21
21
|
"dev": "cross-env TOKENPHAGE_API=http://localhost:8080 node src/index.js",
|
|
22
|
-
"test": "node --test test/*.test.js"
|
|
22
|
+
"test": "node --test test/*.test.js",
|
|
23
|
+
"test:coverage": "node --test --experimental-test-coverage --test-coverage-exclude='test/**' --test-coverage-exclude='src/index.js' --test-coverage-exclude='src/ui.js' --test-coverage-exclude='src/animated-select.js' --test-coverage-exclude='src/gradient.js' --test-coverage-exclude='src/auth.js' --test-coverage-exclude='src/scheduler.js' --test-coverage-exclude='src/parser.js' --test-coverage-lines=80 --test-coverage-functions=80 test/*.test.js"
|
|
23
24
|
},
|
|
24
25
|
"dependencies": {
|
|
25
26
|
"@inquirer/prompts": "^8.5.0",
|
|
27
|
+
"better-sqlite3": "^13.0.3",
|
|
26
28
|
"chalk": "^5.6.2",
|
|
27
29
|
"commander": "^12.1.0",
|
|
28
30
|
"glob": "^11.0.0",
|
|
@@ -36,6 +38,6 @@
|
|
|
36
38
|
"mute-stream": "^3.0.0"
|
|
37
39
|
},
|
|
38
40
|
"engines": {
|
|
39
|
-
"node": "^
|
|
41
|
+
"node": "^22.14.0 || >=23.6.0"
|
|
40
42
|
}
|
|
41
43
|
}
|
package/src/auth.js
CHANGED
|
@@ -4,6 +4,7 @@ import { join } from 'path';
|
|
|
4
4
|
import { randomUUID } from 'crypto';
|
|
5
5
|
import { normalizeConfig } from './lib/config-schema.js';
|
|
6
6
|
import { localDateOf } from './lib/dates.js';
|
|
7
|
+
import { apiFetch } from './sync.js';
|
|
7
8
|
|
|
8
9
|
// 로컬 설정 파일 경로: ~/.tokenphage/config.json (JWT·deviceId·sync 상태 보관)
|
|
9
10
|
const CONFIG_DIR = join(homedir(), '.tokenphage');
|
|
@@ -79,18 +80,10 @@ export function getCustomClaudePaths() {
|
|
|
79
80
|
return Array.isArray(paths) ? paths : [];
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
/**
|
|
83
|
-
export function
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
export function saveLastSyncDate(date) {
|
|
87
|
-
saveConfig({ ...loadConfig(), lastSyncDate: date });
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** lastSyncDate를 제거해 다음 sync가 전체 히스토리를 재파싱하도록 한다. token/deviceId는 보존. */
|
|
91
|
-
export function clearLastSyncDate() {
|
|
92
|
-
const { lastSyncDate: _omit, ...rest } = loadConfig();
|
|
93
|
-
saveConfig(rest);
|
|
83
|
+
/** reset용: lastSyncDate와 lastSyncVersion을 지워 다음 sync가 토큰·실행시간을 처음부터 보내게 한다. 나머지는 보존. */
|
|
84
|
+
export function clearSyncState({ readConfig = loadConfig, writeConfig = saveConfig } = {}) {
|
|
85
|
+
const { lastSyncDate: _date, lastSyncVersion: _version, ...rest } = readConfig();
|
|
86
|
+
writeConfig(rest);
|
|
94
87
|
}
|
|
95
88
|
|
|
96
89
|
// Gist 소유권 검증용 파일명 — 사용자가 이 이름으로 Gist를 만들어야 한다.
|
|
@@ -113,8 +106,8 @@ export function parseGistId(input) {
|
|
|
113
106
|
|
|
114
107
|
/** url에 JSON body를 POST하고 {ok, status, body}를 반환한다. 응답이 JSON이 아니면 {error: 원문}으로 감싼다. */
|
|
115
108
|
async function postJson(url, body) {
|
|
116
|
-
//
|
|
117
|
-
const res = await
|
|
109
|
+
// 이름표를 붙이고 지원 종료 헤더를 안내하는 공통 요청
|
|
110
|
+
const res = await apiFetch(url, {
|
|
118
111
|
method: 'POST',
|
|
119
112
|
headers: { 'Content-Type': 'application/json' },
|
|
120
113
|
body: JSON.stringify(body),
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auto-sync 등록 정합성 복구.
|
|
3
|
+
* 설정(config.json)은 켜짐인데 OS 스케줄러에서 예약이 사라진 경우를 감지해 다시 등록한다.
|
|
4
|
+
*
|
|
5
|
+
* index.js가 아니라 별 모듈인 이유: index.js는 최상단에서 program.parse()를 실행하므로
|
|
6
|
+
* 테스트가 import하는 순간 CLI가 시동된다. lib/가 아닌 이유는 ui.js에 의존해
|
|
7
|
+
* 순수 로직 계층 규약에 맞지 않기 때문이다.
|
|
8
|
+
*/
|
|
9
|
+
import { loadConfig, setHookInstalled } from './auth.js';
|
|
10
|
+
import { installHook, probeHookRegistration } from './scheduler.js';
|
|
11
|
+
import { showError, pauseForEnter } from './ui.js';
|
|
12
|
+
|
|
13
|
+
const ISSUE_HINT =
|
|
14
|
+
'Log: ~/.tokenphage/logs/sync.log — please report it: https://github.com/TOKENPHAGE/tokenphage-cli/issues';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 설정은 auto-sync 켜짐인데 OS 스케줄러에 등록이 없으면 재등록한다.
|
|
18
|
+
*
|
|
19
|
+
* 헤드리스에서는 절대 재등록하지 않는다 — installMac()이 첫 단계로
|
|
20
|
+
* launchctlBootout()을 호출하는데, 그 프로세스가 바로 해당 launchd job이라 자기 자신을 내려
|
|
21
|
+
* SIGTERM으로 죽고 plist 작성에 도달하지 못한다. 그래서 호출 위치에만 의존하지 않고
|
|
22
|
+
* 함수 안에서 isTTY를 직접 확인해 구조적으로 막는다.
|
|
23
|
+
*
|
|
24
|
+
* 의존성은 전부 기본값으로 실제 구현이 배선돼 있다 — 호출부는 인자 없이 부르고 테스트만 대체한다.
|
|
25
|
+
* @param {{interactive?: boolean, readConfig?: Function, probe?: Function, install?: Function,
|
|
26
|
+
* persist?: Function, onError?: Function, onPause?: Function}} [deps]
|
|
27
|
+
* @returns {Promise<boolean>} 등록을 초록으로 단정할 수 없으면 true (확인 불가 또는 재등록 실패)
|
|
28
|
+
*/
|
|
29
|
+
export async function reconcileHookRegistration({
|
|
30
|
+
interactive = Boolean(process.stdin.isTTY),
|
|
31
|
+
readConfig = loadConfig,
|
|
32
|
+
probe = probeHookRegistration,
|
|
33
|
+
install = installHook,
|
|
34
|
+
persist = setHookInstalled,
|
|
35
|
+
onError = showError,
|
|
36
|
+
onPause = pauseForEnter,
|
|
37
|
+
} = {}) {
|
|
38
|
+
if (!interactive) return false; // 헤드리스: 자기 예약을 건드리지 않는다
|
|
39
|
+
if (readConfig().hookInstalled !== true) return false; // 꺼짐(사용자 의사): 조회조차 하지 않는다
|
|
40
|
+
|
|
41
|
+
const state = probe();
|
|
42
|
+
if (state === 'registered') return false;
|
|
43
|
+
if (state === 'unknown') return true; // 판정 불가에서 재등록하면 정상 예약을 깬다
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
persist(true, await install()); // installHook은 멱등이라 그대로 재사용한다
|
|
47
|
+
return false;
|
|
48
|
+
} catch (err) {
|
|
49
|
+
onError('Auto-sync re-registration failed', err.message, ISSUE_HINT);
|
|
50
|
+
await onPause();
|
|
51
|
+
return true; // 실패했으므로 초록으로 단정하지 않는다
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/index.js
CHANGED
|
@@ -5,13 +5,14 @@
|
|
|
5
5
|
* 맨 끝 program.parse()가 argv를 해석해 실제 시동을 건다.
|
|
6
6
|
*/
|
|
7
7
|
import { Command } from 'commander';
|
|
8
|
-
import {
|
|
9
|
-
import { loadToken, saveToken, getOrCreateDeviceId, gistLogin, setHookInstalled,
|
|
10
|
-
import {
|
|
8
|
+
import { parseLogs } from './parser.js';
|
|
9
|
+
import { loadConfig, loadToken, saveToken, getOrCreateDeviceId, gistLogin, setHookInstalled, clearSyncState } from './auth.js';
|
|
10
|
+
import { resetData } from './sync.js';
|
|
11
|
+
import { runSyncFlow } from './sync-flow.js';
|
|
11
12
|
import { installHook, uninstallHook } from './scheduler.js';
|
|
13
|
+
import { reconcileHookRegistration } from './hook-reconcile.js';
|
|
12
14
|
import { promptFirstRun, promptDashboard, promptAdvanced, confirmReset, pauseForEnter, showError } from './ui.js';
|
|
13
15
|
import { accent } from './gradient.js';
|
|
14
|
-
import { localDateOf } from './lib/dates.js';
|
|
15
16
|
import { claudeDirCandidates } from './parser.js';
|
|
16
17
|
import { checkClaudeRetention, applyRetentionSetting, DEFAULT_CLEANUP_DAYS } from './lib/retention.js';
|
|
17
18
|
import { checkLatestVersion, getUpdateInfo } from './lib/update-check.js';
|
|
@@ -125,30 +126,11 @@ async function promptRetentionSetup() {
|
|
|
125
126
|
}
|
|
126
127
|
}
|
|
127
128
|
|
|
128
|
-
/** sync 명령 본체:
|
|
129
|
+
/** sync 명령 본체: 인증·기기 ID를 확인하고 sync 흐름(sync-flow.js)을 실행한다. */
|
|
129
130
|
async function runSync() {
|
|
130
131
|
const token = requireToken();
|
|
131
132
|
const deviceId = getOrCreateDeviceId();
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
// 최초(lastSyncDate 없음)면 fromDate=null → 전체 히스토리 파싱.
|
|
135
|
-
// 이후엔 직전 sync일부터 오늘까지 증분만 파싱.
|
|
136
|
-
const lastSyncDate = getLastSyncDate();
|
|
137
|
-
const fromDate = lastSyncDate ?? null;
|
|
138
|
-
|
|
139
|
-
console.log(`Parsing local JSONL... (${fromDate ?? 'beginning'} ~ ${today})`);
|
|
140
|
-
const records = await parseAll(fromDate);
|
|
141
|
-
console.log(`${records.length} records aggregated (date×model)`);
|
|
142
|
-
if (records.length === 0) {
|
|
143
|
-
console.log('Nothing to sync.');
|
|
144
|
-
saveLastSyncDate(today);
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
console.log('Uploading to server...');
|
|
149
|
-
await syncRecords(API_BASE, token, deviceId, records);
|
|
150
|
-
saveLastSyncDate(today);
|
|
151
|
-
console.log('Done! Badge will update shortly.');
|
|
133
|
+
await runSyncFlow({ apiBase: API_BASE, token, deviceId, parseLogs });
|
|
152
134
|
}
|
|
153
135
|
|
|
154
136
|
/**
|
|
@@ -182,8 +164,8 @@ async function runAdvanced(apiBase, cfg) {
|
|
|
182
164
|
await pauseForEnter();
|
|
183
165
|
return;
|
|
184
166
|
}
|
|
185
|
-
// 2단계: 로컬 워터마크 제거 후 전체 재-sync. 재-sync가 실패해도 서버 삭제는 이미 완료됐음을 명확히 안내.
|
|
186
|
-
|
|
167
|
+
// 2단계: 로컬 워터마크(lastSyncDate·lastSyncVersion) 제거 후 전체 재-sync. 재-sync가 실패해도 서버 삭제는 이미 완료됐음을 명확히 안내.
|
|
168
|
+
clearSyncState();
|
|
187
169
|
console.log(accent(' [OK] Server data reset.') + ' Re-syncing from scratch...');
|
|
188
170
|
try {
|
|
189
171
|
await runSync();
|
|
@@ -239,14 +221,14 @@ program.action(async () => {
|
|
|
239
221
|
}
|
|
240
222
|
}
|
|
241
223
|
|
|
242
|
-
// 인증 이후 메뉴는 최신 설정이 필요하므로 여기서 동적 import (상단 정적 import과 별개)
|
|
243
|
-
const { loadConfig } = await import('./auth.js');
|
|
244
224
|
// TUI 진입 시 npm 최신버전을 1회 조회(짧은 타임아웃). 세션 동안 재사용해 배너/메뉴에 반영한다.
|
|
245
225
|
const latest = await checkLatestVersion();
|
|
226
|
+
// 대시보드 진입 전 1회: 설정만 켜져 있고 OS 스케줄러에서 사라졌으면 여기서 되살린다.
|
|
227
|
+
const hookUnverified = await reconcileHookRegistration();
|
|
246
228
|
// 대시보드 루프: 액션 수행 후 메인으로 복귀, Exit/Esc 에서만 종료
|
|
247
229
|
while (true) {
|
|
248
230
|
const cfg = loadConfig();
|
|
249
|
-
const action = await promptDashboard(cfg, latest);
|
|
231
|
+
const action = await promptDashboard(cfg, latest, hookUnverified);
|
|
250
232
|
if (action === 'exit') break;
|
|
251
233
|
if (action === 'sync') await runSync();
|
|
252
234
|
else if (action === 'install-hook') await runInstallHook();
|
|
@@ -293,9 +275,9 @@ program.command('login [username]')
|
|
|
293
275
|
}
|
|
294
276
|
});
|
|
295
277
|
|
|
296
|
-
// sync: 로컬 Claude/Codex 로그를 파싱해 서버로 업로드한다(배지 갱신). 미인증이면 안내 후 종료.
|
|
278
|
+
// sync: 로컬 Claude Code/Codex/opencode 로그를 파싱해 서버로 업로드한다(배지 갱신). 미인증이면 안내 후 종료.
|
|
297
279
|
program.command('sync')
|
|
298
|
-
.description('Sync local Claude Code
|
|
280
|
+
.description('Sync local token usage (Claude Code, Codex, opencode) to the server')
|
|
299
281
|
.action(async () => {
|
|
300
282
|
try { await runSync(); }
|
|
301
283
|
catch (err) { showError('Sync failed', err.message); process.exitCode = 1; }
|
package/src/lib/config-schema.js
CHANGED
|
@@ -83,6 +83,7 @@ export function normalizeConfig(raw, today) {
|
|
|
83
83
|
dropIf('token', isJwtShape);
|
|
84
84
|
dropIf('deviceId', isUuid); // 없으면 getOrCreateDeviceId가 재발급
|
|
85
85
|
dropIf('lastSyncDate', (v) => isYmdDate(v, today)); // 없으면 전체 재파싱
|
|
86
|
+
dropIf('lastSyncVersion', (v) => typeof v === 'string' && v !== ''); // 없으면 실행시간 전체 재전송
|
|
86
87
|
dropIf('hookInstalledAt', isIsoDate);
|
|
87
88
|
dropIf('hookInstalled', (v) => typeof v === 'boolean');
|
|
88
89
|
dropIf('claudePaths', isStringArray);
|
package/src/lib/paths.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { join, resolve } from 'path';
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
// AI 도구별 로컬 디렉터리 후보를 우선순위 순으로 만든다.
|
|
4
|
+
// - Claude: 설정(config) 디렉터리. 기본(~/.claude) → XDG($XDG_CONFIG_HOME/claude) → 사용자 지정.
|
|
5
|
+
// - opencode: 데이터(data) 디렉터리. XDG($XDG_DATA_HOME, 기본 ~/.local/share)/opencode → 사용자 지정.
|
|
6
|
+
// (opencode는 세션을 <data>/opencode/storage/... 에 저장하므로 config가 아니라 data home 기준이다.)
|
|
5
7
|
|
|
6
8
|
function expandTilde(p, home) {
|
|
7
9
|
if (p === '~') return home;
|
|
@@ -9,8 +11,30 @@ function expandTilde(p, home) {
|
|
|
9
11
|
return p;
|
|
10
12
|
}
|
|
11
13
|
|
|
14
|
+
// 후보 경로들을 절대경로로 normalize하고 순서를 유지한 채 중복을 제거한다.
|
|
15
|
+
function normalizeUnique(candidates) {
|
|
16
|
+
const seen = new Set();
|
|
17
|
+
const dirs = [];
|
|
18
|
+
for (const candidate of candidates) {
|
|
19
|
+
const normalized = resolve(candidate);
|
|
20
|
+
if (seen.has(normalized)) continue;
|
|
21
|
+
seen.add(normalized);
|
|
22
|
+
dirs.push(normalized);
|
|
23
|
+
}
|
|
24
|
+
return dirs;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 외부 입력(config.json 등)의 customPaths를 검증한다: 문자열 배열만 인정, 틸드는 home으로 확장.
|
|
28
|
+
function cleanCustomPaths(customPaths, home) {
|
|
29
|
+
if (!Array.isArray(customPaths)) return [];
|
|
30
|
+
return customPaths
|
|
31
|
+
.filter((p) => typeof p === 'string' && p.trim())
|
|
32
|
+
.map((p) => expandTilde(p.trim(), home));
|
|
33
|
+
}
|
|
34
|
+
|
|
12
35
|
/**
|
|
13
36
|
* Claude 설정 디렉터리 후보 목록을 우선순위 순으로 반환한다.
|
|
37
|
+
* 순서: 기본(~/.claude) → XDG($XDG_CONFIG_HOME/claude, 기본 ~/.config/claude) → 사용자 지정.
|
|
14
38
|
* @param {{ home: string, xdgConfigHome?: string, customPaths?: string[] }} opts
|
|
15
39
|
* home: 사용자 홈, xdgConfigHome: $XDG_CONFIG_HOME, customPaths: config.json의 claudePaths
|
|
16
40
|
* @returns {string[]} normalize·중복 제거된 절대 경로 목록
|
|
@@ -20,24 +44,28 @@ export function resolveClaudeDirs({ home, xdgConfigHome, customPaths } = {}) {
|
|
|
20
44
|
? xdgConfigHome
|
|
21
45
|
: join(home, '.config');
|
|
22
46
|
|
|
23
|
-
|
|
24
|
-
const custom = Array.isArray(customPaths)
|
|
25
|
-
? customPaths.filter((p) => typeof p === 'string' && p.trim())
|
|
26
|
-
: [];
|
|
27
|
-
|
|
28
|
-
const candidates = [
|
|
47
|
+
return normalizeUnique([
|
|
29
48
|
join(home, '.claude'),
|
|
30
49
|
join(xdgBase, 'claude'),
|
|
31
|
-
...
|
|
32
|
-
];
|
|
50
|
+
...cleanCustomPaths(customPaths, home),
|
|
51
|
+
]);
|
|
52
|
+
}
|
|
33
53
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
54
|
+
/**
|
|
55
|
+
* opencode 데이터 디렉터리 후보 목록을 우선순위 순으로 반환한다.
|
|
56
|
+
* 순서: XDG($XDG_DATA_HOME/opencode, 기본 ~/.local/share/opencode) → 사용자 지정.
|
|
57
|
+
* Claude(config home 기준)와 달리 data home 기준임에 주의.
|
|
58
|
+
* @param {{ home: string, xdgDataHome?: string, customPaths?: string[] }} opts
|
|
59
|
+
* home: 사용자 홈, xdgDataHome: $XDG_DATA_HOME, customPaths: 사용자 지정 opencode 디렉터리
|
|
60
|
+
* @returns {string[]} normalize·중복 제거된 절대 경로 목록
|
|
61
|
+
*/
|
|
62
|
+
export function resolveOpencodeDirs({ home, xdgDataHome, customPaths } = {}) {
|
|
63
|
+
const xdgBase = typeof xdgDataHome === 'string' && xdgDataHome.trim()
|
|
64
|
+
? xdgDataHome
|
|
65
|
+
: join(home, '.local', 'share');
|
|
66
|
+
|
|
67
|
+
return normalizeUnique([
|
|
68
|
+
join(xdgBase, 'opencode'),
|
|
69
|
+
...cleanCustomPaths(customPaths, home),
|
|
70
|
+
]);
|
|
43
71
|
}
|
package/src/lib/retention.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync, renameSync } from 'fs';
|
|
2
|
-
import { join } from 'path';
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from 'fs';
|
|
2
|
+
import { dirname, join } from 'path';
|
|
3
3
|
|
|
4
4
|
// Claude Code의 cleanupPeriodDays(트랜스크립트 보존 기간) 검사·설정.
|
|
5
5
|
// 규칙은 하나: 99999 이상이면 통과, 아니면 동의를 받아 무조건 99999로 설정한다.
|
|
@@ -41,9 +41,9 @@ export function checkClaudeRetention(claudeDirs) {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
|
-
* settings.json의 cleanupPeriodDays를 무조건 99999로 설정한다 (다른 필드 보존, 원자
|
|
44
|
+
* settings.json의 cleanupPeriodDays를 무조건 99999로 설정한다 (다른 필드 보존, 원자 쓰기, 상위 폴더가 없으면 0700으로 생성).
|
|
45
45
|
* @param {string} settingsPath checkClaudeRetention()이 돌려준 settings.json 경로
|
|
46
|
-
* @returns {boolean} 성공 여부 — 손상 JSON이면 수정하지 않고 false
|
|
46
|
+
* @returns {boolean} 성공 여부 — 손상 JSON이면 수정하지 않고 false, 쓰기(폴더 생성 포함)에 실패해도 false
|
|
47
47
|
*/
|
|
48
48
|
export function applyRetentionSetting(settingsPath) {
|
|
49
49
|
let config = {};
|
|
@@ -57,6 +57,7 @@ export function applyRetentionSetting(settingsPath) {
|
|
|
57
57
|
|
|
58
58
|
try {
|
|
59
59
|
const next = { ...config, cleanupPeriodDays: FOREVER_DAYS };
|
|
60
|
+
mkdirSync(dirname(settingsPath), { recursive: true, mode: 0o700 }); // Claude Code가 이 폴더에 대화 기록을 쌓는다
|
|
60
61
|
const tmpPath = settingsPath + '.tmp';
|
|
61
62
|
writeFileSync(tmpPath, JSON.stringify(next, null, 2) + '\n');
|
|
62
63
|
renameSync(tmpPath, settingsPath); // 같은 디렉터리 내 rename = 원자적 교체
|
package/src/parser.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { glob } from 'glob';
|
|
2
2
|
import { homedir } from 'os';
|
|
3
|
-
import {
|
|
4
|
-
import { parseCodexFiles } from './parsers/codex.js';
|
|
5
|
-
import { resolveClaudeDirs } from './lib/paths.js';
|
|
3
|
+
import { resolveClaudeDirs, resolveOpencodeDirs } from './lib/paths.js';
|
|
6
4
|
import { getCustomClaudePaths } from './auth.js';
|
|
5
|
+
import { readLogs } from './runtime/read-logs.js';
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
8
|
* 소스별 파서가 돌려준 항목들을 "날짜|모델" 단위 레코드로 합산한다.
|
|
@@ -41,7 +40,16 @@ export function claudeDirCandidates() {
|
|
|
41
40
|
});
|
|
42
41
|
}
|
|
43
42
|
|
|
44
|
-
|
|
43
|
+
/** opencode 데이터 디렉터리 후보(XDG data home 기준)를 우선순위 순으로 반환한다. */
|
|
44
|
+
export function opencodeDirCandidates() {
|
|
45
|
+
return resolveOpencodeDirs({
|
|
46
|
+
home: homedir(),
|
|
47
|
+
xdgDataHome: process.env.XDG_DATA_HOME,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 세 도구의 로그 경로를 기존 탐색 순서대로 찾는다. */
|
|
52
|
+
async function discoverLogPaths() {
|
|
45
53
|
// 경로가 겹쳐도 파일 간 first-wins dedup이 흡수하므로 순서만 보장하면 된다.
|
|
46
54
|
const claudeFiles = [];
|
|
47
55
|
for (const dir of claudeDirCandidates()) {
|
|
@@ -55,9 +63,33 @@ export async function parseAll(fromDate = null) {
|
|
|
55
63
|
...(await globSorted(`${codexHome}/archived_sessions/**/*.jsonl`)),
|
|
56
64
|
];
|
|
57
65
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
66
|
+
// 최신 opencode는 data home 바로 아래 opencode.db에 저장한다.
|
|
67
|
+
// DB가 있으면 그 파일만 직접 읽고(구버전에서 마이그레이션된 JSON과의 이중 집계 방지),
|
|
68
|
+
// DB가 없는 구버전만 storage/message·part JSON을 읽는다.
|
|
69
|
+
let opencodeDatabase = null;
|
|
70
|
+
const opencodeFiles = [];
|
|
71
|
+
for (const dir of opencodeDirCandidates()) {
|
|
72
|
+
const base = dir.replaceAll('\\', '/');
|
|
73
|
+
if (!opencodeDatabase) {
|
|
74
|
+
[opencodeDatabase = null] = await globSorted(`${base}/opencode.db`);
|
|
75
|
+
}
|
|
76
|
+
if (!opencodeDatabase) {
|
|
77
|
+
opencodeFiles.push(...await globSorted(`${base}/storage/message/**/*.json`));
|
|
78
|
+
opencodeFiles.push(...await globSorted(`${base}/storage/part/**/*.json`));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { claudeFiles, codexFiles, opencodeDatabase, opencodeFiles };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 로그를 한 번 읽어 토큰 레코드와 실행시간을 함께 계산한다.
|
|
87
|
+
* @param {{ tokenFrom: string|null, runtimeFrom: string|null }} range YYYY-MM-DD(포함) 또는 null(전체 기간)
|
|
88
|
+
* @returns {Promise<{ records: Array<{date: string, model: string, inputTok: number, outputTok: number, cacheReadTok: number, cacheCreateTok: number}>,
|
|
89
|
+
* runtimes: Array<{ date: string, source: 'claude'|'codex'|'opencode', model: string, runtimeMs: number }>,
|
|
90
|
+
* allSourcesRead: boolean, failedSources: string[] }>}
|
|
91
|
+
*/
|
|
92
|
+
export async function parseLogs({ tokenFrom = null, runtimeFrom = null } = {}) {
|
|
93
|
+
const { entries, runtimes, allSourcesRead, failedSources } = await readLogs(await discoverLogPaths(), runtimeFrom);
|
|
94
|
+
return { records: aggregateRecords(entries, tokenFrom), runtimes, allSourcesRead, failedSources };
|
|
63
95
|
}
|
package/src/parsers/claude.js
CHANGED
|
@@ -50,9 +50,10 @@ function mergedEntry(existing, usage, timestamp) {
|
|
|
50
50
|
/**
|
|
51
51
|
* Claude 트랜스크립트 파일들을 응답(dedup된 메시지) 단위 usage 목록으로 파싱한다.
|
|
52
52
|
* @param {string[]} files JSONL 파일 경로 목록 (정렬된 순서 = 파일 간 first-wins 기준)
|
|
53
|
+
* @param {((record: object, file: string) => void)|null} [onRecord] 파싱한 줄마다 부르는 수집기(예외를 던지지 않아야 한다)
|
|
53
54
|
* @returns {Promise<Array<{date: string, model: string, inputTok: number, outputTok: number, cacheReadTok: number, cacheCreateTok: number}>>}
|
|
54
55
|
*/
|
|
55
|
-
export async function parseClaudeFiles(files) {
|
|
56
|
+
export async function parseClaudeFiles(files, onRecord = null) {
|
|
56
57
|
const deduped = new Map(); // key → { file, ts, ...entry }
|
|
57
58
|
const noKey = []; // dedup 키를 만들 수 없는 라인은 전부 집계
|
|
58
59
|
|
|
@@ -62,6 +63,7 @@ export async function parseClaudeFiles(files) {
|
|
|
62
63
|
if (!line.trim()) continue;
|
|
63
64
|
try {
|
|
64
65
|
const obj = JSON.parse(line);
|
|
66
|
+
onRecord?.(obj, file);
|
|
65
67
|
if (obj.type !== 'assistant') continue;
|
|
66
68
|
const msg = obj.message;
|
|
67
69
|
if (!msg?.usage) continue;
|
package/src/parsers/codex.js
CHANGED
|
@@ -130,9 +130,10 @@ function resolveIncrement(total, last, previous) {
|
|
|
130
130
|
/**
|
|
131
131
|
* Codex rollout 파일들을 턴 증분 단위 usage 목록으로 파싱한다.
|
|
132
132
|
* @param {string[]} files JSONL 파일 경로 목록 (정렬된 순서 — sessions를 archived보다 먼저)
|
|
133
|
+
* @param {((record: object, file: string) => void)|null} [onRecord] 파싱한 줄마다 부르는 수집기(예외를 던지지 않아야 한다)
|
|
133
134
|
* @returns {Promise<Array<{date: string, model: string, inputTok: number, outputTok: number, cacheReadTok: number, cacheCreateTok: number}>>}
|
|
134
135
|
*/
|
|
135
|
-
export async function parseCodexFiles(files) {
|
|
136
|
+
export async function parseCodexFiles(files, onRecord = null) {
|
|
136
137
|
const entries = [];
|
|
137
138
|
const seenKeys = new Set(); // 파일 간 replay/복제 dedup
|
|
138
139
|
|
|
@@ -158,6 +159,7 @@ export async function parseCodexFiles(files) {
|
|
|
158
159
|
if (!line.trim()) continue;
|
|
159
160
|
try {
|
|
160
161
|
const obj = JSON.parse(line);
|
|
162
|
+
onRecord?.(obj, file);
|
|
161
163
|
const payload = obj.payload;
|
|
162
164
|
if (!payload) continue;
|
|
163
165
|
|