clauddy 1.29.1 โ†’ 2.0.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
@@ -1,6 +1,6 @@
1
1
  # ๐ŸŸซ Clauddy
2
2
 
3
- A pixel-art desktop pet that keeps an eye on your Claude Code usage: the real session and weekly % from your account, the tokens from your local logs, and a pet that works, sleeps and catches fire along with it. It can follow Codex and Cursor too, and you can dress it as a pumpkin, a ghost or Santa.
3
+ A pixel-art desktop pet that keeps an eye on your Claude Code usage: the real session and weekly % from your account, the tokens from your local logs, and a pet that works, sleeps and catches fire along with it. It can follow Codex and Cursor too, and you can dress it as a wizard, Santa or a gingerbread cookie.
4
4
 
5
5
  <p align="center">
6
6
  <a href="#running">๐Ÿ“š Get started</a> ยท
@@ -14,7 +14,7 @@ https://github.com/user-attachments/assets/be8d339c-ac69-4980-96a7-8e286f8b080f
14
14
 
15
15
  ## Stack
16
16
 
17
- An Electron widget in vanilla JS, with Bun for the scripts and the tests, and Biome for format and lint.
17
+ A native app in Rust with egui, no browser engine: about 1โ€“2% CPU and a 20 MB app. The `clauddy` command on npm is a small launcher that downloads it.
18
18
 
