tokenphage 2026.7.2-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>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenphage",
3
- "version": "2026.7.2-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",
@@ -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,9 +6,10 @@
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';
@@ -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) {
@@ -239,14 +240,14 @@ program.action(async () => {
239
240
  }
240
241
  }
241
242
 
242
- // 인증 이후 메뉴는 최신 설정이 필요하므로 여기서 동적 import (상단 정적 import과 별개)
243
- const { loadConfig } = await import('./auth.js');
244
243
  // TUI 진입 시 npm 최신버전을 1회 조회(짧은 타임아웃). 세션 동안 재사용해 배너/메뉴에 반영한다.
245
244
  const latest = await checkLatestVersion();
245
+ // 대시보드 진입 전 1회: 설정만 켜져 있고 OS 스케줄러에서 사라졌으면 여기서 되살린다.
246
+ const hookUnverified = await reconcileHookRegistration();
246
247
  // 대시보드 루프: 액션 수행 후 메인으로 복귀, Exit/Esc 에서만 종료
247
248
  while (true) {
248
249
  const cfg = loadConfig();
249
- const action = await promptDashboard(cfg, latest);
250
+ const action = await promptDashboard(cfg, latest, hookUnverified);
250
251
  if (action === 'exit') break;
251
252
  if (action === 'sync') await runSync();
252
253
  else if (action === 'install-hook') await runInstallHook();
@@ -293,9 +294,9 @@ program.command('login [username]')
293
294
  }
294
295
  });
295
296
 
296
- // sync: 로컬 Claude/Codex 로그를 파싱해 서버로 업로드한다(배지 갱신). 미인증이면 안내 후 종료.
297
+ // sync: 로컬 Claude Code/Codex/opencode 로그를 파싱해 서버로 업로드한다(배지 갱신). 미인증이면 안내 후 종료.
297
298
  program.command('sync')
298
- .description('Sync local Claude Code token usage to the server')
299
+ .description('Sync local token usage (Claude Code, Codex, opencode) to the server')
299
300
  .action(async () => {
300
301
  try { await runSync(); }
301
302
  catch (err) { showError('Sync failed', err.message); process.exitCode = 1; }
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
  }
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
@@ -129,9 +129,10 @@ export async function promptFirstRun() {
129
129
  * 인증된 사용자를 위한 대시보드 메뉴.
130
130
  * @param {{ token: string, lastSyncDate: string|null, hookInstalled: boolean, hookMeta: object|null }} cfg
131
131
  * @param {string|null} latest TUI 진입 시 조회한 npm 최신버전(조회 실패/옵트아웃 시 null)
132
+ * @param {boolean} [hookUnverified] OS 스케줄러 등록 여부를 확인할 수 없었으면 true
132
133
  * @returns {'update' | 'sync' | 'install-hook' | 'uninstall-hook' | 'advanced' | 'exit'}
133
134
  */
134
- export async function promptDashboard(cfg, latest) {
135
+ export async function promptDashboard(cfg, latest, hookUnverified = false) {
135
136
  printWelcome();
136
137
 
137
138
  // TUI 진입 시 조회한 npm 최신버전과 현재 버전을 비교해, 다를 때만 민트 배너를 출력한다.
@@ -145,9 +146,14 @@ export async function promptDashboard(cfg, latest) {
145
146
 
146
147
  const username = decodeJwtUsername(cfg.token) ?? '(unknown)';
147
148
  const lastSync = cfg.lastSyncDate ?? 'never';
148
- const hookStatus = cfg.hookInstalled
149
- ? chalk.green(' Auto-sync enabled') + chalk.gray(` (${cfg.hookMeta?.scheduleSpec ?? 'daily 04:00'})`)
150
- : 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'})`);
151
157
 
152
158
  console.log(chalk.green(' Authenticated as ') + chalk.bold(`@${username}`));
153
159
  console.log(chalk.gray(` Last sync: ${lastSync}`));