oh-my-hi 0.4.2 → 0.4.4

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.2",
13
+ "version": "0.4.4",
14
14
  "author": {
15
15
  "name": "Jae Sung Park",
16
16
  "email": "alberto.park@gmail.com"
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.4] - 2026-04-04
4
+
5
+ ### Fixed
6
+ - SKILL.md: show explicit error message (`ERROR — generate-dashboard.mjs not found`) and exit 1 when script is not found, instead of silently failing
7
+
8
+ ### Added
9
+ - Tests: `test/firstrun.test.mjs` — 10 tests covering `computeDateRange` logic (empty data, null timestamps, multi-scope aggregation) and SKILL.md bash command correctness
10
+ - Tests: `web-ui.test.mjs` — `showFirstRunBanner` function, `_firstRun`/`_dateRange` references, CSS classes, locale keys
11
+ - Tests: `build.test.mjs` — normal builds must not include `_firstRun` or `_partial` flags
12
+
13
+ ## [0.4.3] - 2026-04-04
14
+
15
+ ### Fixed
16
+ - Plugin path resolution: SKILL.md `find` command now searches `plugins/` cache directory instead of relying on `CLAUDE_PLUGIN_ROOT` (which points to the marketplace mirror, not the versioned install cache with `scripts/`)
17
+
18
+ ### Added
19
+ - First-run completion banner: after full data loads on first run, a green banner shows the parsed date range (e.g. "✅ Full data loaded · Jan 1, 2025 – Apr 4, 2026") with an × close button that only disappears on user click
20
+
3
21
  ## [0.4.2] - 2026-04-03
4
22
 
