tokenphage 2026.7.1-BETA → 2026.8.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,7 +5,7 @@
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
 
@@ -20,7 +20,7 @@ Claude Code와 Codex의 토큰 사용량을 모아 GitHub README에 붙일 수
20
20
  GitHub README(이미지를 붙일 수 있는 곳이면 어디든)에 아래 한 줄만 넣으면 됩니다. `kobenlys`를 본인 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/kobenlys)](https://github.com/TOKENPHAGE)
24
24
  ```
25
25
 
26
26
  **결과 ↓**
@@ -32,11 +32,11 @@ 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/>
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
36
  <code>https://api.tokenphage.com/badge/kobenlys?theme=gpu&amp;mode=dark</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/>
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
40
  <code>https://api.tokenphage.com/badge/kobenlys?theme=claude&amp;mode=light</code>
41
41
  </td>
42
42
  </tr>
@@ -54,7 +54,7 @@ npm install -g tokenphage@latest
54
54
 
55
55
  ### ⬆️ 업데이트
56
56
 
57
- CLI를 실행하면(대시보드를 열거나 `sync` 할 때) 새 버전이 있는지 확인합니다. 매번 조회하지는 않고, 과도한 요청을 막기 위해 **최대 하루 한 번**만 npm에 물어봅니다(24시간 캐시). 새 버전이 확인되면 대시보드 맨 위에 안내 배너가 뜨고, 메뉴에 **⬆ Update to …** 항목이 추가됩니다. 이 항목을 고르면 최신 버전이 자동으로 설치됩니다(전역 설치가 아니면 정확한 수동 명령을 안내).
57
+ 대시보드를 열면 새 버전이 있는지 확인합니다. 새 버전이 확인되면 대시보드 맨 위에 안내 배너가 뜨고, 메뉴에 **⬆ Update to …** 항목이 추가됩니다. 이 항목을 고르면 최신 버전이 자동으로 설치됩니다(전역 설치가 아니면 정확한 수동 명령을 안내).
58
58
 
59
59
  직접 올리고 싶다면 언제든 아래 명령을 실행하면 됩니다.
60
60
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenphage",
3
- "version": "2026.7.1-BETA",
3
+ "version": "2026.8.1",
4
4
  "description": "AI 토큰 사용량 GitHub README 뱃지",
5
5
  "author": "kobenlys <kobenlys@gmail.com>",
6
6
  "license": "MIT",
@@ -19,7 +19,8 @@
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",
package/src/auth.js CHANGED
@@ -93,17 +93,6 @@ export function clearLastSyncDate() {
93
93
  saveConfig(rest);
94
94
  }
95
95
 
96
- /** 업데이트 캐시(npm 최신버전·마지막 조회시각)를 반환한다. 없으면 각각 null. */
97
- export function getUpdateCache() {
98
- const cfg = loadConfig();
99
- return { latestVersion: cfg.latestVersion ?? null, lastUpdateCheck: cfg.lastUpdateCheck ?? null };
100
- }
101
-
102
- /** npm 최신버전과 조회시각(ISO)을 다른 설정을 보존한 채 갱신 저장한다. */
103
- export function saveUpdateCache(latestVersion, lastUpdateCheck) {
104
- saveConfig({ ...loadConfig(), latestVersion, lastUpdateCheck });
105
- }
106
-
107
96
  // Gist 소유권 검증용 파일명 — 사용자가 이 이름으로 Gist를 만들어야 한다.
108
97
  export const VERIFICATION_FILE = 'tokenphage.txt';
109
98
 
@@ -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
@@ -6,15 +6,16 @@
6
6
  */
7
7
  import { Command } from 'commander';
8
8
  import { parseAll } from './parser.js';
9
- import { loadToken, saveToken, getOrCreateDeviceId, gistLogin, setHookInstalled, getLastSyncDate, saveLastSyncDate, clearLastSyncDate } from './auth.js';
9
+ import { loadConfig, loadToken, saveToken, getOrCreateDeviceId, gistLogin, setHookInstalled, getLastSyncDate, saveLastSyncDate, clearLastSyncDate } from './auth.js';
10
10
  import { syncRecords, resetData } from './sync.js';
11
11
  import { installHook, uninstallHook } from './scheduler.js';
12
+ import { reconcileHookRegistration } from './hook-reconcile.js';
12
13
  import { promptFirstRun, promptDashboard, promptAdvanced, confirmReset, pauseForEnter, showError } from './ui.js';
13
14
  import { accent } from './gradient.js';
14
15
  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
- import { refreshCache, getUpdateInfo } from './lib/update-check.js';
18
+ import { checkLatestVersion, getUpdateInfo } from './lib/update-check.js';
18
19
  import { runUpdate } from './lib/updater.js';
19
20
  import { confirm } from '@inquirer/prompts';
20
21
  import chalk from 'chalk';
@@ -136,7 +137,7 @@ async function runSync() {
136
137
  const lastSyncDate = getLastSyncDate();
137
138
  const fromDate = lastSyncDate ?? null;
138
139
 
139
- console.log(`Parsing local JSONL... (${fromDate ?? 'beginning'} ~ ${today})`);
140
+ console.log(`Parsing local logs... (${fromDate ?? 'beginning'} ~ ${today})`);
140
141
  const records = await parseAll(fromDate);
141
142
  console.log(`${records.length} records aggregated (date×model)`);
142
143
  if (records.length === 0) {
@@ -149,10 +150,6 @@ async function runSync() {
149
150
  await syncRecords(API_BASE, token, deviceId, records);
150
151
  saveLastSyncDate(today);
151
152
  console.log('Done! Badge will update shortly.');
152
-
153
- // 이미 네트워크를 쓰는 지점이므로 여기서 npm 최신버전 캐시를 갱신한다(TTL 게이트 내장, 실패 무해).
154
- // 04:00 자동 sync가 이 경로로 캐시를 채워, 다음 대시보드 진입 시 업데이트 배너가 뜬다.
155
- await refreshCache(Date.now());
156
153
  }
157
154
 
158
155
  /**
@@ -243,14 +240,14 @@ program.action(async () => {
243
240
  }
244
241
  }
245
242
 
246
- // 인증 이후 메뉴는 최신 설정이 필요하므로 여기서 동적 import (상단 정적 import과 별개)
247
- const { loadConfig } = await import('./auth.js');
248
- // 업데이트 캐시를 백그라운드로 갱신한다(await 안 함 → startup 지연 0). 결과는 다음 렌더에 반영된다.
249
- refreshCache(Date.now()).catch(() => {});
243
+ // TUI 진입 시 npm 최신버전을 1회 조회(짧은 타임아웃). 세션 동안 재사용해 배너/메뉴에 반영한다.
244
+ const latest = await checkLatestVersion();
245
+ // 대시보드 진입 전 1회: 설정만 켜져 있고 OS 스케줄러에서 사라졌으면 여기서 되살린다.
246
+ const hookUnverified = await reconcileHookRegistration();
250
247
  // 대시보드 루프: 액션 수행 후 메인으로 복귀, Exit/Esc 에서만 종료
251
248
  while (true) {
252
249
  const cfg = loadConfig();
253
- const action = await promptDashboard(cfg);
250
+ const action = await promptDashboard(cfg, latest, hookUnverified);
254
251
  if (action === 'exit') break;
255
252
  if (action === 'sync') await runSync();
256
253
  else if (action === 'install-hook') await runInstallHook();
@@ -258,7 +255,7 @@ program.action(async () => {
258
255
  else if (action === 'advanced') await runAdvanced(API_BASE, cfg);
259
256
  else if (action === 'update') {
260
257
  // 업데이트 성공 시 현재 프로세스는 여전히 구버전 코드 → 재실행 안내 후 종료.
261
- const { hasUpdate, latest } = getUpdateInfo(version, cfg);
258
+ const { hasUpdate } = getUpdateInfo(version, latest);
262
259
  if (hasUpdate) {
263
260
  const result = await runUpdate(latest);
264
261
  await pauseForEnter();
@@ -297,9 +294,9 @@ program.command('login [username]')
297
294
  }
298
295
  });
299
296
 
300
- // sync: 로컬 Claude/Codex 로그를 파싱해 서버로 업로드한다(배지 갱신). 미인증이면 안내 후 종료.
297
+ // sync: 로컬 Claude Code/Codex/opencode 로그를 파싱해 서버로 업로드한다(배지 갱신). 미인증이면 안내 후 종료.
301
298
  program.command('sync')
302
- .description('Sync local Claude Code token usage to the server')
299
+ .description('Sync local token usage (Claude Code, Codex, opencode) to the server')
303
300
  .action(async () => {
304
301
  try { await runSync(); }
305
302
  catch (err) { showError('Sync failed', err.message); process.exitCode = 1; }
@@ -84,9 +84,7 @@ export function normalizeConfig(raw, today) {
84
84
  dropIf('deviceId', isUuid); // 없으면 getOrCreateDeviceId가 재발급
85
85
  dropIf('lastSyncDate', (v) => isYmdDate(v, today)); // 없으면 전체 재파싱
86
86
  dropIf('hookInstalledAt', isIsoDate);
87
- dropIf('lastUpdateCheck', isIsoDate);
88
87
  dropIf('hookInstalled', (v) => typeof v === 'boolean');
89
- dropIf('latestVersion', (v) => typeof v === 'string');
90
88
  dropIf('claudePaths', isStringArray);
91
89
  dropIf('hookMeta', (v) => v != null && typeof v === 'object' && !Array.isArray(v));
92
90
 
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,53 +1,19 @@
1
1
  /**
2
- * CLI 업데이트 검사: npm 레지스트리에서 최신버전을 조회해 config.json에 캐시하고,
3
- * 현재 버전과 비교해 업데이트 여부를 판정한다.
2
+ * CLI 업데이트 검사: TUI(대시보드) 진입 시 npm 레지스트리에서 latest를 1회 조회하고,
3
+ * 현재 버전과 문자열이 다르면 업데이트가 있다고 판정한다.
4
4
  *
5
5
  * 설계 원칙
6
- * - 화면(메인 대시보드)은 캐시(getUpdateInfo)만 읽어 즉시 렌더 → startup 지연 0.
7
- * - 네트워크 조회(refreshCache)는 백그라운드/sync 시점에 돌아 캐시를 채우고, 결과는 "다음 렌더"에 반영된다.
8
- * - 어떤 실패(네트워크·404·파싱·옵트아웃)도 조용히 흘려보내 본 기능(대시보드)을 절대 막지 않는다.
6
+ * - update 제안은 대화형 TUI에서만 뜬다. 비대화형(sync·스크립트)은 대상 아님 → 캐시/TTL 불필요.
7
+ * - checkLatestVersion()을 TUI 진입 시 1회 await로 호출(짧은 타임아웃), 결과를 세션 메모리에 담아 배너 판정에 쓴다.
8
+ * - 어떤 실패(네트워크·404·파싱·옵트아웃)도 조용히 null로 흘려보내 대시보드를 절대 막지 않는다.
9
9
  */
10
- import { loadConfig, saveUpdateCache } from '../auth.js';
11
10
 
12
11
  // 배포 패키지명 — 레지스트리 URL과 `npm i -g` 대상에서 공유한다.
13
12
  export const PACKAGE_NAME = 'tokenphage';
14
13
  // npm 레지스트리 latest 매니페스트(공개·무인증). { "version": "", ... } 반환, 미배포면 404.
15
14
  export const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
16
15
 
17
- const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h TTL — 하루 1회만 조회
18
- const FETCH_TIMEOUT_MS = 2500; // 레지스트리 지연이 sync를 끌지 않도록 상한
19
-
20
- /**
21
- * CalVer "YYYY.M.PATCH" 두 버전을 숫자 3파트로 비교한다.
22
- * @returns {-1|0|1} a<b → -1, a==b → 0, a>b → 1. 어느 한쪽이라도 비숫자(dev 등)면 0(=차이 없음)으로 안전 폴백.
23
- */
24
- export function compareVersion(a, b) {
25
- const pa = String(a).split('.').map(Number);
26
- const pb = String(b).split('.').map(Number);
27
- // CalVer는 정확히 3파트여야 한다. 형식이 어긋나면(2파트/4파트/dev 등) 비교 불가로 보고 0(=업데이트 없음) 폴백.
28
- if (pa.length !== 3 || pb.length !== 3) return 0;
29
- for (let i = 0; i < 3; i++) {
30
- const x = pa[i];
31
- const y = pb[i];
32
- if (Number.isNaN(x) || Number.isNaN(y)) return 0; // 비CalVer → 비교 불가 → 업데이트 없음
33
- if (x > y) return 1;
34
- if (x < y) return -1;
35
- }
36
- return 0;
37
- }
38
-
39
- /**
40
- * 마지막 조회 이후 TTL(24h)이 지났는지 판정한다. now는 인자로 주입해 테스트 결정성을 확보한다.
41
- * @param {string|null} lastUpdateCheck 마지막 조회 ISO 시각 (없으면 null)
42
- * @param {number} now 현재 epoch ms
43
- * @returns {boolean} 조회가 필요하면 true
44
- */
45
- export function shouldCheck(lastUpdateCheck, now) {
46
- if (!lastUpdateCheck) return true;
47
- const last = Date.parse(lastUpdateCheck);
48
- if (Number.isNaN(last)) return true; // 손상된 시각 → 재조회
49
- return now - last >= CHECK_INTERVAL_MS;
50
- }
16
+ const FETCH_TIMEOUT_MS = 1500; // TUI 진입 조회 상한 — 오프라인/지연 시 이 시간 후 배너 없이 진행
51
17
 
52
18
  /** 업데이트 알림을 끄는 환경인지. 옵트아웃 env 또는 dev(TOKENPHAGE_API 세팅)면 조회·표시 모두 스킵. */
53
19
  export function isUpdateNotifierDisabled() {
@@ -55,23 +21,27 @@ export function isUpdateNotifierDisabled() {
55
21
  }
56
22
 
57
23
  /**
58
- * 캐시(cfg.latestVersion)와 현재 버전을 비교해 업데이트 정보를 반환한다. 네트워크를 쓰지 않는 동기 함수.
24
+ * 조회한 latest와 현재 버전을 비교해 업데이트 정보를 반환한다. 순수 함수(네트워크·env·캐시 없음).
25
+ * update 액션은 항상 `@latest`를 설치하므로 "설치본 ≠ latest"가 곧 "업데이트하면 바뀜"과 일치한다.
26
+ * 프리릴리스 접미사(-BETA)도 파싱 없이 문자열 비교라 안전하다.
59
27
  * @param {string} currentVersion 현재 설치 버전(package.json)
60
- * @param {object} cfg loadConfig() 결과(또는 동등한 객체)
28
+ * @param {string|null|undefined} latest checkLatestVersion() 결과(조회 실패/옵트아웃 시 null)
61
29
  * @returns {{hasUpdate:boolean, latest?:string, current?:string}}
62
30
  */
63
- export function getUpdateInfo(currentVersion, cfg) {
64
- if (isUpdateNotifierDisabled()) return { hasUpdate: false };
65
- const latest = cfg?.latestVersion;
66
- if (!latest) return { hasUpdate: false };
67
- if (compareVersion(latest, currentVersion) > 0) {
31
+ export function getUpdateInfo(currentVersion, latest) {
32
+ if (latest && latest !== currentVersion) {
68
33
  return { hasUpdate: true, latest, current: currentVersion };
69
34
  }
70
35
  return { hasUpdate: false };
71
36
  }
72
37
 
73
- /** npm 레지스트리에서 latest 버전 문자열을 조회한다. 실패(타임아웃·404·파싱)는 모두 null. */
74
- async function fetchLatestFromNpm() {
38
+ /**
39
+ * npm 레지스트리에서 latest 버전 문자열을 1회 조회한다. TUI 진입 시 한 번 await로 호출해 세션 동안 재사용한다.
40
+ * 옵트아웃/실패(타임아웃·404·파싱)는 모두 null — 절대 throw하지 않는다.
41
+ * @returns {Promise<string|null>}
42
+ */
43
+ export async function checkLatestVersion() {
44
+ if (isUpdateNotifierDisabled()) return null; // 옵트아웃/dev → 조회 스킵
75
45
  const controller = new AbortController();
76
46
  const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
77
47
  try {
@@ -85,20 +55,3 @@ async function fetchLatestFromNpm() {
85
55
  clearTimeout(timer);
86
56
  }
87
57
  }
88
-
89
- /**
90
- * TTL이 지났으면 npm 최신버전을 조회해 config 캐시를 갱신한다. 절대 throw하지 않는다.
91
- * @param {number} now 현재 epoch ms (Date.now()를 주입)
92
- */
93
- export async function refreshCache(now) {
94
- try {
95
- if (isUpdateNotifierDisabled()) return;
96
- const cfg = loadConfig();
97
- if (!shouldCheck(cfg.lastUpdateCheck, now)) return;
98
- const latest = await fetchLatestFromNpm();
99
- if (!latest) return;
100
- saveUpdateCache(latest, new Date(now).toISOString());
101
- } catch {
102
- // 캐시 갱신 실패는 무해 — 다음 기회에 재시도한다.
103
- }
104
- }
package/src/parser.js CHANGED
@@ -2,7 +2,8 @@ import { glob } from 'glob';
2
2
  import { homedir } from 'os';
3
3
  import { parseClaudeFiles } from './parsers/claude.js';
4
4
  import { parseCodexFiles } from './parsers/codex.js';
5
- import { resolveClaudeDirs } from './lib/paths.js';
5
+ import { parseOpencodeFiles } from './parsers/opencode.js';
6
+ import { resolveClaudeDirs, resolveOpencodeDirs } from './lib/paths.js';
6
7
  import { getCustomClaudePaths } from './auth.js';
7
8
 
8
9
  /**
@@ -41,6 +42,14 @@ export function claudeDirCandidates() {
41
42
  });
42
43
  }
43
44
 
45
+ /** opencode 데이터 디렉터리 후보(XDG data home 기준)를 우선순위 순으로 반환한다. */
46
+ export function opencodeDirCandidates() {
47
+ return resolveOpencodeDirs({
48
+ home: homedir(),
49
+ xdgDataHome: process.env.XDG_DATA_HOME,
50
+ });
51
+ }
52
+
44
53
  export async function parseAll(fromDate = null) {
45
54
  // 경로가 겹쳐도 파일 간 first-wins dedup이 흡수하므로 순서만 보장하면 된다.
46
55
  const claudeFiles = [];
@@ -55,9 +64,19 @@ export async function parseAll(fromDate = null) {
55
64
  ...(await globSorted(`${codexHome}/archived_sessions/**/*.jsonl`)),
56
65
  ];
57
66
 
58
- const [claudeEntries, codexEntries] = await Promise.all([
67
+ // opencode는 message(모델·시각)와 part(step-finish 토큰)를 모두 읽어야 조인할 수 있다.
68
+ // parseOpencodeFiles가 내용으로 분류하므로 두 목록을 합쳐 넘긴다(순서 무관).
69
+ const opencodeFiles = [];
70
+ for (const dir of opencodeDirCandidates()) {
71
+ const base = dir.replaceAll('\\', '/');
72
+ opencodeFiles.push(...await globSorted(`${base}/storage/message/**/*.json`));
73
+ opencodeFiles.push(...await globSorted(`${base}/storage/part/**/*.json`));
74
+ }
75
+
76
+ const [claudeEntries, codexEntries, opencodeEntries] = await Promise.all([
59
77
  parseClaudeFiles(claudeFiles),
60
78
  parseCodexFiles(codexFiles),
79
+ parseOpencodeFiles(opencodeFiles),
61
80
  ]);
62
- return aggregateRecords([...claudeEntries, ...codexEntries], fromDate);
81
+ return aggregateRecords([...claudeEntries, ...codexEntries, ...opencodeEntries], fromDate);
63
82
  }
@@ -0,0 +1,65 @@
1
+ import { readFile } from 'fs/promises';
2
+ import { localDateOf } from '../lib/dates.js';
3
+
4
+ // opencode(sst) 세션 저장 파서.
5
+ // opencode는 턴당 assistant 메시지 1개에 스텝마다 tokens를 덮어써(=) 마지막 스텝만 남기므로,
6
+ // 스텝별 토큰이 보존된 step-finish part(storage/part/<messageID>/*.json)를 전부 합산해야 정확하다.
7
+ // 전략:
8
+ // - message(role==='assistant')에서 id→{model, date}를 모으고, step-finish part를 messageID로 조인한다.
9
+ // - 입력 파일 목록에 message/part가 섞여 오고 순서 보장이 없으므로, 두 맵을 모은 뒤 마지막에 조인한다.
10
+ // - 필드 매핑(claude/codex와 정합): opencode getUsage가 input은 캐시 제외로, output은 reasoning 제외로
11
+ // 저장하므로 → inputTok은 그대로, outputTok은 output+reasoning으로 reasoning을 되살린다.
12
+ // cache.write는 cacheCreate로 둔다(opencode는 Anthropic cacheCreation을 여기 담는다).
13
+ // - 손상 파일·부모 없는 part·모델/날짜 없는 메시지는 집계에서 제외한다.
14
+
15
+ // step-finish part의 tokens를 tokenphage 레코드 4필드로 매핑한다. 음수 방어로 max(…,0).
16
+ function entryOf(date, model, tokens) {
17
+ const cache = tokens.cache ?? {};
18
+ return {
19
+ date,
20
+ model,
21
+ inputTok: Math.max(tokens.input ?? 0, 0),
22
+ // opencode는 output에서 reasoning을 미리 뺐으므로 되살려 청구 기준(claude/codex)과 맞춘다.
23
+ outputTok: Math.max(tokens.output ?? 0, 0) + Math.max(tokens.reasoning ?? 0, 0),
24
+ cacheReadTok: Math.max(cache.read ?? 0, 0),
25
+ cacheCreateTok: Math.max(cache.write ?? 0, 0),
26
+ };
27
+ }
28
+
29
+ /**
30
+ * opencode message/part 파일들을 스텝(step-finish) 단위 usage 목록으로 파싱한다.
31
+ * @param {string[]} files 메시지·파트 JSON 파일 경로 목록 (섞여 있어도 됨)
32
+ * @returns {Promise<Array<{date: string, model: string, inputTok: number, outputTok: number, cacheReadTok: number, cacheCreateTok: number}>>}
33
+ */
34
+ export async function parseOpencodeFiles(files) {
35
+ const messageMeta = new Map(); // messageID → { model, date }
36
+ const stepParts = []; // { messageID, tokens }
37
+
38
+ for (const file of files) {
39
+ let obj;
40
+ try {
41
+ obj = JSON.parse(await readFile(file, 'utf8'));
42
+ } catch { continue; } // 읽기 실패·손상 JSON skip
43
+ if (!obj) continue;
44
+
45
+ if (obj.role === 'assistant') {
46
+ // 모델·날짜를 못 만들면 이 메시지의 파트는 귀속 불가 → 맵에 넣지 않는다.
47
+ if (!obj.id || !obj.modelID) continue;
48
+ const date = localDateOf(obj.time?.created);
49
+ if (!date) continue;
50
+ messageMeta.set(obj.id, { model: obj.modelID, date });
51
+ } else if (obj.type === 'step-finish') {
52
+ if (!obj.messageID || !obj.tokens) continue;
53
+ stepParts.push({ messageID: obj.messageID, tokens: obj.tokens });
54
+ }
55
+ // 그 외(user 메시지, 다른 part, session/project 파일)는 무시
56
+ }
57
+
58
+ const entries = [];
59
+ for (const part of stepParts) {
60
+ const meta = messageMeta.get(part.messageID);
61
+ if (!meta) continue; // 부모 메시지 없음/모델·날짜 무효 → skip
62
+ entries.push(entryOf(meta.date, meta.model, part.tokens));
63
+ }
64
+ return entries;
65
+ }
package/src/scheduler.js CHANGED
@@ -493,10 +493,11 @@ function hasCrontab() {
493
493
  // 현재 사용자 crontab 전체를 반환한다. '확정된 빈 crontab'만 ''로 취급하고,
494
494
  // 그 외 읽기 실패는 fail-closed로 throw한다 — 이 값이 crontab 전체 재기록의 base가
495
495
  // 되므로, 애매한 실패를 ''로 흡수하면 사용자의 기존 항목을 통째로 덮어쓸 수 있다.
496
- function readCrontab() {
496
+ // run/timeout은 조회 경로와 테스트를 위한 주입 지점이다 — 기본값이 실제 동작이라 설치 경로는 그대로다.
497
+ function readCrontab(run = execFileSync, timeout = undefined) {
497
498
  try {
498
499
  // maxBuffer 상향: 대형(정상) crontab이 기본 1MB를 넘겨 ENOBUFS로 오인 실패하는 것을 막는다.
499
- return execFileSync('crontab', ['-l'], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
500
+ return run('crontab', ['-l'], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, timeout });
500
501
  } catch (err) {
501
502
  // 빈 crontab: exit≠0 + stderr 'no crontab for <user>' → 이때만 빈 문자열(잃을 항목 없음).
502
503
  const stderr = (err.stderr?.toString() || '').toLowerCase();
@@ -588,6 +589,68 @@ function uninstallLinux() {
588
589
  return { platform: 'linux', removed };
589
590
  }
590
591
 
592
+ // ─── 등록 상태 조회 ───────────────────────────────────────────────────────────
593
+
594
+ // 조회가 멈추면 CLI 진입이 그대로 블로킹된다 (update-check.js의 FETCH_TIMEOUT_MS와 같은 이유).
595
+ const PROBE_TIMEOUT_MS = 3000;
596
+
597
+ /**
598
+ * launchctl 실패를 등록 상태로 분류한다. 113(서비스 없음)만 확정적 미등록이며,
599
+ * 112(도메인 없음 — SSH 등 비 GUI 세션)·ENOENT(명령 부재)·timeout(SIGTERM)은 판정 불가다.
600
+ * 판정 불가를 미등록으로 오해하면 살아있는 예약에 재등록을 걸어 bootout으로 내려버린다.
601
+ * @returns {'missing'|'unknown'}
602
+ */
603
+ export function classifyLaunchctlProbe(err) {
604
+ return err?.status === 113 ? 'missing' : 'unknown';
605
+ }
606
+
607
+ // 설치가 bootstrap gui/<uid>이므로 조회도 같은 도메인을 명시한다.
608
+ // launchctl list는 호출자의 암묵 도메인만 조회해 비 GUI 세션에서 오탐한다.
609
+ function probeMac(run) {
610
+ const uid = userInfo().uid;
611
+ try {
612
+ run('launchctl', ['print', `gui/${uid}/${TASK_LABEL}`],
613
+ { stdio: 'ignore', timeout: PROBE_TIMEOUT_MS });
614
+ return 'registered';
615
+ } catch (err) {
616
+ return classifyLaunchctlProbe(err);
617
+ }
618
+ }
619
+
620
+ // 종료코드만 판정 근거로 쓴다 — 출력 메시지는 현지화·OEM 코드페이지 탓에 파싱할 수 없다.
621
+ function probeWindows(run) {
622
+ try {
623
+ run('schtasks', ['/Query', '/TN', WIN_TASK_NAME],
624
+ { stdio: 'ignore', timeout: PROBE_TIMEOUT_MS, windowsHide: true });
625
+ return 'registered';
626
+ } catch (err) {
627
+ return err.status === 1 ? 'missing' : 'unknown';
628
+ }
629
+ }
630
+
631
+ // readCrontab()을 재사용하고 그 함수의 fail-closed throw만 판정 불가로 흡수한다
632
+ // 조회는 CLI 시작을 블로킹하므로 설치 경로와 달리 타임아웃을 반드시 건넨다.
633
+ function probeLinux(run) {
634
+ try {
635
+ return readCrontab(run, PROBE_TIMEOUT_MS).includes(CRON_BEGIN) ? 'registered' : 'missing';
636
+ } catch {
637
+ return 'unknown';
638
+ }
639
+ }
640
+
641
+ /**
642
+ * 자동 sync 스케줄이 OS 스케줄러에 실제로 등록돼 있는지 조회한다(읽기 전용).
643
+ * 라벨·작업명은 완전 일치로만 찾는다 — 같은 홈에 다른 dev.tokenphage.* 작업이 있을 수 있다.
644
+ * @param {{ platform?: string, run?: Function }} [deps]
645
+ * @returns {'registered'|'missing'|'unknown'}
646
+ */
647
+ export function probeHookRegistration({ platform = process.platform, run = execFileSync } = {}) {
648
+ if (platform === 'darwin') return probeMac(run);
649
+ if (platform === 'win32') return probeWindows(run);
650
+ if (platform === 'linux') return probeLinux(run);
651
+ return 'unknown'; // 미지원 OS는 throw 대신 판정 불가 — 조회 실패로 CLI가 죽지 않게 한다.
652
+ }
653
+
591
654
  // ─── Public API ──────────────────────────────────────────────────────────────
592
655
 
593
656
  /** 현재 OS에 맞는 자동 sync 스케줄을 설치한다 (macOS=launchd / Windows=schtasks / Linux=cron). 그 외 OS는 throw. */
package/src/ui.js CHANGED
@@ -127,14 +127,16 @@ export async function promptFirstRun() {
127
127
 
128
128
  /**
129
129
  * 인증된 사용자를 위한 대시보드 메뉴.
130
- * @param {{ token: string, lastSyncDate: string|null, hookInstalled: boolean, hookMeta: object|null, latestVersion?: string }} cfg
130
+ * @param {{ token: string, lastSyncDate: string|null, hookInstalled: boolean, hookMeta: object|null }} cfg
131
+ * @param {string|null} latest TUI 진입 시 조회한 npm 최신버전(조회 실패/옵트아웃 시 null)
132
+ * @param {boolean} [hookUnverified] OS 스케줄러 등록 여부를 확인할 수 없었으면 true
131
133
  * @returns {'update' | 'sync' | 'install-hook' | 'uninstall-hook' | 'advanced' | 'exit'}
132
134
  */
133
- export async function promptDashboard(cfg) {
135
+ export async function promptDashboard(cfg, latest, hookUnverified = false) {
134
136
  printWelcome();
135
137
 
136
- // 캐시된 npm 최신버전과 현재 버전을 비교해, 새 버전이 있을 때만 민트 배너를 출력한다(네트워크 X).
137
- const update = getUpdateInfo(version, cfg);
138
+ // TUI 진입 시 조회한 npm 최신버전과 현재 버전을 비교해, 다를 때만 민트 배너를 출력한다.
139
+ const update = getUpdateInfo(version, latest);
138
140
  if (update.hasUpdate) {
139
141
  const bar = accent(' ┃ ');
140
142
  console.log(bar + accent(`⬆ Update available v${update.current} → v${update.latest}`));
@@ -144,9 +146,14 @@ export async function promptDashboard(cfg) {
144
146
 
145
147
  const username = decodeJwtUsername(cfg.token) ?? '(unknown)';
146
148
  const lastSync = cfg.lastSyncDate ?? 'never';
147
- const hookStatus = cfg.hookInstalled
148
- ? chalk.green(' Auto-sync enabled') + chalk.gray(` (${cfg.hookMeta?.scheduleSpec ?? 'daily 04:00'})`)
149
- : chalk.yellow(' Auto-sync not configured');
149
+ // 설정 상태를 먼저 보고, 켜진 경우에만 검증 결과를 반영한다
150
+ // (순서를 뒤집으면 방금 auto-sync를 끈 사용자에게도 "enabled"가 남는다).
151
+ // 등록을 확인하지 못했거나 재등록이 실패했으면 초록으로 단정하지 않고 노랑으로 유보한다.
152
+ const hookStatus = !cfg.hookInstalled
153
+ ? chalk.yellow(' Auto-sync not configured')
154
+ : hookUnverified
155
+ ? chalk.yellow(' Auto-sync enabled') + chalk.gray(' (could not verify — see message above)')
156
+ : chalk.green(' Auto-sync enabled') + chalk.gray(` (${cfg.hookMeta?.scheduleSpec ?? 'daily 04:00'})`);
150
157
 
151
158
  console.log(chalk.green(' Authenticated as ') + chalk.bold(`@${username}`));
152
159
  console.log(chalk.gray(` Last sync: ${lastSync}`));