oh-my-hi 0.4.0 → 0.4.2

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.
@@ -10,7 +10,7 @@
10
10
  {
11
11
  "name": "oh-my-hi",
12
12
  "description": "Visual dashboard for Claude Code harness — usage/token analysis of skills, agents, plugins, hooks, memory, MCP servers, rules, and principles",
13
- "version": "0.4.0",
13
+ "version": "0.4.2",
14
14
  "author": {
15
15
  "name": "Jae Sung Park",
16
16
  "email": "alberto.park@gmail.com"
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.2] - 2026-04-03
4
+
5
+ ### Changed
6
+ - Progress output improved: step-numbered messages (`[1/3]`, `[1/4]`) replace generic lines; first-run vs normal-run messaging differentiated
7
+ - In-place progress bar (`█░` style) rendered during file collection
8
+ - `collectAllScopes` accepts `progress` flag to enable/disable bar rendering
9
+ - Update check now queries **GitHub tags** first (primary distribution channel), with npm registry as fallback
10
+
11
+ ### Fixed
12
+ - Progress bar newline flushed after collection completes (no broken terminal output)
13
+ - Update check now compares versions numerically; pre-publish local versions no longer trigger spurious "downgrade" attempts
14
+ - `--update` now runs `git fetch --tags` on the marketplace cache before calling `claude plugin update`, so stale local caches no longer report "already at latest" when a newer version exists on GitHub
15
+ - Test: replaced flaky mtime comparison with output-based assertion (macOS APFS sub-ms timestamp precision artifact)
16
+
3
17
  ## [0.4.0] - 2026-04-02
4
18
 
5
19
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-my-hi",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Claude Code harness insights dashboard",
5
5
  "repository": {
6
6
  "type": "git",
@@ -64,43 +64,82 @@ async function runUpdate() {
64
64
  const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf-8'));
65
65
  console.log(`oh-my-hi: current version v${pkg.version}`);
66
66
  console.log('oh-my-hi: checking for updates...');
67
+
68
+ // Detect marketplace name and cache dir
69
+ const pluginCacheDir = path.join(CLAUDE_CONFIG_DIR, 'plugins', 'cache');
70
+ let marketplace = 'oh-my-hi';
71
+ if (fs.existsSync(pluginCacheDir)) {
72
+ for (const entry of fs.readdirSync(pluginCacheDir, { withFileTypes: true })) {
73
+ if (!entry.isDirectory() || entry.name.startsWith('temp_')) continue;
74
+ if (fs.existsSync(path.join(pluginCacheDir, entry.name, pkg.name))) {
75
+ marketplace = entry.name;
76
+ break;
77
+ }
78
+ }
79
+ }
80
+
81
+ // Refresh marketplace cache so claude plugin update sees latest tags
82
+ const marketplaceCacheDir = path.join(CLAUDE_CONFIG_DIR, 'plugins', 'marketplaces', marketplace);
83
+ if (fs.existsSync(marketplaceCacheDir)) {
84
+ try {
85
+ execSync('git fetch --tags --quiet', { cwd: marketplaceCacheDir, stdio: 'pipe', timeout: 10000 });
86
+ } catch { /* best effort — offline or not a git repo */ }
87
+ }
88
+
67
89
  try {
68
- const controller = new AbortController();
69
- const timeout = setTimeout(() => controller.abort(), 5000);
70
- const res = await fetch(`https://registry.npmjs.org/${pkg.name}/latest`, {
71
- signal: controller.signal, headers: { 'Accept': 'application/json' },
72
- });
73
- clearTimeout(timeout);
74
- if (!res.ok) throw new Error('registry request failed');
75
- const data = await res.json();
90
+ // Check latest version via GitHub tags API (git-based distribution)
91
+ const repoUrl = pkg.repository?.url || '';
92
+ const ghMatch = repoUrl.match(/github\.com[/:]([^/]+\/[^/.]+)/);
93
+ let latest = null;
94
+
95
+ if (ghMatch) {
96
+ const controller = new AbortController();
97
+ const timeout = setTimeout(() => controller.abort(), 5000);
98
+ const res = await fetch(`https://api.github.com/repos/${ghMatch[1]}/tags`, {
99
+ signal: controller.signal, headers: { 'Accept': 'application/vnd.github+json' },
100
+ });
101
+ clearTimeout(timeout);
102
+ if (res.ok) {
103
+ const tags = await res.json();
104
+ // Find highest semver tag
105
+ for (const tag of tags) {
106
+ const v = tag.name.replace(/^v/, '');
107
+ if (/^\d+\.\d+\.\d+$/.test(v) && (!latest || semverGt(v, latest))) latest = v;
108
+ }
109
+ }
110
+ }
111
+
112
+ // Fallback to npm registry if GitHub API unavailable
113
+ if (!latest) {
114
+ const controller = new AbortController();
115
+ const timeout = setTimeout(() => controller.abort(), 5000);
116
+ const res = await fetch(`https://registry.npmjs.org/${pkg.name}/latest`, {
117
+ signal: controller.signal, headers: { 'Accept': 'application/json' },
118
+ });
119
+ clearTimeout(timeout);
120
+ if (res.ok) {
121
+ const data = await res.json();
122
+ latest = data.version;
123
+ }
124
+ }
125
+
126
+ if (!latest) throw new Error('could not determine latest version');
76
127
 
77
128
  // Cache check result
78
129
  const UPDATE_CHECK_FILE = path.join(OUTPUT, 'cache', '.update-check');
79
130
  try {
80
131
  fs.mkdirSync(path.dirname(UPDATE_CHECK_FILE), { recursive: true });
81
- fs.writeFileSync(UPDATE_CHECK_FILE, JSON.stringify({ timestamp: Date.now(), current: pkg.version, latest: data.version }), 'utf8');
132
+ fs.writeFileSync(UPDATE_CHECK_FILE, JSON.stringify({ timestamp: Date.now(), current: pkg.version, latest }), 'utf8');
82
133
  } catch { /* best effort */ }
83
134
 
84
- if (data.version === pkg.version) {
135
+ if (!semverGt(latest, pkg.version)) {
85
136
  console.log('oh-my-hi: ✅ already up to date');
86
137
  return;
87
138
  }
88
- console.log(`oh-my-hi: v${data.version} available`);
89
- // Detect marketplace name from plugin cache path
90
- const pluginCacheDir = path.join(CLAUDE_CONFIG_DIR, 'plugins', 'cache');
91
- let marketplace = 'oh-my-hi';
92
- if (fs.existsSync(pluginCacheDir)) {
93
- for (const entry of fs.readdirSync(pluginCacheDir, { withFileTypes: true })) {
94
- if (!entry.isDirectory() || entry.name.startsWith('temp_')) continue;
95
- if (fs.existsSync(path.join(pluginCacheDir, entry.name, pkg.name))) {
96
- marketplace = entry.name;
97
- break;
98
- }
99
- }
100
- }
101
- console.log(`oh-my-hi: updating v${pkg.version} → v${data.version}...`);
139
+ console.log(`oh-my-hi: v${latest} available`);
140
+ console.log(`oh-my-hi: updating v${pkg.version} v${latest}...`);
102
141
  execSync(`claude plugin update ${pkg.name}@${marketplace}`, { stdio: 'inherit' });
103
- console.log(`oh-my-hi: ✅ updated to v${data.version}`);
142
+ console.log(`oh-my-hi: ✅ updated to v${latest}`);
104
143
  } catch (e) {
105
144
  if (e.name === 'AbortError') console.log('oh-my-hi: ❌ network timeout');
106
145
  else console.log('oh-my-hi: ❌ update failed —', e.message);
@@ -112,6 +151,17 @@ async function runUpdate() {
112
151
  const SETTINGS_PATH = path.join(CLAUDE_CONFIG_DIR, 'settings.json');
113
152
  const AUTO_HOOK_CMD = `node "${path.join(ROOT, 'scripts', 'generate-dashboard.mjs')}" --data-only`;
114
153
 
154
+ /** Returns true if version a is greater than version b (semver, numeric comparison) */
155
+ function semverGt(a, b) {
156
+ const pa = a.split('.').map(Number);
157
+ const pb = b.split('.').map(Number);
158
+ for (let i = 0; i < 3; i++) {
159
+ if ((pa[i] || 0) > (pb[i] || 0)) return true;
160
+ if ((pa[i] || 0) < (pb[i] || 0)) return false;
161
+ }
162
+ return false;
163
+ }
164
+
115
165
  function readSettings() {
116
166
  if (!fs.existsSync(SETTINGS_PATH)) return {};
117
167
  try { return JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf-8')); } catch { return {}; }
@@ -267,11 +317,7 @@ async function main() {
267
317
  }
268
318
 
269
319
  // ── Full mode (user-initiated /omh) ──
270
- console.log('oh-my-hi: collecting data...');
271
-
272
320
  const scopes = detectScopes(CLAUDE_CONFIG_DIR, extraPaths);
273
- console.log(` scopes: ${scopes.length} detected`);
274
-
275
321
  const systemLocale = detectSystemLocale();
276
322
 
277
323
  // Check cache state to decide progressive mode
@@ -285,30 +331,37 @@ async function main() {
285
331
  }
286
332
 
287
333
  if (!cacheExists && !pendingExists) {
288
- // Progressive mode: no cache at all
289
- console.log('oh-my-hi: progressive modequick preview first...');
334
+ // Progressive mode: no cache at all (first run)
335
+ console.log('oh-my-hi: first rungenerating dashboard from scratch...');
336
+ console.log(` [1/4] scanning ${scopes.length} workspace(s)...`);
290
337
 
291
338
  const cache = {};
292
- const phase1ScopeData = await collectAllScopes(scopes, { days: 7, cache });
339
+ console.log(' [2/4] building 7-day preview...');
340
+ const phase1ScopeData = await collectAllScopes(scopes, { days: 7, cache, progress: true });
293
341
  const phase1Data = buildDataObject(scopes, phase1ScopeData, systemLocale, { _partial: true });
294
342
  writeDataJs(phase1Data, dataPath);
343
+
344
+ console.log(' [3/4] opening browser with preview...');
295
345
  openOrRefreshBrowser(indexPath);
296
- console.log('oh-my-hi: preview ready — loading full data in background...');
297
346
 
298
- const phase2ScopeData = await collectAllScopes(scopes, { days: 0, cache, cachePath: CACHE_PATH });
347
+ console.log(' [4/4] loading full history (this may take a moment)...');
348
+ const phase2ScopeData = await collectAllScopes(scopes, { days: 0, cache, cachePath: CACHE_PATH, progress: true });
299
349
  const phase2Data = buildDataObject(scopes, phase2ScopeData, systemLocale);
300
350
  writeDataJs(phase2Data, dataPath);
301
351
  openOrRefreshBrowser(indexPath);
302
- console.log('oh-my-hi: full data loaded — browser refreshed');
303
352
  } else {
304
353
  // Normal mode: load cache + merge pending + full data
305
- const scopeData = await collectAllScopes(scopes, { days: 0, cachePath: CACHE_PATH });
354
+ console.log('oh-my-hi: collecting data...');
355
+ console.log(` [1/3] scanning ${scopes.length} workspace(s)...`);
356
+ const scopeData = await collectAllScopes(scopes, { days: 0, cachePath: CACHE_PATH, progress: true });
357
+ console.log(' [2/3] building dashboard...');
306
358
  const data = buildDataObject(scopes, scopeData, systemLocale);
307
359
  writeDataJs(data, dataPath);
360
+ console.log(' [3/3] opening browser...');
308
361
  openOrRefreshBrowser(indexPath);
309
362
  }
310
363
 
311
- console.log('oh-my-hi: done');
364
+ console.log('oh-my-hi: done');
312
365
 
313
366
  // Auto-refresh status notice
314
367
  const settings = readSettings();
@@ -323,7 +376,7 @@ async function main() {
323
376
  await checkForUpdate();
324
377
  }
325
378
 
326
- /** Check npm registry for newer version (non-blocking, cached for 24h) */
379
+ /** Check GitHub tags for newer version (non-blocking, cached for 24h) */
327
380
  async function checkForUpdate() {
328
381
  const UPDATE_CHECK_FILE = path.join(OUTPUT, 'cache', '.update-check');
329
382
  try {
@@ -331,7 +384,7 @@ async function checkForUpdate() {
331
384
  if (fs.existsSync(UPDATE_CHECK_FILE)) {
332
385
  const lastCheck = JSON.parse(fs.readFileSync(UPDATE_CHECK_FILE, 'utf8'));
333
386
  if (Date.now() - lastCheck.timestamp < 24 * 60 * 60 * 1000) {
334
- if (lastCheck.latest && lastCheck.latest !== lastCheck.current) {
387
+ if (lastCheck.latest && semverGt(lastCheck.latest, lastCheck.current)) {
335
388
  console.log(`\noh-my-hi: ✨ v${lastCheck.latest} available (current: v${lastCheck.current})`);
336
389
  console.log(' → Update: /omh --update');
337
390
  }
@@ -341,25 +394,48 @@ async function checkForUpdate() {
341
394
 
342
395
  const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf-8'));
343
396
  const current = pkg.version;
397
+ let latest = null;
398
+
399
+ // Check GitHub tags first (primary distribution channel)
400
+ const repoUrl = pkg.repository?.url || '';
401
+ const ghMatch = repoUrl.match(/github\.com[/:]([^/]+\/[^/.]+)/);
402
+ if (ghMatch) {
403
+ const controller = new AbortController();
404
+ const timeout = setTimeout(() => controller.abort(), 3000);
405
+ const res = await fetch(`https://api.github.com/repos/${ghMatch[1]}/tags`, {
406
+ signal: controller.signal, headers: { 'Accept': 'application/vnd.github+json' },
407
+ });
408
+ clearTimeout(timeout);
409
+ if (res.ok) {
410
+ const tags = await res.json();
411
+ for (const tag of tags) {
412
+ const v = tag.name.replace(/^v/, '');
413
+ if (/^\d+\.\d+\.\d+$/.test(v) && (!latest || semverGt(v, latest))) latest = v;
414
+ }
415
+ }
416
+ }
344
417
 
345
- // Fetch latest version from npm (with 3s timeout)
346
- const controller = new AbortController();
347
- const timeout = setTimeout(() => controller.abort(), 3000);
348
- const res = await fetch(`https://registry.npmjs.org/${pkg.name}/latest`, {
349
- signal: controller.signal,
350
- headers: { 'Accept': 'application/json' },
351
- });
352
- clearTimeout(timeout);
418
+ // Fallback to npm registry
419
+ if (!latest) {
420
+ const controller = new AbortController();
421
+ const timeout = setTimeout(() => controller.abort(), 3000);
422
+ const res = await fetch(`https://registry.npmjs.org/${pkg.name}/latest`, {
423
+ signal: controller.signal, headers: { 'Accept': 'application/json' },
424
+ });
425
+ clearTimeout(timeout);
426
+ if (res.ok) {
427
+ const data = await res.json();
428
+ latest = data.version;
429
+ }
430
+ }
353
431
 
354
- if (!res.ok) return;
355
- const data = await res.json();
356
- const latest = data.version;
432
+ if (!latest) return;
357
433
 
358
434
  // Cache the result
359
435
  fs.mkdirSync(path.dirname(UPDATE_CHECK_FILE), { recursive: true });
360
436
  fs.writeFileSync(UPDATE_CHECK_FILE, JSON.stringify({ timestamp: Date.now(), current, latest }), 'utf8');
361
437
 
362
- if (latest && latest !== current) {
438
+ if (semverGt(latest, current)) {
363
439
  console.log(`\noh-my-hi: ✨ v${latest} available (current: v${current})`);
364
440
  console.log(' → Update: /omh --update');
365
441
  }
@@ -385,8 +461,18 @@ function detectSystemLocale() {
385
461
  return systemLocale;
386
462
  }
387
463
 
464
+ /** Render an in-place progress bar to stdout */
465
+ function renderProgressBar(processed, total) {
466
+ if (!total) return;
467
+ const pct = Math.round((processed / total) * 100);
468
+ const width = 25;
469
+ const filled = Math.round((processed / total) * width);
470
+ const bar = '█'.repeat(filled) + '░'.repeat(width - filled);
471
+ process.stdout.write(`\r [${bar}] ${pct}% (${processed}/${total} files)`);
472
+ }
473
+
388
474
  /** Collect all scopes (global + projects) with given options */
389
- async function collectAllScopes(scopes, { days = 0, cache, cachePath } = {}) {
475
+ async function collectAllScopes(scopes, { days = 0, cache, cachePath, progress = false } = {}) {
390
476
  const projectScopes = scopes.filter(s => s.type !== 'global');
391
477
  // Load or reuse cache; reset parse counter for this collection round
392
478
  const sharedCache = cache || (cachePath ? loadTranscriptCache(cachePath) : {});
@@ -398,6 +484,12 @@ async function collectAllScopes(scopes, { days = 0, cache, cachePath } = {}) {
398
484
  }
399
485
 
400
486
  sharedCache._parsed = 0;
487
+ sharedCache._processed = 0;
488
+ sharedCache._total = 0;
489
+ sharedCache._onProgress = progress
490
+ ? () => renderProgressBar(sharedCache._processed, sharedCache._total)
491
+ : undefined;
492
+
401
493
  const usageOpts = { days, cache: sharedCache, cachePath };
402
494
 
403
495
  const [globalData, ...projectResults] = await Promise.all([
@@ -416,6 +508,8 @@ async function collectAllScopes(scopes, { days = 0, cache, cachePath } = {}) {
416
508
  // Update mtime index for lightweight mode
417
509
  if (cachePath) saveMtimeIndex(cachePath, sharedCache);
418
510
 
511
+ if (progress) process.stdout.write('\n');
512
+
419
513
  const totalFiles = Object.keys(sharedCache).filter(k => !k.startsWith('_')).length;
420
514
  const parsed = Math.min(sharedCache._parsed || 0, totalFiles);
421
515
  console.log(` transcripts: ${totalFiles} files (${parsed} parsed, ${totalFiles - parsed} cached)`);
@@ -441,7 +535,9 @@ function buildDataObject(scopes, scopeData, systemLocale, extra = {}) {
441
535
  }
442
536
  }
443
537
  const taskCategories = buildTaskCategories(cleanScopeData);
444
- const isDevBuild = fs.existsSync(path.join(ROOT, '.git'));
538
+ // 스크립트가 ~/.claude 하위(플러그인)에서 실행되면 배포본, 그 외는 dev
539
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
540
+ const isDevBuild = !scriptDir.startsWith(CLAUDE_CONFIG_DIR);
445
541
  return {
446
542
  scopes,
447
543
  scopeData: cleanScopeData,
@@ -405,19 +405,24 @@ export function saveMtimeIndex(cachePath, cache) {
405
405
  * Always parses without cutoff (full data) so cache is reusable across time ranges.
406
406
  */
407
407
  async function cachedParseTranscriptFile(fp, cache) {
408
- let stat;
409
- try { stat = fs.statSync(fp); } catch { return null; }
410
- const cached = cache[fp];
411
- if (cached && cached.mtimeMs === stat.mtimeMs) {
412
- // Full cache hit (has result) or mtime-only stub (skip — already processed)
413
- if (cached.size === stat.size) return cached.result;
414
- // Mtime match but size=0 means mtime-only stub from lightweight mode — skip
415
- if (cached.size === 0) return null;
408
+ try {
409
+ let stat;
410
+ try { stat = fs.statSync(fp); } catch { return null; }
411
+ const cached = cache[fp];
412
+ if (cached && cached.mtimeMs === stat.mtimeMs) {
413
+ // Full cache hit (has result) or mtime-only stub (skip — already processed)
414
+ if (cached.size === stat.size) return cached.result;
415
+ // Mtime match but size=0 means mtime-only stub from lightweight mode — skip
416
+ if (cached.size === 0) return null;
417
+ }
418
+ const result = await parseTranscriptFile(fp, 0);
419
+ cache[fp] = { mtimeMs: stat.mtimeMs, size: stat.size, result, _new: true };
420
+ cache._parsed = (cache._parsed || 0) + 1;
421
+ return result;
422
+ } finally {
423
+ cache._processed = (cache._processed || 0) + 1;
424
+ cache._onProgress?.();
416
425
  }
417
- const result = await parseTranscriptFile(fp, 0);
418
- cache[fp] = { mtimeMs: stat.mtimeMs, size: stat.size, result, _new: true };
419
- cache._parsed = (cache._parsed || 0) + 1;
420
- return result;
421
426
  }
422
427
 
423
428
  /**
@@ -671,6 +676,7 @@ async function parseTranscripts(configDir, cutoffMs, cache = {}) {
671
676
  }
672
677
 
673
678
  // Phase 2: Parse all files in parallel (with cache)
679
+ cache._total = (cache._total || 0) + filePaths.length;
674
680
  const results = await parallelMap(filePaths, fp => cachedParseTranscriptFile(fp, cache));
675
681
 
676
682
  return applyCutoff(mergeTranscriptResults(results.filter(Boolean)), cutoffMs);
@@ -784,6 +790,7 @@ async function parseProjectTranscripts(projDirPath, cutoffMs, cache = {}) {
784
790
  }
785
791
 
786
792
  // Phase 2: Parse all files in parallel (with cache)
793
+ cache._total = (cache._total || 0) + filePaths.length;
787
794
  const results = await parallelMap(filePaths, fp => cachedParseTranscriptFile(fp, cache));
788
795
 
789
796
  return applyCutoff(mergeTranscriptResults(results.filter(Boolean)), cutoffMs);
@@ -208,13 +208,11 @@ describe('Conditional HTML Rebuild', () => {
208
208
  });
209
209
 
210
210
  it('should not rebuild index.html when version matches', () => {
211
- // Get current mtime
212
- const statBefore = fs.statSync(path.join(OUTPUT, 'index.html'));
213
- // Wait 1s to ensure mtime would differ
214
- execSync('sleep 1');
215
- run('--data-only');
216
- const statAfter = fs.statSync(path.join(OUTPUT, 'index.html'));
217
- assert.equal(statBefore.mtimeMs, statAfter.mtimeMs, 'index.html should not be rewritten');
211
+ // Check output rather than mtime: mtime comparison is unreliable on macOS APFS
212
+ // due to sub-millisecond timestamp precision artifacts.
213
+ const output = run('--data-only');
214
+ assert.ok(!output.includes('rebuilding'), 'should not print rebuilding message');
215
+ assert.ok(!output.includes('data structure changed'), 'should not trigger migration rebuild');
218
216
  });
219
217
  });
220
218
 
@@ -323,6 +321,97 @@ describe('Progressive Loading', () => {
323
321
  });
324
322
  });
325
323
 
324
+ // ── Progress Tracking ──
325
+
326
+ describe('Progress Tracking', () => {
327
+ it('_onProgress is called for every file (cache hit and miss)', async () => {
328
+ const tmpDir = fs.mkdtempSync('/tmp/omh-progress-');
329
+ try {
330
+ // Write two minimal JSONL transcript files
331
+ fs.writeFileSync(path.join(tmpDir, 'a.jsonl'), '');
332
+ fs.writeFileSync(path.join(tmpDir, 'b.jsonl'), '');
333
+
334
+ const cache = {};
335
+ let callCount = 0;
336
+ cache._onProgress = () => { callCount++; };
337
+
338
+ await parseUsage(CLAUDE_CONFIG_DIR, 0, tmpDir, { cache });
339
+ assert.ok(callCount >= 2, `_onProgress should be called at least once per file, got ${callCount}`);
340
+ } finally {
341
+ fs.rmSync(tmpDir, { recursive: true });
342
+ }
343
+ });
344
+
345
+ it('_processed equals number of files processed', async () => {
346
+ const tmpDir = fs.mkdtempSync('/tmp/omh-progress-');
347
+ try {
348
+ fs.writeFileSync(path.join(tmpDir, 'a.jsonl'), '');
349
+ fs.writeFileSync(path.join(tmpDir, 'b.jsonl'), '');
350
+ fs.writeFileSync(path.join(tmpDir, 'c.jsonl'), '');
351
+
352
+ const cache = {};
353
+ cache._onProgress = () => {};
354
+ await parseUsage(CLAUDE_CONFIG_DIR, 0, tmpDir, { cache });
355
+
356
+ assert.equal(cache._processed, 3, '_processed should equal file count');
357
+ } finally {
358
+ fs.rmSync(tmpDir, { recursive: true });
359
+ }
360
+ });
361
+
362
+ it('_total is set before files are processed', async () => {
363
+ const tmpDir = fs.mkdtempSync('/tmp/omh-progress-');
364
+ try {
365
+ fs.writeFileSync(path.join(tmpDir, 'a.jsonl'), '');
366
+ fs.writeFileSync(path.join(tmpDir, 'b.jsonl'), '');
367
+
368
+ const cache = {};
369
+ let totalAtFirstCall = null;
370
+ cache._onProgress = () => {
371
+ if (totalAtFirstCall === null) totalAtFirstCall = cache._total;
372
+ };
373
+
374
+ await parseUsage(CLAUDE_CONFIG_DIR, 0, tmpDir, { cache });
375
+ assert.ok(totalAtFirstCall >= 2, `_total should be set before first _onProgress, got ${totalAtFirstCall}`);
376
+ } finally {
377
+ fs.rmSync(tmpDir, { recursive: true });
378
+ }
379
+ });
380
+
381
+ it('_processed increments for cache hits too', async () => {
382
+ const tmpDir = fs.mkdtempSync('/tmp/omh-progress-');
383
+ try {
384
+ fs.writeFileSync(path.join(tmpDir, 'a.jsonl'), '');
385
+
386
+ // First run — cache miss
387
+ const cache = {};
388
+ cache._onProgress = () => {};
389
+ await parseUsage(CLAUDE_CONFIG_DIR, 0, tmpDir, { cache });
390
+ const parsedFirst = cache._parsed;
391
+
392
+ // Second run — cache hit
393
+ cache._processed = 0;
394
+ cache._parsed = 0;
395
+ cache._onProgress = () => {};
396
+ await parseUsage(CLAUDE_CONFIG_DIR, 0, tmpDir, { cache });
397
+
398
+ assert.equal(cache._processed, 1, '_processed should increment even on cache hit');
399
+ assert.equal(cache._parsed, 0, '_parsed should not increment on cache hit');
400
+ assert.equal(parsedFirst, 1, 'first run should have parsed the file');
401
+ } finally {
402
+ fs.rmSync(tmpDir, { recursive: true });
403
+ }
404
+ });
405
+
406
+ it('normal mode output contains progress bar characters', () => {
407
+ // Progress bar is only shown in full (non --data-only) mode
408
+ // Use captured stdout which includes \r-separated progress updates
409
+ const output = run();
410
+ const hasProgressBar = output.includes('[') && output.includes('%') && output.includes('files)');
411
+ assert.ok(hasProgressBar, 'output should contain progress bar ([ ... % ... files))');
412
+ });
413
+ });
414
+
326
415
  // ── findSkillFiles Exclusions ──
327
416
 
328
417
  describe('findSkillFiles exclusions', () => {