5
23
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-my-hi",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Claude Code harness insights dashboard",
5
5
  "repository": {
6
6
  "type": "git",
@@ -346,7 +346,7 @@ async function main() {
346
346
 
347
347
  console.log(' [4/4] loading full history (this may take a moment)...');
348
348
  const phase2ScopeData = await collectAllScopes(scopes, { days: 0, cache, cachePath: CACHE_PATH, progress: true });
349
- const phase2Data = buildDataObject(scopes, phase2ScopeData, systemLocale);
349
+ const phase2Data = buildDataObject(scopes, phase2ScopeData, systemLocale, { _firstRun: true, _dateRange: computeDateRange(phase2ScopeData) });
350
350
  writeDataJs(phase2Data, dataPath);
351
351
  openOrRefreshBrowser(indexPath);
352
352
  } else {
@@ -523,6 +523,20 @@ async function collectAllScopes(scopes, { days = 0, cache, cachePath, progress =
523
523
  }
524
524
 
525
525
  /** Build data object from scope data (strips internal _cacheStats) */
526
+ /** Compute min/max date range from all tokenEntries across all scopes */
527
+ function computeDateRange(scopeData) {
528
+ let minTs = null, maxTs = null;
529
+ for (const sdata of Object.values(scopeData)) {
530
+ for (const entry of (sdata?.usage?.tokenEntries || [])) {
531
+ const ts = entry.timestamp;
532
+ if (!ts) continue;
533
+ if (!minTs || ts < minTs) minTs = ts;
534
+ if (!maxTs || ts > maxTs) maxTs = ts;
535
+ }
536
+ }
537
+ return minTs && maxTs ? { from: minTs, to: maxTs } : null;
538
+ }
539
+
526
540
  function buildDataObject(scopes, scopeData, systemLocale, extra = {}) {
527
541
  // Strip _cacheStats from usage data before building output
528
542
  const cleanScopeData = {};
@@ -42,14 +42,13 @@ If the user chooses **enable**, run `--enable-auto`. If they choose **disable**,
42
42
  - **Auto-refresh**: `--enable-auto` registers a Stop hook — rebuilds on every session end
43
43
  - **Browser reuse**: macOS AppleScript tab detection; Windows/Linux fallback to system open
44
44
 
45
- Find the oh-my-hi plugin installation path and run the script:
45
+ Find and run the script (searches plugin cache first, then marketplaces):
46
46
 
47
47
  ```bash
48
- OMH_ROOT=$(dirname "$(find "${CLAUDE_CONFIG_DIR:-$HOME/.claude}" -path "*/oh-my-hi/*/scripts/generate-dashboard.mjs" -print -quit 2>/dev/null)") && node "$OMH_ROOT/generate-dashboard.mjs" $ARGUMENTS
49
- ```
50
-
51
- If `CLAUDE_PLUGIN_ROOT` is available, use it directly:
52
-
53
- ```bash
54
- node "${CLAUDE_PLUGIN_ROOT}/scripts/generate-dashboard.mjs" $ARGUMENTS
48
+ SCRIPT=$(find "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/plugins" -name "generate-dashboard.mjs" -path "*/scripts/generate-dashboard.mjs" -print -quit 2>/dev/null)
49
+ if [ -z "$SCRIPT" ]; then
50
+ echo "oh-my-hi: ERROR — generate-dashboard.mjs not found. Try: /omh --update"
51
+ exit 1
52
+ fi
53
+ node "$SCRIPT" $ARGUMENTS
55
54
  ```
package/templates/app.js CHANGED
@@ -4034,7 +4034,24 @@
4034
4034
  document.body.prepend(banner);
4035
4035
  }
4036
4036
 
4037
+ // ── First-run completion banner ──
4038
+ function showFirstRunBanner() {
4039
+ if (!DATA._firstRun) return;
4040
+ const dr = DATA._dateRange;
4041
+ let dateStr = '';
4042
+ if (dr && dr.from && dr.to) {
4043
+ const fmt = d => new Date(d).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
4044
+ dateStr = ' · ' + fmt(dr.from) + ' – ' + fmt(dr.to);
4045
+ }
4046
+ const banner = document.createElement('div');
4047
+ banner.className = 'firstrun-banner';
4048
+ banner.innerHTML = '<span>' + t('firstRunBannerMsg') + dateStr + '</span>'
4049
+ + '<button class="firstrun-close" onclick="this.parentElement.remove()" title="' + t('close') + '">✕</button>';
4050
+ document.body.prepend(banner);
4051
+ }
4052
+
4037
4053
  // ── Boot ──
4038
4054
  init();
4039
4055
  showPartialBanner();
4056
+ showFirstRunBanner();
4040
4057
  checkDataVersion();
@@ -339,6 +339,8 @@
339
339
  "updateBannerMsg": "⚡ New data detected. Refresh the page to see the latest data.",
340
340
  "updateBannerRefresh": "Refresh",
341
341
  "partialBannerMsg": "Showing last 7 days. Full data is loading in the background\u2026",
342
+ "firstRunBannerMsg": "✅ Full data loaded",
343
+ "close": "Close",
342
344
  "catDescSkills": "Skills are reusable prompt templates invoked via /commands. Provided from the skills/ directory or plugins, each SKILL.md defines name, description, and argument hints in its frontmatter. They can also be auto-invoked via the Skill tool during conversations.",
343
345
  "catDescAgents": "Agents are subprocesses that autonomously handle complex tasks. Defined as markdown in the agents/ directory, they specify model (haiku/sonnet/opus), available tools, and domain expertise. Dispatched via the Agent tool, they run in independent contexts.",
344
346
  "catDescRules": "Rules are instruction files that control Claude's behavior. Stored as markdown files in {{configDir}}/rules/, they are automatically loaded into context in every session. Unlike CLAUDE.md, they allow separation of concerns into individual files.",
@@ -339,6 +339,8 @@
339
339
  "updateBannerMsg": "⚡ 새로운 데이터가 감지되었습니다. 페이지를 새로고침하면 최신 데이터를 확인할 수 있습니다.",
340
340
  "updateBannerRefresh": "새로고침",
341
341
  "partialBannerMsg": "최근 7일 데이터로 표시 중입니다. 전체 데이터를 백그라운드에서 로딩하고 있습니다\u2026",
342
+ "firstRunBannerMsg": "✅ 전체 데이터 파싱이 완료되었습니다",
343
+ "close": "닫기",
342
344
  "catDescSkills": "Skills는 사용자가 /명령어로 호출하는 재사용 가능한 프롬프트 템플릿입니다. skills/ 디렉토리 또는 플러그인을 통해 제공되며, 각 SKILL.md 파일의 frontmatter에 이름·설명·인자 힌트를 정의합니다. Skill 도구를 통해 대화 중 자동으로 호출될 수도 있습니다.",
343
345
  "catDescAgents": "Agents는 복잡한 작업을 자율적으로 수행하는 서브프로세스입니다. agents/ 디렉토리에 마크다운으로 정의하며, 모델(haiku/sonnet/opus), 사용 가능 도구, 전문 영역을 설정할 수 있습니다. Agent 도구로 디스패치되어 독립적인 컨텍스트에서 실행됩니다.",
344
346
  "catDescRules": "Rules는 Claude의 동작을 제어하는 지시 파일입니다. {{configDir}}/rules/ 디렉토리에 마크다운 파일로 저장되며, 모든 세션에서 자동으로 컨텍스트에 로드됩니다. CLAUDE.md와 달리 개별 파일로 분리하여 관심사를 구분할 수 있습니다.",
@@ -1829,7 +1829,8 @@ body.dark .bb-tooltip .value {
1829
1829
 
1830
1830
  /* ── Top Banners (shared base) ── */
1831
1831
  .update-banner,
1832
- .partial-banner {
1832
+ .partial-banner,
1833
+ .firstrun-banner {
1833
1834
  position: fixed;
1834
1835
  top: 0;
1835
1836
  left: var(--sidebar-width);
@@ -2055,6 +2056,31 @@ body.dark .bb-tooltip .value {
2055
2056
  background: #e67700;
2056
2057
  }
2057
2058
 
2059
+ .firstrun-banner {
2060
+ gap: 12px;
2061
+ background: #2f9e44;
2062
+ }
2063
+
2064
+ .firstrun-banner span {
2065
+ flex: 1;
2066
+ }
2067
+
2068
+ .firstrun-close {
2069
+ background: rgba(255,255,255,0.2);
2070
+ border: 1px solid rgba(255,255,255,0.3);
2071
+ color: #fff;
2072
+ padding: 2px 8px;
2073
+ border-radius: var(--radius-sm);
2074
+ cursor: pointer;
2075
+ font-size: 13px;
2076
+ line-height: 1.5;
2077
+ flex-shrink: 0;
2078
+ }
2079
+
2080
+ .firstrun-close:hover {
2081
+ background: rgba(255,255,255,0.35);
2082
+ }
2083
+
2058
2084
  .partial-spinner {
2059
2085
  display: inline-block;
2060
2086
  width: 14px;
@@ -78,6 +78,16 @@ describe('Build', () => {
78
78
  // Test runs from the git repo, so _devBuild should be true
79
79
  assert.equal(data._devBuild, true);
80
80
  });
81
+
82
+ it('should NOT have _firstRun flag in normal --data-only build', () => {
83
+ // _firstRun is only set during the first full run (no cache)
84
+ // After that, subsequent builds must not carry the flag
85
+ assert.equal(data._firstRun, undefined, '_firstRun must be absent in cached builds');
86
+ });
87
+
88
+ it('should NOT have _partial flag in normal --data-only build', () => {
89
+ assert.equal(data._partial, undefined, '_partial must be absent in full builds');
90
+ });
81
91
  });
82
92
 
83
93
  describe('index.html', () => {
@@ -0,0 +1,131 @@
1
+ import { describe, it, before } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import { fileURLToPath } from 'url';
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+ const ROOT = path.resolve(__dirname, '..');
9
+
10
+ // ── computeDateRange (inline — not exported, so logic is replicated here) ──
11
+ // Keep in sync with scripts/generate-dashboard.mjs:computeDateRange
12
+ function computeDateRange(scopeData) {
13
+ let minTs = null, maxTs = null;
14
+ for (const sdata of Object.values(scopeData)) {
15
+ for (const entry of (sdata?.usage?.tokenEntries || [])) {
16
+ const ts = entry.timestamp;
17
+ if (!ts) continue;
18
+ if (!minTs || ts < minTs) minTs = ts;
19
+ if (!maxTs || ts > maxTs) maxTs = ts;
20
+ }
21
+ }
22
+ return minTs && maxTs ? { from: minTs, to: maxTs } : null;
23
+ }
24
+
25
+ describe('computeDateRange', () => {
26
+ it('returns null for empty scopeData', () => {
27
+ assert.equal(computeDateRange({}), null);
28
+ });
29
+
30
+ it('returns null when no tokenEntries exist', () => {
31
+ const scopeData = { global: { usage: { tokenEntries: [] } } };
32
+ assert.equal(computeDateRange(scopeData), null);
33
+ });
34
+
35
+ it('returns null when tokenEntries have no timestamp', () => {
36
+ const scopeData = { global: { usage: { tokenEntries: [{ model: 'haiku' }, { model: 'sonnet' }] } } };
37
+ assert.equal(computeDateRange(scopeData), null);
38
+ });
39
+
40
+ it('returns from === to for a single entry', () => {
41
+ const ts = '2025-06-15T10:00:00.000Z';
42
+ const scopeData = { global: { usage: { tokenEntries: [{ timestamp: ts }] } } };
43
+ const result = computeDateRange(scopeData);
44
+ assert.deepEqual(result, { from: ts, to: ts });
45
+ });
46
+
47
+ it('returns correct min/max across multiple entries', () => {
48
+ const scopeData = {
49
+ global: {
50
+ usage: {
51
+ tokenEntries: [
52
+ { timestamp: '2025-03-01T00:00:00.000Z' },
53
+ { timestamp: '2025-01-15T00:00:00.000Z' },
54
+ { timestamp: '2025-06-30T00:00:00.000Z' },
55
+ ],
56
+ },
57
+ },
58
+ };
59
+ const result = computeDateRange(scopeData);
60
+ assert.equal(result.from, '2025-01-15T00:00:00.000Z');
61
+ assert.equal(result.to, '2025-06-30T00:00:00.000Z');
62
+ });
63
+
64
+ it('aggregates across multiple scopes', () => {
65
+ const scopeData = {
66
+ global: { usage: { tokenEntries: [{ timestamp: '2025-03-01T00:00:00.000Z' }] } },
67
+ proj1: { usage: { tokenEntries: [{ timestamp: '2024-11-01T00:00:00.000Z' }] } },
68
+ proj2: { usage: { tokenEntries: [{ timestamp: '2025-08-20T00:00:00.000Z' }] } },
69
+ };
70
+ const result = computeDateRange(scopeData);
71
+ assert.equal(result.from, '2024-11-01T00:00:00.000Z');
72
+ assert.equal(result.to, '2025-08-20T00:00:00.000Z');
73
+ });
74
+
75
+ it('skips scopes with missing usage', () => {
76
+ const scopeData = {
77
+ global: { usage: { tokenEntries: [{ timestamp: '2025-05-10T00:00:00.000Z' }] } },
78
+ noUsage: {},
79
+ nullUsage: { usage: null },
80
+ };
81
+ const result = computeDateRange(scopeData);
82
+ assert.deepEqual(result, { from: '2025-05-10T00:00:00.000Z', to: '2025-05-10T00:00:00.000Z' });
83
+ });
84
+
85
+ it('skips entries with falsy timestamp', () => {
86
+ const scopeData = {
87
+ global: {
88
+ usage: {
89
+ tokenEntries: [
90
+ { timestamp: null },
91
+ { timestamp: '' },
92
+ { timestamp: '2025-04-04T00:00:00.000Z' },
93
+ { timestamp: undefined },
94
+ ],
95
+ },
96
+ },
97
+ };
98
+ const result = computeDateRange(scopeData);
99
+ assert.deepEqual(result, { from: '2025-04-04T00:00:00.000Z', to: '2025-04-04T00:00:00.000Z' });
100
+ });
101
+ });
102
+
103
+ // ── SKILL.md bash command validation ──
104
+
105
+ describe('SKILL.md', () => {
106
+ let skillContent;
107
+ before(() => {
108
+ skillContent = fs.readFileSync(path.join(ROOT, 'skills', 'omh', 'SKILL.md'), 'utf-8');
109
+ });
110
+
111
+ it('should search plugins directory broadly (not rely on CLAUDE_PLUGIN_ROOT)', () => {
112
+ assert.ok(
113
+ skillContent.includes('plugins') && skillContent.includes('generate-dashboard.mjs'),
114
+ 'bash command searches plugins directory for the script',
115
+ );
116
+ assert.ok(
117
+ !skillContent.includes('CLAUDE_PLUGIN_ROOT'),
118
+ 'CLAUDE_PLUGIN_ROOT removed — it points to marketplace mirror, not install cache',
119
+ );
120
+ });
121
+
122
+ it('should output an error message when script is not found', () => {
123
+ assert.ok(skillContent.includes('ERROR'), 'error message present when script not found');
124
+ assert.ok(skillContent.includes('exit 1'), 'exits with non-zero on failure');
125
+ });
126
+
127
+ it('should not use the old broken find pattern with extra wildcard level', () => {
128
+ // old pattern: */oh-my-hi/*/scripts/ — extra * meant it never matched the actual install path
129
+ assert.ok(!skillContent.includes('oh-my-hi/*/scripts'), 'old broken pattern removed');
130
+ });
131
+ });
@@ -129,6 +129,21 @@ describe('Web UI — Templates', () => {
129
129
  assert.ok(js.includes('dayNames'));
130
130
  assert.ok(js.includes("dow"));
131
131
  });
132
+
133
+ it('should have showFirstRunBanner function called at boot', () => {
134
+ assert.ok(js.includes('function showFirstRunBanner'), 'showFirstRunBanner function defined');
135
+ assert.ok(js.includes('showFirstRunBanner()'), 'showFirstRunBanner called at boot');
136
+ });
137
+
138
+ it('should reference _firstRun and _dateRange from DATA', () => {
139
+ assert.ok(js.includes('DATA._firstRun'), '_firstRun flag checked');
140
+ assert.ok(js.includes('DATA._dateRange'), '_dateRange used for date range display');
141
+ });
142
+
143
+ it('should have firstrun close button inline handler', () => {
144
+ assert.ok(js.includes('firstrun-banner'), 'firstrun-banner class used');
145
+ assert.ok(js.includes('firstrun-close'), 'firstrun-close class used');
146
+ });
132
147
  });
133
148
 
134
149
  describe('styles.css', () => {
@@ -175,6 +190,17 @@ describe('Web UI — Templates', () => {
175
190
  assert.ok(css.includes('.cost-trend-label'));
176
191
  });
177
192
 
193
+ it('should have first-run completion banner styles', () => {
194
+ assert.ok(css.includes('.firstrun-banner'), '.firstrun-banner class defined');
195
+ assert.ok(css.includes('.firstrun-close'), '.firstrun-close button class defined');
196
+ });
197
+
198
+ it('should include firstrun-banner in shared banner base rule', () => {
199
+ // .firstrun-banner must share position/layout base with .update-banner and .partial-banner
200
+ const sharedRuleMatch = css.match(/\.update-banner[^{]*\.partial-banner[^{]*\.firstrun-banner|\.firstrun-banner[^{]*\.update-banner|\.update-banner[^{]*\.firstrun-banner/);
201
+ assert.ok(sharedRuleMatch, '.firstrun-banner included in shared banner base selector');
202
+ });
203
+
178
204
  it('should have responsive styles', () => {
179
205
  assert.ok(css.includes('@media'));
180
206
  assert.ok(css.includes('max-width: 768px'));
@@ -207,6 +233,7 @@ describe('Web UI — Templates', () => {
207
233
  'compareToggle', 'comparePrev',
208
234
  'unusedCleanupTipTitle', 'unusedCleanupTipDetail',
209
235
  'cacheTipLowHitTitle', 'cacheTipGoodTitle', 'cacheTipHighCreationTitle', 'cacheTipNoSessionTitle',
236
+ 'firstRunBannerMsg', 'close',
210
237
  ];
211
238
  for (const key of requiredKeys) {
212
239
  assert.ok(en[key], `missing en key: ${key}`);