clauddy 1.5.1 → 1.7.0

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
@@ -50,9 +50,13 @@ Plus a welcome **wave** on launch. You can [poke the pet from the terminal](#pla
50
50
 
51
51
  ## Install
52
52
 
53
- macOS (Apple Silicon). Two ways, depending on what you want:
53
+ **macOS (Apple Silicon)** is the first-class build. **Windows (x64)** works too, and **Linux** is next. The `bunx`/`npx` route below runs on all of them today.
54
54
 
55
- ### 1. Install as an app — opens at login (recommended)
55
+ ### macOS
56
+
57
+ Two ways, depending on what you want:
58
+
59
+ #### 1. Install as an app — opens at login (recommended)
56
60
 
57
61
  One command — it downloads the latest release and drops it in `/Applications`:
58
62
 
@@ -64,7 +68,7 @@ curl -fsSL https://raw.githubusercontent.com/renatoaug/claude-usage-monitor/main
64
68
 
65
69
  Why not a normal download? macOS blocks **unsigned** apps downloaded through a browser with a scary *"damaged, move to Trash"* warning — even when they're perfectly safe. It's a false alarm: the only way to silence it is to pay Apple **$99/year** to sign + notarize, which a free hobby app skips. Files fetched with `curl` aren't flagged, so this method simply **lets your Mac open the app** without the block. It then registers in **Login Items** and starts with your Mac — set it and forget it.
66
70
 
67
- ### 2. Run it via `bunx` (no install)
71
+ #### 2. Run it via `bunx` (no install)
68
72
 
69
73
  Needs [Bun](https://bun.sh) (or use `npx` with Node 24):
70
74
 
@@ -74,7 +78,23 @@ bunx clauddy
74
78
 
75
79
  The first run downloads Electron, so give it a moment. Handy for a quick run, but it stays up **only while that command is open** and won't start on its own. Quit it with the **×** button.
76
80
 
77
- > The app keeps its data in `~/.claude-usage-monitor`, regardless of how you run it.
81
+ ### Windows (x64)
82
+
83
+ The quickest path works the same as macOS — with [Bun](https://bun.sh) or Node 24 installed:
84
+
85
+ ```powershell
86
+ bunx clauddy # or: npx clauddy
87
+ ```
88
+
89
+ Prefer a standalone app with no Node/Bun? Grab the **portable zip** (`Clauddy-<version>-win.zip`) from the [latest release](https://github.com/renatoaug/claude-usage-monitor/releases), unzip it anywhere, and run `Clauddy.exe`. Because the app is unsigned, Windows **SmartScreen** shows a "Windows protected your PC" prompt the first time — click **More info → Run anyway**. From then on it starts with Windows.
90
+
91
+ > Windows builds are produced by the **Build** workflow (Actions ▸ Build) — attaching them to every release automatically is on the roadmap.
92
+
93
+ ### Linux
94
+
95
+ Coming soon — the build is already wired for `tar.gz` and AppImage. In the meantime, `bunx clauddy` / `npx clauddy` works today.
96
+
97
+ > The app keeps its data in `~/.claude-usage-monitor`, regardless of platform or how you run it.
78
98
 
79
99
  ## Controls
80
100
 
@@ -95,7 +115,8 @@ Settings saved from the UI live in `~/.claude-usage-monitor/config.json`, so you
95
115
  ```jsonc
96
116
  {
97
117
  "alerts": true, // macOS notifications on/off
98
- "alertThresholds": [80, 95], // notify when session/week cross these %
118
+ "alertThresholds": [80, 95], // notify when session/week cross these % (two levels)
119
+ "fireThreshold": 90, // session % at which the pet catches fire (maxed out stays 100)
99
120
  "pollIntervalMs": 4000, // how often local logs are re-read
100
121
  "activeThresholdMs": 8000, // "working" if Claude was active within this window
101
122
  "sleepThresholdMs": 300000, // "sleeping" after this much idle time (5 min)
package/config.json CHANGED
@@ -2,6 +2,7 @@
2
2
  "_note": "Defaults bundled with the app. User settings are saved to ~/.claude-usage-monitor/config.json. Session/weekly % come from your account (log in via Settings).",
3
3
  "alerts": true,
4
4
  "alertThresholds": [80, 95],
5
+ "fireThreshold": 90,
5
6
  "pollIntervalMs": 4000,
6
7
  "activeThresholdMs": 8000,
7
8
  "sleepThresholdMs": 300000
package/main.js CHANGED
@@ -38,6 +38,7 @@ function publicConfig(c) {
38
38
  weeklyAnchorIso: c.weeklyAnchorIso,
39
39
  alerts: c.alerts,
40
40
  alertThresholds: c.alertThresholds,
41
+ fireThreshold: c.fireThreshold,
41
42
  }
42
43
  }
43
44
 
@@ -49,6 +50,7 @@ function loadConfig() {
49
50
  weeklyAnchorIso: null,
50
51
  alerts: true,
51
52
  alertThresholds: [80, 95],
53
+ fireThreshold: 90, // session % at which the pet catches fire (tired still fixed at 100)
52
54
  pollIntervalMs: 4000,
53
55
  activeThresholdMs: 8000,
54
56
  sleepThresholdMs: 300000,
@@ -101,6 +103,7 @@ function createWindow() {
101
103
  y: workAreaSize.height - H - 24,
102
104
  frame: false,
103
105
  transparent: true,
106
+ backgroundColor: '#00000000', // fully transparent — Windows needs this or the window paints black
104
107
  resizable: false,
105
108
  alwaysOnTop: true,
106
109
  skipTaskbar: true,
@@ -265,6 +268,8 @@ ipcMain.on('save-config', (_e, patch) => {
265
268
  ipcMain.on('quit', () => app.quit())
266
269
 
267
270
  app.whenReady().then(() => {
271
+ // Windows toast notifications need an explicit AppUserModelID to show reliably
272
+ if (process.platform === 'win32') app.setAppUserModelId('app.clauddy')
268
273
  createWindow()
269
274
  // open at login (packaged app only)
270
275
  if (app.isPackaged) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clauddy",
3
- "version": "1.5.1",
3
+ "version": "1.7.0",
4
4
  "description": "A cute desktop pet that tracks your Claude Code usage",
5
5
  "main": "main.js",
6
6
  "bin": {
@@ -19,6 +19,8 @@
19
19
  "start": "electron .",
20
20
  "pack": "node scripts/build-app.js --dir",
21
21
  "dist": "node scripts/build-app.js --mac zip",
22
+ "dist:win": "node scripts/build-app.js --win zip",
23
+ "dist:linux": "node scripts/build-app.js --linux",
22
24
  "icon": "bash build-icon.sh",
23
25
  "gifs": "electron tools/capture/capture.js",
24
26
  "format": "biome format --write .",
@@ -73,6 +75,18 @@
73
75
  "target": "dir",
74
76
  "identity": null,
75
77
  "icon": "build/icon.icns"
78
+ },
79
+ "win": {
80
+ "target": "zip",
81
+ "icon": "build/icon.ico"
82
+ },
83
+ "linux": {
84
+ "target": [
85
+ "tar.gz",
86
+ "AppImage"
87
+ ],
88
+ "category": "Utility",
89
+ "icon": "build/icon.png"
76
90
  }
77
91
  },
78
92
  "dependencies": {
@@ -31,6 +31,43 @@
31
31
 
32
32
  <!-- Claude pixel pet -->
33
33
  <div id="stage">
34
+ <!-- reusable gear shape -->
35
+ <svg width="0" height="0" aria-hidden="true" style="position: absolute">
36
+ <defs>
37
+ <g id="gear-unit">
38
+ <g id="gear-teeth">
39
+ <rect x="10.5" y="0.5" width="3" height="5" rx="0.5" />
40
+ <rect x="10.5" y="18.5" width="3" height="5" rx="0.5" />
41
+ <rect x="0.5" y="10.5" width="5" height="3" rx="0.5" />
42
+ <rect x="18.5" y="10.5" width="5" height="3" rx="0.5" />
43
+ </g>
44
+ <use href="#gear-teeth" transform="rotate(45 12 12)" />
45
+ <circle cx="12" cy="12" r="7.5" />
46
+ <circle cx="12" cy="12" r="2.6" fill="#0a0706" />
47
+ </g>
48
+ </defs>
49
+ </svg>
50
+ <!-- loose gears turning on the ground behind the pet (settings only) -->
51
+ <svg id="gears" viewBox="0 0 216 135" preserveAspectRatio="none">
52
+ <g transform="translate(150 118) scale(1.35)">
53
+ <g>
54
+ <use href="#gear-unit" x="-12" y="-12" />
55
+ <animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="3s" repeatCount="indefinite" />
56
+ </g>
57
+ </g>
58
+ <g transform="translate(188 124) scale(0.78)">
59
+ <g>
60
+ <use href="#gear-unit" x="-12" y="-12" />
61
+ <animateTransform attributeName="transform" type="rotate" from="360" to="0" dur="2.3s" repeatCount="indefinite" />
62
+ </g>
63
+ </g>
64
+ <g transform="translate(118 125) scale(0.6)">
65
+ <g>
66
+ <use href="#gear-unit" x="-12" y="-12" />
67
+ <animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="1.8s" repeatCount="indefinite" />
68
+ </g>
69
+ </g>
70
+ </svg>
34
71
  <svg id="scene" viewBox="0 0 216 135" preserveAspectRatio="none"></svg>
35
72
  <div id="shadow"></div>
36
73
  <div id="pet">
@@ -38,6 +75,21 @@
38
75
  <g id="body"></g>
39
76
  <g id="eyes"></g>
40
77
  <rect id="mouth" x="38" y="54" width="24" height="10" rx="2" />
78
+ <!-- mechanic mode (settings): hard hat + screwdriver -->
79
+ <g id="robot">
80
+ <!-- yellow hard hat: pixel-art bitmap, built in pet.js (buildHat) -->
81
+ <g id="hardhat"></g>
82
+ <!-- screwdriver held at an angle to the side, tapping away -->
83
+ <g id="driver">
84
+ <rect class="dr-tip" x="90" y="39" width="6" height="4" />
85
+ <rect class="dr-shaft" x="91" y="43" width="4" height="19" />
86
+ <rect class="dr-hi" x="91" y="43" width="1.6" height="19" />
87
+ <rect class="dr-collar" x="87" y="61" width="12" height="5" rx="1" />
88
+ <rect class="dr-handle" x="84" y="66" width="18" height="24" rx="8" />
89
+ <rect class="dr-handle-hi" x="86.5" y="69" width="2.6" height="17" rx="1.3" />
90
+ <animateTransform attributeName="transform" type="rotate" values="-32 93 70; -26 93 70; -32 93 70" dur="1s" repeatCount="indefinite" />
91
+ </g>
92
+ </g>
41
93
  </svg>
42
94
  <svg id="zzz" viewBox="0 0 44 44" width="44" height="44">
43
95
  <text x="2" y="34" class="z1">Z</text>
@@ -141,7 +193,7 @@
141
193
  </div>
142
194
 
143
195
  <label class="set-check"><input id="set-alerts" type="checkbox" /> Alerts</label>
144
- <div class="set-hint set-hint-left">macOS notifications when your session or weekly usage crosses these % thresholds.</div>
196
+ <div class="set-hint set-hint-left">A macOS notification when your session or weekly usage crosses each level.</div>
145
197
  <label class="set-field">Alert thresholds (%)
146
198
  <span class="set-two">
147
199
  <span class="num">
@@ -159,6 +211,23 @@
159
211
  </span>
160
212
  </span>
161
213
  </span>
214
+ <span class="set-two set-caps">
215
+ <span class="num-cap">heads-up</span>
216
+ <span class="num-cap">near limit</span>
217
+ </span>
218
+ </label>
219
+ <div class="set-hint set-hint-left">The pet catches fire past this session % (it's maxed out at 100%).</div>
220
+ <label class="set-field">Catch fire at (%)
221
+ <span class="set-two">
222
+ <span class="num">
223
+ <input id="set-fire" type="number" min="1" max="99" />
224
+ <span class="num-spin">
225
+ <button type="button" class="num-btn" data-for="set-fire" data-step="1">▲</button>
226
+ <button type="button" class="num-btn" data-for="set-fire" data-step="-1">▼</button>
227
+ </span>
228
+ </span>
229
+ <span class="num" aria-hidden="true"></span>
230
+ </span>
162
231
  </label>
163
232
  <div class="set-actions">
164
233
  <button id="set-cancel">Cancel</button>
package/renderer/pet.js CHANGED
@@ -40,6 +40,39 @@ const SPRITE = [
40
40
  })
41
41
  })()
42
42
 
43
+ // hard hat (mechanic mode): pixel-art bitmap so it matches the pet's blocky look
44
+ ;(function buildHat() {
45
+ const g = el('hardhat')
46
+ if (!g) return
47
+ // L = highlight, Y = main, D = shadow rim, B = brim
48
+ const HAT = [
49
+ '.....YLLY.....',
50
+ '...YYYLLYYY...',
51
+ '..YYYYLLYYYY..',
52
+ '.YYYYYLLYYYYY.',
53
+ '.DDDDDDDDDDDD.',
54
+ 'BBBBBBBBBBBBBB',
55
+ ]
56
+ const COL = { L: '#ffe27a', Y: '#f5c518', D: '#d99a00', B: '#c98f14' }
57
+ const C = 8 // cell size in svg units
58
+ const cols = HAT[0].length
59
+ const ox = 50 - (cols * C) / 2 // centered on the head (center x = 50)
60
+ const oy = -24 // rises above the head, brim ends just over the eyes
61
+ HAT.forEach((row, r) => {
62
+ for (let c = 0; c < row.length; c++) {
63
+ const ch = row[c]
64
+ if (ch === '.') continue
65
+ const rect = document.createElementNS(SVGNS, 'rect')
66
+ rect.setAttribute('x', ox + c * C)
67
+ rect.setAttribute('y', oy + r * C)
68
+ rect.setAttribute('width', C)
69
+ rect.setAttribute('height', C)
70
+ rect.setAttribute('fill', COL[ch])
71
+ g.appendChild(rect)
72
+ }
73
+ })
74
+ })()
75
+
43
76
  // night scene backdrop (clouds, crescent moon, stars, dotted ground/sky)
44
77
  ;(function buildScene() {
45
78
  const s = el('scene')
@@ -369,12 +402,13 @@ function render(d) {
369
402
  const wkPct = liveOn ? realUsage.week.pct : 0
370
403
  const wkReset = liveOn ? realUsage.week.resetMs : null
371
404
 
405
+ const fireAt = currentConfig.fireThreshold ?? 90
372
406
  let st =
373
407
  liveOn && sessPct >= 100
374
408
  ? 'tired'
375
409
  : d.active
376
410
  ? 'working'
377
- : liveOn && sessPct >= 90
411
+ : liveOn && sessPct >= fireAt
378
412
  ? 'stressed'
379
413
  : d.sleeping
380
414
  ? 'sleeping'
@@ -567,12 +601,32 @@ el('acc-confirm').addEventListener('click', () => {
567
601
  el('acc-logout').addEventListener('click', () => window.api.authLogout())
568
602
 
569
603
  // settings panel
604
+ // snapshot of the editable fields, to tell whether there are unsaved changes
605
+ let settingsBaseline = null
606
+ function snapshotSettings() {
607
+ return JSON.stringify({
608
+ alerts: el('set-alerts').checked,
609
+ t1: el('set-t1').value,
610
+ t2: el('set-t2').value,
611
+ fire: el('set-fire').value,
612
+ })
613
+ }
614
+ function refreshSaveDirty() {
615
+ if (settingsBaseline == null) return
616
+ el('set-save').classList.toggle('dirty', snapshotSettings() !== settingsBaseline)
617
+ }
618
+ function clearSaveDirty() {
619
+ settingsBaseline = snapshotSettings()
620
+ el('set-save').classList.remove('dirty')
621
+ }
570
622
  function populateSettings() {
571
623
  const c = currentConfig || {}
572
624
  el('set-alerts').checked = c.alerts !== false
573
625
  const th = c.alertThresholds || [80, 95]
574
626
  el('set-t1').value = th[0] != null ? th[0] : 80
575
627
  el('set-t2').value = th[1] != null ? th[1] : 95
628
+ el('set-fire').value = c.fireThreshold != null ? c.fireThreshold : 90
629
+ clearSaveDirty() // fields now match the saved config
576
630
  }
577
631
  function openSettings() {
578
632
  document.body.classList.remove('collapsed')
@@ -591,20 +645,30 @@ for (const b of document.querySelectorAll('.num-btn')) {
591
645
  const max = Number(input.max) || 100
592
646
  const next = (Number.parseInt(input.value, 10) || 0) + Number(b.dataset.step)
593
647
  input.value = Math.min(max, Math.max(min, next))
648
+ refreshSaveDirty() // steppers change the value without an 'input' event
594
649
  })
595
650
  }
651
+ // light up Save whenever an editable field changes
652
+ for (const id of ['set-alerts', 'set-t1', 'set-t2', 'set-fire']) {
653
+ el(id).addEventListener('input', refreshSaveDirty)
654
+ el(id).addEventListener('change', refreshSaveDirty)
655
+ }
596
656
  el('set-cancel').addEventListener('click', () => {
597
657
  document.body.classList.remove('settings-open')
658
+ clearSaveDirty()
598
659
  fitSize()
599
660
  })
600
661
  el('set-save').addEventListener('click', () => {
601
662
  const num = (id) => parseFloat(el(id).value)
663
+ const fire = num('set-fire')
602
664
  window.api.saveConfig({
603
665
  alerts: el('set-alerts').checked,
604
666
  alertThresholds: [num('set-t1'), num('set-t2')]
605
667
  .filter((n) => n >= 1 && n <= 100)
606
668
  .sort((a, b) => a - b),
669
+ fireThreshold: fire >= 1 && fire <= 99 ? fire : 90,
607
670
  })
671
+ clearSaveDirty()
608
672
  document.body.classList.remove('settings-open')
609
673
  fitSize()
610
674
  })
@@ -242,6 +242,16 @@ body.settings-open #settings {
242
242
  display: flex;
243
243
  gap: 6px;
244
244
  }
245
+ /* small captions under each threshold box */
246
+ .set-caps {
247
+ margin-top: 3px;
248
+ }
249
+ .num-cap {
250
+ flex: 1;
251
+ font-size: 8.5px;
252
+ color: var(--muted);
253
+ padding-left: 2px;
254
+ }
245
255
  /* custom number stepper */
246
256
  .num {
247
257
  position: relative;
@@ -324,12 +334,26 @@ body.settings-open #settings {
324
334
  background: rgba(255, 255, 255, 0.2);
325
335
  }
326
336
  #set-save {
327
- background: var(--coral);
337
+ background: rgba(217, 119, 87, 0.4); /* calm until there are unsaved changes */
328
338
  color: #fff;
329
339
  }
330
340
  #set-save:hover {
331
341
  background: #e2876a;
332
342
  }
343
+ /* unsaved changes: light up and pulse so you don't forget to save */
344
+ #set-save.dirty {
345
+ background: var(--coral);
346
+ animation: save-pulse 1.6s ease-in-out infinite;
347
+ }
348
+ @keyframes save-pulse {
349
+ 0%,
350
+ 100% {
351
+ box-shadow: 0 0 0 0 rgba(217, 119, 87, 0);
352
+ }
353
+ 50% {
354
+ box-shadow: 0 0 9px 2px rgba(217, 119, 87, 0.6);
355
+ }
356
+ }
333
357
 
334
358
  /* live tag */
335
359
  .live-only {
@@ -667,6 +691,51 @@ body.collapsed #shadow {
667
691
  transform-origin: center;
668
692
  animation: blink 5.5s infinite;
669
693
  }
694
+ /* mechanic mode (settings): hard hat + screwdriver on the pet, loose gears
695
+ turning behind it. Hidden otherwise. */
696
+ #robot {
697
+ opacity: 0;
698
+ pointer-events: none;
699
+ transition: opacity 0.2s ease;
700
+ }
701
+ /* hard hat colors live inline (built pixel-by-pixel in pet.js buildHat) */
702
+ .dr-handle {
703
+ fill: #c0392b;
704
+ }
705
+ .dr-handle-hi {
706
+ fill: #e0685a;
707
+ }
708
+ .dr-collar {
709
+ fill: #9a9a9a;
710
+ }
711
+ .dr-tip,
712
+ .dr-shaft {
713
+ fill: #d0d0d0;
714
+ }
715
+ .dr-hi {
716
+ fill: #f2f2f2;
717
+ }
718
+ #gears {
719
+ position: absolute;
720
+ inset: 0;
721
+ width: 100%;
722
+ height: 100%;
723
+ z-index: 1;
724
+ fill: #7a6a5c;
725
+ opacity: 0;
726
+ pointer-events: none;
727
+ transition: opacity 0.2s ease;
728
+ }
729
+ body.settings-open #robot,
730
+ body.settings-open #gears {
731
+ opacity: 1;
732
+ }
733
+ /* declutter the scene while configuring */
734
+ body.settings-open #zzz,
735
+ body.settings-open #flames,
736
+ body.settings-open #sweat {
737
+ display: none;
738
+ }
670
739
  #mouth {
671
740
  fill: var(--eye);
672
741
  transform-box: fill-box;