clauddy 1.29.1 โ 2.0.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 +11 -9
- package/bin/clauddy.js +82 -79
- package/package.json +10 -82
- package/accounts.js +0 -181
- package/auth.js +0 -300
- package/codex.js +0 -345
- package/config.json +0 -11
- package/cursor.js +0 -278
- package/main.js +0 -1305
- package/preload.js +0 -43
- package/reminders.js +0 -54
- package/renderer/burn.js +0 -98
- package/renderer/companion.js +0 -52
- package/renderer/index.html +0 -527
- package/renderer/motion.css +0 -646
- package/renderer/pet.js +0 -2394
- package/renderer/pets.css +0 -916
- package/renderer/pets.js +0 -579
- package/renderer/style.css +0 -3758
- package/renderer/voice.js +0 -244
- package/usage.js +0 -539
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
|
|
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
|
-
|
|
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
|
|
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,15 @@ bunx clauddy
|
|
|
44
45
|
## Test
|
|
45
46
|
|
|
46
47
|
```bash
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
cd native
|
|
49
|
+
cargo fmt --check # formatting
|
|
50
|
+
cargo clippy --release --all-targets -- -D warnings # lint
|
|
51
|
+
cargo test --release # unit tests and the snapshot gallery
|
|
52
|
+
scripts/bundle.sh # builds target/Clauddy.app (macOS)
|
|
53
|
+
CLAUDDY_STATE=fire cargo run --release # forces a mood: idle, working, fire, tired, sleeping
|
|
52
54
|
```
|
|
53
55
|
|
|
54
|
-
|
|
56
|
+
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
57
|
|
|
56
58
|
## Releases
|
|
57
59
|
|
package/bin/clauddy.js
CHANGED
|
@@ -1,87 +1,90 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
// `clauddy`
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
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
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
'
|
|
18
|
-
|
|
19
|
-
'
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
'
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
'
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
'
|
|
39
|
-
'
|
|
40
|
-
|
|
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 (
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"description": "A cute desktop pet that tracks your Claude Code usage",
|
|
6
|
-
"main": "main.js",
|
|
3
|
+
"version": "2.0.1",
|
|
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
|
-
"
|
|
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": ">=
|
|
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.
|
|
118
|
-
"
|
|
119
|
-
"
|
|
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
|
-
}
|