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 CHANGED
@@ -5,22 +5,22 @@
5
5
 
6
6
  > 먹어치운 토큰, 이젠 README에 기록하세요!
7
7
 
8
- Claude Code와 Codex의 토큰 사용량을 모아 GitHub README에 붙일 수 있는 배지로 만들어 줍니다.
8
+ Claude Code, Codex, opencode의 토큰 사용량을 모아 GitHub README에 붙일 수 있는 배지로 만들어 줍니다.
9
9
 
10
10
  ---
11
11
 
12
12
  ## 📋 요구 사항
13
13
 
14
- - **Node.js 20.17 이상**
14
+ - **Node.js 22.14 이상**
15
15
  - macOS 또는 Windows (자동 동기화 사용 시)
16
16
  - GitHub 계정
17
17
 
18
18
  ## 🖼️ 이런 배지가 만들어져요
19
19
 
20
- GitHub README(이미지를 붙일 수 있는 곳이면 어디든)에 아래 한 줄만 넣으면 됩니다. `kobenlys`를 본인 GitHub 아이디로 바꾸세요.
20
+ GitHub README(이미지를 붙일 수 있는 곳이면 어디든)에 아래 한 줄만 넣으면 됩니다. `<your-github-name>`을 본인 GitHub 아이디로 바꾸세요.
21
21
 
22
22
  ```markdown
23
- [![TokenPhage](https://api.tokenphage.com/badge/kobenlys)](https://github.com/TOKENPHAGE/tokenphage-api)
23
+ [![Tokenphage](https://api.tokenphage.com/badge/<your-github-name>)](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/tokenphage-api"><img src="https://api.tokenphage.com/badge/kobenlys?theme=gpu&amp;mode=dark" alt="TokenPhage gpu dark 배지"></a><br/>
36
- <code>https://api.tokenphage.com/badge/kobenlys?theme=gpu&amp;mode=dark</code>
35
+ <a href="https://github.com/TOKENPHAGE"><img src="https://api.tokenphage.com/badge/kobenlys?theme=gpu&amp;mode=dark" alt="TokenPhage gpu dark 배지"></a><br/>
36
+ <code>[![Tokenphage](https://api.tokenphage.com/badge/&lt;your-github-name&gt;?theme=gpu&amp;mode=dark)](https://github.com/TOKENPHAGE)</code>
37
37
  </td>
38
38
  <td align="center">
39
- <a href="https://github.com/TOKENPHAGE/tokenphage-api"><img src="https://api.tokenphage.com/badge/kobenlys?theme=claude&amp;mode=light" alt="TokenPhage claude light 배지"></a><br/>
40
- <code>https://api.tokenphage.com/badge/kobenlys?theme=claude&amp;mode=light</code>
39
+ <a href="https://github.com/TOKENPHAGE"><img src="https://api.tokenphage.com/badge/kobenlys?theme=claude&amp;mode=light" alt="TokenPhage claude light 배지"></a><br/>
40
+ <code>[![Tokenphage](https://api.tokenphage.com/badge/&lt;your-github-name&gt;?theme=claude&amp;mode=light)](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>[![Tokenphage](https://api.tokenphage.com/badge/&lt;your-github-name&gt;?theme=grass-claude)](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&amp;mode=dark" alt="TokenPhage grass-claude dark 배지"></a><br/>
58
+ <code>[![Tokenphage](https://api.tokenphage.com/badge/&lt;your-github-name&gt;?theme=grass-claude&amp;mode=dark)](https://github.com/TOKENPHAGE)</code>
41
59
  </td>
42
60
  </tr>
43
61
  </table>
44
62
 
45
- > 🎨 URL 뒤에 옵션을 붙일 수 있어요 — 테마 `?theme=gpu`(기본) 또는 `?theme=claude`, 색상 `?mode=light`(기본) 또는 `?mode=dark`.
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.7.2-BETA",
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": "^20.17.0 || ^22.13.0 || >=23.5.0"
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
- /** 마지막 sync 완료 날짜(YYYY-MM-DD)를 반환한다. 없으면 null. */
83
- export function getLastSyncDate() { return loadConfig().lastSyncDate ?? null; }
84
-
85
- /** 마지막 sync 날짜를 저장한다. 다음 sync는 이 날짜 이후만 증분 파싱한다. */
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
- // Node 18+ 전역 fetch 사용 (node-fetch 의존성 불필요).
117
- const res = await fetch(url, {
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 { parseAll } from './parser.js';
9
- import { loadToken, saveToken, getOrCreateDeviceId, gistLogin, setHookInstalled, getLastSyncDate, saveLastSyncDate, clearLastSyncDate } from './auth.js';
10
- import { syncRecords, resetData } from './sync.js';
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 명령 본체: 로컬 로그를 파싱(최초=전체 / 이후=증분)해 서버로 업로드하고, 마지막 sync 날짜를 갱신한다. */
129
+ /** sync 명령 본체: 인증·기기 ID를 확인하고 sync 흐름(sync-flow.js)을 실행한다. */
129
130
  async function runSync() {
130
131
  const token = requireToken();
131
132
  const deviceId = getOrCreateDeviceId();
132
- const today = localDateOf(new Date());
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
- clearLastSyncDate();
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 token usage to the server')
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; }
@@ -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
- // Claude 설정 디렉터리 후보를 우선순위 순으로 만든다.
4
- // 순서: 기본(~/.claude) → XDG($XDG_CONFIG_HOME/claude, 기본 ~/.config/claude) → 사용자 지정.
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
- // 외부 입력(config.json) 검증: 문자열 배열만 인정, 그 외는 통째로 무시
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
- ...custom.map((p) => expandTilde(p.trim(), home)),
32
- ];
50
+ ...cleanCustomPaths(customPaths, home),
51
+ ]);
52
+ }
33
53
 
34
- const seen = new Set();
35
- const dirs = [];
36
- for (const candidate of candidates) {
37
- const normalized = resolve(candidate);
38
- if (seen.has(normalized)) continue;
39
- seen.add(normalized);
40
- dirs.push(normalized);
41
- }
42
- return dirs;
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
  }
@@ -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 { parseClaudeFiles } from './parsers/claude.js';
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
- export async function parseAll(fromDate = null) {
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
- const [claudeEntries, codexEntries] = await Promise.all([
59
- parseClaudeFiles(claudeFiles),
60
- parseCodexFiles(codexFiles),
61
- ]);
62
- return aggregateRecords([...claudeEntries, ...codexEntries], fromDate);
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
  }
@@ -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;
@@ -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