19
19
  More details, with every tool and version, in [`docs/design/01-architecture.md`](docs/design/01-architecture.md#stack).
20
20
 
@@ -22,7 +22,8 @@ More details, with every tool and version, in [`docs/design/01-architecture.md`]
22
22
 
23
23
  - macOS on Apple Silicon (the first-class build), Windows x64 or Linux x64
24
24
  - [Claude Code](https://claude.com/claude-code), whose logs in `~/.claude/projects` give the token counts; a Claude login, done once in Settings, gives the real %
25
- - [Bun](https://bun.sh), or Node 24 for `npx`, only to run it without installing or to work on the code
25
+ - [Bun](https://bun.sh), or Node 18+ for `npx`, only to run it with `bunx clauddy`
26
+ - [Rust](https://rustup.rs), only to work on the code
26
27
  - Optional: [Codex](https://openai.com/codex) or the [Cursor](https://cursor.com) app on the same machine, to follow them too
27
28
 
28
29
  ## Running
@@ -44,14 +45,14 @@ bunx clauddy
44
45
  ## Test
45
46
 
46
47
  ```bash
47
- bun run check # Biome format and lint (autofix)
48
- bun run test # the three test groups, each in its own process
49
- bun run test:coverage # the same, failing under 80% overall or 60% per file
50
- bun run pack # builds dist/mac-arm64/Clauddy.app
51
- ./pet fire # forces a pet state in the running widget (./pet auto to go back)
48
+ cd native
49
+ cargo fmt --check # formatting
50
+ cargo test --release # unit tests and the snapshot gallery
51
+ scripts/bundle.sh # builds target/Clauddy.app (macOS)
52
+ CLAUDDY_STATE=fire cargo run --release # forces a mood: idle, working, fire, tired, sleeping
52
53
  ```
53
54
 
54
- Never run bare `bun test`: the groups under `test/` must each run in their own process ([ADR-002](docs/decisions/ADR-002-three-test-processes.md)). `bun install` turns on a pre-commit hook that formats the staged files and blocks on lint errors. CI runs the coverage gate on every pull request.
55
+ The snapshot gallery renders every screen state into `native/tests/snapshots/`; after an intended visual change, `UPDATE_SNAPSHOTS=1 cargo test --release` rewrites it. At the root, `bun run test` tests the npm launcher, and `bun install` turns on a pre-commit hook that formats it. CI runs all of it on every pull request.
55
56
 
56
57
  ## Releases
57
58
 
package/bin/clauddy.js CHANGED
@@ -1,87 +1,90 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // `clauddy` โ†’ launch the widget (spawns Electron on main.js)
4
- // `clauddy <state>` โ†’ poke the running widget for fun/demo (writes the state
5
- // to the data dir the app watches; the app reacts live)
6
- const path = require('node:path')
3
+ // `clauddy` launches the native app (native/, Rust). The first run of each
4
+ // version downloads that version's release build for this OS into
5
+ // ~/.claude-usage-monitor/bin/<version>/; later runs open it straight away.
6
+ // The app runs on its own: this launcher exits as soon as it has started it.
7
7
  const fs = require('node:fs')
8
8
  const os = require('node:os')
9
+ const path = require('node:path')
10
+ const { spawn, execFileSync } = require('node:child_process')
11
+
12
+ const REPO = 'renatoaug/claude-usage-monitor'
13
+
14
+ // the release asset for an OS, and the file inside it to start
15
+ function assetFor(platform, arch, version) {
16
+ if (platform === 'darwin' && arch === 'arm64')
17
+ return { file: `Clauddy-${version}-arm64-mac.zip`, app: 'Clauddy.app' }
18
+ if (platform === 'win32' && arch === 'x64')
19
+ return { file: `Clauddy-${version}-win-x64.zip`, app: 'Clauddy.exe' }
20
+ if (platform === 'linux' && arch === 'x64')
21
+ return { file: `Clauddy-${version}-linux-x64.tar.gz`, app: path.join('Clauddy', 'clauddy') }
22
+ return undefined
23
+ }
24
+
25
+ function dataDir() {
26
+ return process.env.CLAUDE_CONFIG_DIR
27
+ ? path.join(process.env.CLAUDE_CONFIG_DIR, 'usage-monitor')
28
+ : path.join(os.homedir(), '.claude-usage-monitor')
29
+ }
30
+
31
+ async function install(asset, version, dir) {
32
+ const url = `https://github.com/${REPO}/releases/download/v${version}/${asset.file}`
33
+ console.log(`clauddy: downloading ${asset.file}โ€ฆ`)
34
+ const res = await fetch(url)
35
+ if (!res.ok) throw new Error(`download failed (${res.status}): ${url}`)
36
+ const tmp = `${dir}.tmp`
37
+ fs.rmSync(tmp, { recursive: true, force: true })
38
+ fs.mkdirSync(tmp, { recursive: true })
39
+ const archive = path.join(tmp, asset.file)
40
+ fs.writeFileSync(archive, Buffer.from(await res.arrayBuffer()))
41
+ // ditto keeps the app bundle's signature; tar reads zips on Windows 10+
42
+ if (process.platform === 'darwin') execFileSync('ditto', ['-x', '-k', archive, tmp])
43
+ else execFileSync('tar', ['-xf', archive, '-C', tmp])
44
+ fs.rmSync(archive)
45
+ fs.rmSync(dir, { recursive: true, force: true })
46
+ fs.renameSync(tmp, dir)
47
+ }
9
48
 
10
- // same data dir the app uses (respects CLAUDE_CONFIG_DIR for multi-account)
11
- const dataDir = process.env.CLAUDE_CONFIG_DIR
12
- ? path.join(process.env.CLAUDE_CONFIG_DIR, 'usage-monitor')
13
- : path.join(os.homedir(), '.claude-usage-monitor')
49
+ // older versions' builds are dead weight once a newer one is in
50
+ function prune(root, keep) {
51
+ for (const name of fs.readdirSync(root)) {
52
+ if (name !== keep) fs.rmSync(path.join(root, name), { recursive: true, force: true })
53
+ }
54
+ }
14
55
 
15
- const STATES = [
16
- 'fire',
17
- 'sleeping',
18
- 'working',
19
- 'tired',
20
- 'idle',
21
- 'poke',
22
- 'celebrate',
23
- 'say',
24
- 'auto',
25
- 'clear',
26
- // activity states (what Claude Code is doing)
27
- 'editing',
28
- 'reading',
29
- 'planning',
30
- 'running',
31
- 'researching',
32
- 'delegating',
33
- 'waiting',
34
- ]
35
- // `clauddy say <kind>` previews one of the pet's remarks
36
- const REMARKS = [
37
- 'greeting',
38
- 'fire',
39
- 'reset',
40
- 'maxed',
41
- 'welcome',
42
- 'streak',
43
- 'record',
44
- 'codex',
45
- 'cursor',
46
- ]
47
- const arg = process.argv[2]
48
- const kind = arg === 'say' ? process.argv[3] || 'greeting' : undefined
56
+ async function main() {
57
+ const arg = process.argv[2]
58
+ if (arg === '--help' || arg === '-h') {
59
+ console.log(
60
+ 'clauddy โ€” a desktop pet that tracks your Claude Code usage\n\nUsage:\n clauddy launch the app',
61
+ )
62
+ return
63
+ }
64
+ const { version } = require('../package.json')
65
+ const asset = assetFor(process.platform, process.arch, version)
66
+ if (!asset) {
67
+ console.error(
68
+ `clauddy: no build for ${process.platform}-${process.arch} (macOS arm64, Windows x64, Linux x64)`,
69
+ )
70
+ process.exit(1)
71
+ }
72
+ const root = path.join(dataDir(), 'bin')
73
+ const dir = path.join(root, version)
74
+ const app = path.join(dir, asset.app)
75
+ if (!fs.existsSync(app)) {
76
+ await install(asset, version, dir)
77
+ prune(root, version)
78
+ }
79
+ const [cmd, args] = process.platform === 'darwin' ? ['open', [app]] : [app, []]
80
+ spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref()
81
+ }
49
82
 
50
- if (!arg) {
51
- // no argument โ†’ launch the app
52
- const { spawn } = require('node:child_process')
53
- const electron = require('electron')
54
- const child = spawn(electron, [path.join(__dirname, '..', 'main.js')], { stdio: 'inherit' })
55
- child.on('close', (code) => process.exit(code ?? 0))
56
- } else if (arg === '--help' || arg === '-h') {
57
- console.log(
58
- [
59
- 'clauddy โ€” a cute desktop pet that tracks your Claude Code usage',
60
- '',
61
- 'Usage:',
62
- ' clauddy launch the widget',
63
- ' clauddy <state> poke the running widget (just for fun)',
64
- ' clauddy say [kind] make the pet say one of its remarks',
65
- '',
66
- 'States: fire, sleeping, working, tired, idle, poke, celebrate, auto',
67
- `Remarks: ${REMARKS.join(', ')}`,
68
- '(the widget must be running for a state to show)',
69
- ].join('\n'),
70
- )
71
- } else if (kind && !REMARKS.includes(kind)) {
72
- console.error(`clauddy: unknown remark "${kind}"`)
73
- console.error(`try: ${REMARKS.join(', ')}`)
74
- process.exit(1)
75
- } else if (STATES.includes(arg)) {
76
- // write the state for the running app to pick up
77
- fs.mkdirSync(dataDir, { recursive: true })
78
- fs.writeFileSync(
79
- path.join(dataDir, 'debug.json'),
80
- `${JSON.stringify({ state: arg, kind, t: Date.now() })}\n`,
81
- )
82
- console.log(`clauddy โ†’ ${arg}${kind ? ` ${kind}` : ''} (the running widget will react)`)
83
- } else {
84
- console.error(`clauddy: unknown command "${arg}"`)
85
- console.error('try: fire, sleeping, working, tired, idle, poke, celebrate, auto โ€” or --help')
86
- process.exit(1)
83
+ if (require.main === module) {
84
+ main().catch((e) => {
85
+ console.error(`clauddy: ${e.message}`)
86
+ process.exit(1)
87
+ })
87
88
  }
89
+
90
+ module.exports = { assetFor }
package/package.json CHANGED
@@ -1,38 +1,18 @@
1
1
  {
2
2
  "name": "clauddy",
3
- "desktopName": "clauddy.desktop",
4
- "version": "1.29.1",
5
- "description": "A cute desktop pet that tracks your Claude Code usage",
6
- "main": "main.js",
3
+ "version": "2.0.0",
4
+ "description": "A desktop pet that tracks your Claude Code usage",
7
5
  "bin": {
8
6
  "clauddy": "bin/clauddy.js"
9
7
  },
10
8
  "files": [
11
- "bin",
12
- "main.js",
13
- "preload.js",
14
- "usage.js",
15
- "auth.js",
16
- "accounts.js",
17
- "codex.js",
18
- "cursor.js",
19
- "reminders.js",
20
- "config.json",
21
- "renderer"
9
+ "bin"
22
10
  ],
23
11
  "scripts": {
24
- "start": "electron .",
25
- "pack": "node scripts/build-app.js --dir",
26
- "dist": "node scripts/build-app.js --mac zip --publish never",
27
- "dist:win": "node scripts/build-app.js --win zip --publish never",
28
- "dist:linux": "node scripts/build-app.js --linux --publish never",
29
- "icon": "bash build-icon.sh",
30
- "gifs": "electron tools/capture/capture.js",
31
- "test": "bun test test/unit/ && bun test test/main/ && bun test test/dom/",
32
- "test:coverage": "node scripts/check-coverage.js",
33
- "format": "biome format --write .",
34
- "lint": "biome lint .",
12
+ "test": "bun test test/",
35
13
  "check": "biome check --write .",
14
+ "lint": "biome lint .",
15
+ "format": "biome format --write .",
36
16
  "prepare": "git config core.hooksPath .githooks || true"
37
17
  },
38
18
  "author": "Renato",
@@ -51,7 +31,6 @@
51
31
  "usage",
52
32
  "monitor",
53
33
  "desktop-pet",
54
- "electron",
55
34
  "macos",
56
35
  "menubar",
57
36
  "widget"
@@ -61,62 +40,11 @@
61
40
  },
62
41
  "packageManager": "bun@1.3.14",
63
42
  "engines": {
64
- "node": ">=24"
65
- },
66
- "build": {
67
- "appId": "app.clauddy",
68
- "afterPack": "scripts/adhoc-sign.js",
69
- "productName": "Clauddy",
70
- "directories": {
71
- "output": "dist"
72
- },
73
- "files": [
74
- "main.js",
75
- "preload.js",
76
- "usage.js",
77
- "auth.js",
78
- "accounts.js",
79
- "codex.js",
80
- "cursor.js",
81
- "reminders.js",
82
- "config.json",
83
- "renderer/**",
84
- "build/trayTemplate.png",
85
- "build/trayTemplate@2x.png",
86
- "build/trayColor.png",
87
- "build/trayColor@2x.png",
88
- "build/icon.png"
89
- ],
90
- "mac": {
91
- "category": "public.app-category.developer-tools",
92
- "target": "dir",
93
- "identity": null,
94
- "icon": "build/icon.icns",
95
- "artifactName": "${productName}-${version}-mac-${arch}.${ext}"
96
- },
97
- "win": {
98
- "target": "zip",
99
- "icon": "build/icon.ico",
100
- "artifactName": "${productName}-${version}-win-${arch}.${ext}"
101
- },
102
- "linux": {
103
- "target": [
104
- "tar.gz",
105
- "AppImage"
106
- ],
107
- "artifactName": "${productName}-${version}-linux-${arch}.${ext}",
108
- "category": "Utility",
109
- "icon": "build/icon.png",
110
- "syncDesktopName": true
111
- }
112
- },
113
- "dependencies": {
114
- "electron": "^42.4.1"
43
+ "node": ">=18"
115
44
  },
116
45
  "devDependencies": {
117
- "@biomejs/biome": "^2.5.0",
118
- "@happy-dom/global-registrator": "^20.11.2",
119
- "electron-builder": "^26.15.3",
120
- "semantic-release": "^25.0.5"
46
+ "@biomejs/biome": "^2.6.6",
47
+ "conventional-changelog-conventionalcommits": "^9.3.1",
48
+ "semantic-release": "^25.0.9"
121
49
  }
122
50
  }
package/accounts.js DELETED
@@ -1,181 +0,0 @@
1
- // Multiple Claude subscriptions in one widget.
2
- //
3
- // An account is a token plus, optionally, the Claude config dir whose logs it
4
- // reads. The first one ("default") keeps the paths the app has always used, so
5
- // an existing install carries over untouched; every extra account gets its own
6
- // folder under `accounts/` for auth.json and alerts.json.
7
- //
8
- // Only the active account is polled โ€” and therefore only it can notify.
9
- const fs = require('node:fs')
10
- const path = require('node:path')
11
- const os = require('node:os')
12
-
13
- const BASE_DIR = process.env.CLAUDE_CONFIG_DIR
14
- ? path.join(process.env.CLAUDE_CONFIG_DIR, 'usage-monitor')
15
- : path.join(os.homedir(), '.claude-usage-monitor')
16
- const FILE = path.join(BASE_DIR, 'accounts.json')
17
- const DEFAULT_ID = 'default'
18
-
19
- function defaults() {
20
- return { active: DEFAULT_ID, accounts: [{ id: DEFAULT_ID, label: null, claudeDir: null }] }
21
- }
22
-
23
- function sane(j) {
24
- const list = (Array.isArray(j?.accounts) ? j.accounts : [])
25
- .filter((a) => a && typeof a.id === 'string' && a.id)
26
- .map((a) => ({
27
- id: a.id,
28
- label: typeof a.label === 'string' && a.label ? a.label : null,
29
- claudeDir: typeof a.claudeDir === 'string' && a.claudeDir ? a.claudeDir : null,
30
- }))
31
- // the default account can be removed like any other, so it is only recreated
32
- // when nothing is left โ€” an empty list would leave the widget with no token
33
- if (!list.length) list.push(defaults().accounts[0])
34
- const active = list.some((a) => a.id === j?.active) ? j.active : list[0].id
35
- return { active, accounts: list }
36
- }
37
-
38
- // re-read on every call instead of caching: the file is a handful of rows, it
39
- // is read only when the account list is shown or changed, and a stale cache
40
- // here would mean the widget polling a token it thinks it still has
41
- function load() {
42
- try {
43
- return sane(JSON.parse(fs.readFileSync(FILE, 'utf8')))
44
- } catch {
45
- return defaults() // first run, or a corrupted file
46
- }
47
- }
48
-
49
- function save(state) {
50
- try {
51
- fs.mkdirSync(BASE_DIR, { recursive: true })
52
- fs.writeFileSync(FILE, JSON.stringify(state, null, 2))
53
- } catch {}
54
- }
55
-
56
- // where an account's own files (auth.json, alerts.json) live
57
- function dataDirOf(id) {
58
- return id === DEFAULT_ID ? BASE_DIR : path.join(BASE_DIR, 'accounts', id)
59
- }
60
-
61
- function list() {
62
- return load().accounts.map((a) => ({ ...a }))
63
- }
64
-
65
- function activeId() {
66
- return load().active
67
- }
68
-
69
- function active() {
70
- const s = load()
71
- return s.accounts.find((a) => a.id === s.active) || s.accounts[0]
72
- }
73
-
74
- function find(s, id) {
75
- return s.accounts.find((a) => a.id === id)
76
- }
77
-
78
- function setActive(id) {
79
- const s = load()
80
- const hit = find(s, id)
81
- if (!hit) return null
82
- s.active = id
83
- save(s)
84
- return hit
85
- }
86
-
87
- // a fresh, logged-out account โ€” the caller switches to it and runs the usual
88
- // browser login, which is what gives it an identity
89
- function add(claudeDir) {
90
- const s = load()
91
- // two clicks inside the same millisecond used to mint the same id, and the
92
- // duplicate then looked like the account we were already on
93
- let id = `acct-${Date.now().toString(36)}`
94
- while (s.accounts.some((a) => a.id === id)) {
95
- id = `acct-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
96
- }
97
- s.accounts.push({ id, label: null, claudeDir: claudeDir || null })
98
- save(s)
99
- return id
100
- }
101
-
102
- // the login flow knows the email; that's a better name than "Account 2"
103
- function label(id, text) {
104
- const s = load()
105
- const a = find(s, id)
106
- if (!a || a.label === (text || null)) return
107
- a.label = text || null
108
- save(s)
109
- }
110
-
111
- function setClaudeDir(id, dir) {
112
- const s = load()
113
- const a = find(s, id)
114
- if (!a) return
115
- a.claudeDir = dir || null
116
- save(s)
117
- }
118
-
119
- // removing an account takes its token with it โ€” leaving a stray auth.json
120
- // behind would be a credential nobody can see or revoke from the UI
121
- function remove(id) {
122
- const s = load()
123
- if (id === s.active) return false // switch away first: the widget is showing it
124
- const i = s.accounts.findIndex((a) => a.id === id)
125
- if (i < 0) return false
126
- if (s.accounts.length < 2) return false // never leave the widget with no account
127
- s.accounts.splice(i, 1)
128
- save(s)
129
- try {
130
- if (id === DEFAULT_ID) {
131
- // its folder is the app's own data dir: drop the credentials, not the
132
- // settings, the account list or the simulator file that live beside them
133
- for (const f of ['auth.json', 'alerts.json'])
134
- fs.rmSync(path.join(dataDirOf(id), f), { force: true })
135
- } else {
136
- fs.rmSync(dataDirOf(id), { recursive: true, force: true })
137
- }
138
- } catch {}
139
- return true
140
- }
141
-
142
- // Folders left behind under `accounts/` by slots that are no longer listed โ€”
143
- // an add abandoned mid-login, mostly, since saving the alert state recreates
144
- // the folder on its way out. Only ever drops a folder with no auth.json in it,
145
- // so a token is never destroyed by a bad read of accounts.json.
146
- function pruneOrphans() {
147
- const root = path.join(BASE_DIR, 'accounts')
148
- let dirs = []
149
- try {
150
- dirs = fs.readdirSync(root, { withFileTypes: true })
151
- } catch {
152
- return 0 // no extra accounts were ever added
153
- }
154
- const known = new Set(list().map((a) => a.id))
155
- let n = 0
156
- for (const d of dirs) {
157
- if (!d.isDirectory() || known.has(d.name)) continue
158
- const dir = path.join(root, d.name)
159
- if (fs.existsSync(path.join(dir, 'auth.json'))) continue
160
- try {
161
- fs.rmSync(dir, { recursive: true, force: true })
162
- n++
163
- } catch {}
164
- }
165
- return n
166
- }
167
-
168
- module.exports = {
169
- BASE_DIR,
170
- DEFAULT_ID,
171
- dataDirOf,
172
- list,
173
- active,
174
- activeId,
175
- setActive,
176
- add,
177
- label,
178
- setClaudeDir,
179
- remove,
180
- pruneOrphans,
181
- }