claude-rpc 0.3.8
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/LICENSE +21 -0
- package/README.md +300 -0
- package/bin/claude-rpc.js +2 -0
- package/config.example.json +67 -0
- package/package.json +53 -0
- package/src/badge.js +144 -0
- package/src/cli.js +765 -0
- package/src/daemon.js +324 -0
- package/src/default-config.js +91 -0
- package/src/format.js +657 -0
- package/src/git.js +74 -0
- package/src/hook.js +169 -0
- package/src/insights.js +138 -0
- package/src/install.js +280 -0
- package/src/languages.js +114 -0
- package/src/paths.js +59 -0
- package/src/pricing.js +73 -0
- package/src/scanner.js +721 -0
- package/src/server.js +1584 -0
- package/src/state.js +73 -0
- package/src/tui.js +420 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Archer Simmons
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
<img src="https://cdn.qualit.ly/clawd-working-building.gif" width="120" alt="working" />
|
|
4
|
+
<img src="https://cdn.qualit.ly/clawd-working-typing.gif" width="120" alt="thinking" />
|
|
5
|
+
<img src="https://cdn.qualit.ly/clawd-notification.gif" width="120" alt="notification" />
|
|
6
|
+
<img src="https://cdn.qualit.ly/clawd-sleeping.gif" width="120" alt="idle" />
|
|
7
|
+
|
|
8
|
+
# claude-rpc
|
|
9
|
+
|
|
10
|
+
**Discord Rich Presence for [Claude Code](https://claude.com/claude-code).**
|
|
11
|
+
Your model, project, current tool, tokens, and lifetime stats โ live in your Discord profile.
|
|
12
|
+
|
|
13
|
+
[](LICENSE)
|
|
14
|
+
[](https://nodejs.org)
|
|
15
|
+
[](https://claude.com/claude-code)
|
|
16
|
+
[](https://discord.com/developers/docs/topics/rpc)
|
|
17
|
+
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
<div align="center">
|
|
23
|
+
<img src="docs/demo.gif" width="560" alt="Discord Rich Presence card: Claude Code, working in claude-rpc on Opus 4.7" />
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
Driven entirely by Claude Code's hook system. Zero polling, zero overhead between sessions.
|
|
27
|
+
|
|
28
|
+
<div align="center">
|
|
29
|
+
|
|
30
|
+
<sub>Sample badges (your numbers, your README):</sub>
|
|
31
|
+
|
|
32
|
+
<img src="https://img.shields.io/badge/claude%20%C2%B7%207d-12.4h-4c1" alt="hours" /> <img src="https://img.shields.io/badge/streak-23%20days-fe7d37" alt="streak" /> <img src="https://img.shields.io/badge/claude%20cost%20%C2%B7%2030d-$48.20-3a7" alt="cost" /> <img src="https://img.shields.io/badge/lines%20%C2%B7%20all--time-24.1k-08c" alt="lines" /> <img src="https://img.shields.io/badge/prompts%20%C2%B7%2030d-1.2k-5865F2" alt="prompts" />
|
|
33
|
+
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
> **What's new in v0.2.0** โ Cost estimation, code churn, languages, MCP/built-in split, bash + web + subagent leaderboards, redesigned web dashboard with SSE push, six-tab Electron settings GUI, new `insights` and `badge` subcommands. [Full release notes โ](https://github.com/rar-file/claude-rpc/releases/tag/v0.2.0)
|
|
37
|
+
|
|
38
|
+
## Features
|
|
39
|
+
|
|
40
|
+
**In Discord**
|
|
41
|
+
|
|
42
|
+
| | |
|
|
43
|
+
| :--- | :--- |
|
|
44
|
+
| ๐ด **Live status** | Model, project, current tool/file, and token counts update as you work |
|
|
45
|
+
| ๐๏ธ **Status art** | Large image swaps between *working*, *thinking*, *idle*, *stale*, *notification* |
|
|
46
|
+
| ๐ **Rotation frames** | Cycle through today's stats, streak, top file, lifetime totals, anything you template |
|
|
47
|
+
| ๐ **Auto GitHub button** | When your cwd is a git repo with a github origin, a *View on GitHub* button appears |
|
|
48
|
+
|
|
49
|
+
**Beyond Discord**
|
|
50
|
+
|
|
51
|
+
| | |
|
|
52
|
+
| :--- | :--- |
|
|
53
|
+
| ๐ **All-time aggregates** | Hours, prompts, tokens, streaks, hotspots, **lines changed, languages, cost, bash usage, web domains, subagent runs** โ incremental scanner over `~/.claude/projects/*.jsonl` |
|
|
54
|
+
| ๐ฐ **Cost estimate** | Per-model spend (Opus/Sonnet/Haiku) using public list prices โ editable in `src/pricing.js` |
|
|
55
|
+
| ๐ง **Insights** | `claude-rpc insights` generates 3โ5 contextual lines: weekly trend, peak weekday, hotspot file, cost pace, streak progress |
|
|
56
|
+
| ๐ฅ๏ธ **CLI dashboard** | `claude-rpc status` โ heatmap, hour histogram, top tools / files / projects / languages / bash commands / cost |
|
|
57
|
+
| ๐ **Web dashboard** | `claude-rpc serve` โ range selector (7d / 30d / 90d / 1y / All), live SSE updates, project drilldown, day-detail modal, achievements, theme toggle |
|
|
58
|
+
| ๐ชช **README badges** | `claude-rpc badge --metric hours --range 7d --out h.svg` (or live at `/api/badge.svg?metric=โฆ`) |
|
|
59
|
+
| โ๏ธ **Config GUI** | Electron app with six tabs: Presence (drag-reorder, variable autocomplete, presets), Discord, Assets, Timing, Daemon (start/stop/restart, tail log), Stats |
|
|
60
|
+
|
|
61
|
+
## Screens
|
|
62
|
+
|
|
63
|
+
<table>
|
|
64
|
+
<tr>
|
|
65
|
+
<td align="center" width="50%"><b>Web dashboard</b><br/><sub><code>claude-rpc serve</code></sub><br/><br/><img src="docs/dashboard.png" alt="Web dashboard with range selector, activity chart, heatmap, cost panel, languages stack, and leaderboards" /></td>
|
|
66
|
+
<td align="center" width="50%"><b>Settings GUI</b><br/><sub><code>npm run dashboard</code></sub><br/><br/><img src="docs/electron.png" alt="Electron config editor with Presence / Discord / Assets / Timing / Daemon / Stats tabs" /></td>
|
|
67
|
+
</tr>
|
|
68
|
+
</table>
|
|
69
|
+
|
|
70
|
+
## Install
|
|
71
|
+
|
|
72
|
+
**Windows (no Node required)** โ grab the latest portable exe:
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
# From https://github.com/rar-file/claude-rpc/releases/latest
|
|
76
|
+
# Download claude-rpc.exe, drop it anywhere on PATH.
|
|
77
|
+
claude-rpc setup
|
|
78
|
+
claude-rpc start
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**Any OS (from source)** โ Node 18+:
|
|
82
|
+
|
|
83
|
+
```sh
|
|
84
|
+
git clone https://github.com/rar-file/claude-rpc.git
|
|
85
|
+
cd claude-rpc
|
|
86
|
+
npm install
|
|
87
|
+
cp config.example.json config.json
|
|
88
|
+
npm link # optional, makes `claude-rpc` global
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Requires the Discord **desktop** client (RPC IPC is unavailable in the browser client) and Claude Code with hook support.
|
|
92
|
+
|
|
93
|
+
## Quick start
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
node ./src/cli.js setup # register hooks into ~/.claude/settings.json
|
|
97
|
+
node ./src/cli.js start # launch the daemon (detached)
|
|
98
|
+
node ./src/cli.js status # CLI dashboard
|
|
99
|
+
node ./src/cli.js serve # web dashboard at http://127.0.0.1:47474
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Open Claude Code in any project. Hooks fire on `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Notification`, `Stop`, and `SessionEnd`, and the daemon pushes updated presence to Discord within a second.
|
|
103
|
+
|
|
104
|
+
If you `npm link` (or install the packaged exe), every command above becomes `claude-rpc <command>`.
|
|
105
|
+
|
|
106
|
+
## Discord app setup
|
|
107
|
+
|
|
108
|
+
1. Open the [Discord Developer Portal](https://discord.com/developers/applications) and create a new application named something like `Claude Code`.
|
|
109
|
+
2. Copy the **Application ID** into `config.json` under `clientId`.
|
|
110
|
+
3. *(Optional)* Under **Rich Presence โ Art Assets**, upload images named `claude`, `working`, `thinking`, `idle`, `notification` to match the keys in `statusAssets`.
|
|
111
|
+
4. Or skip uploading and use direct URLs in `statusAssets` (e.g. `"https://example.com/working.gif"`). Modern Discord clients fetch them through their media proxy.
|
|
112
|
+
|
|
113
|
+
## Commands
|
|
114
|
+
|
|
115
|
+
| Command | Description |
|
|
116
|
+
| ------------- | -------------------------------------------------------- |
|
|
117
|
+
| `setup` | Install hooks into `~/.claude/settings.json` |
|
|
118
|
+
| `uninstall` | Remove hooks |
|
|
119
|
+
| `start` | Start the daemon (detached) |
|
|
120
|
+
| `stop` | Stop the daemon |
|
|
121
|
+
| `restart` | Stop then start |
|
|
122
|
+
| `status` | Current session + all-time dashboard |
|
|
123
|
+
| `today` | Today's stats + 24h histogram |
|
|
124
|
+
| `week` | This week's stats + daily breakdown |
|
|
125
|
+
| `serve` | Open the local web dashboard (port 47474) |
|
|
126
|
+
| `preview` | Show how each rotation frame renders right now |
|
|
127
|
+
| `scan` | Incrementally rescan `~/.claude/projects` for aggregates |
|
|
128
|
+
| `rescan` | Force re-parse every transcript |
|
|
129
|
+
| `insights` | Print 3โ5 auto-generated insight lines |
|
|
130
|
+
| `badge` | Render a Shields-style SVG (`--metric hours\|streak\|cost\|lines`, `--range 7d\|30d\|all`, `--out file.svg`) |
|
|
131
|
+
| `tail` | Tail the daemon log |
|
|
132
|
+
| `daemon` | Run the daemon in the foreground (for debugging) |
|
|
133
|
+
|
|
134
|
+
## Config GUI
|
|
135
|
+
|
|
136
|
+
```sh
|
|
137
|
+
cd dashboard
|
|
138
|
+
npm install
|
|
139
|
+
npm start # dev mode
|
|
140
|
+
npm run build # โ dist/claude-rpc-dashboard.exe (Windows portable)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
The Electron app reads and writes `config.json` directly. The daemon hot-reloads.
|
|
144
|
+
|
|
145
|
+
## How it works
|
|
146
|
+
|
|
147
|
+
Three cooperating pieces, glued by JSON files on disk.
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
Claude Code Discord desktop
|
|
151
|
+
โ โฒ
|
|
152
|
+
โ lifecycle event (stdin JSON) โ IPC frame
|
|
153
|
+
โผ โ
|
|
154
|
+
โโโโโโโโโโโโ state.json โโโโโโโโโโโโ โ
|
|
155
|
+
โ hook.js โ โโโโโโโโโโโโโโโโถ โ daemon.jsโ โโโโโโโโโโโโโโโโโ
|
|
156
|
+
โโโโโโโโโโโโ โโโโโโโโโโโโ
|
|
157
|
+
โฒ
|
|
158
|
+
โ aggregate.json
|
|
159
|
+
โ
|
|
160
|
+
โโโโโโโโโโโโโโ
|
|
161
|
+
โ scanner.js โ โโโ ~/.claude/projects/*.jsonl
|
|
162
|
+
โโโโโโโโโโโโโโ
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
1. **Hook** (`src/hook.js`) โ Claude Code spawns it on every lifecycle event. Parses the event JSON from stdin and mutates the shared state file.
|
|
166
|
+
2. **Daemon** (`src/daemon.js`) โ Long-running. Connects to Discord's local IPC, watches the state file plus periodic transcript scans, pushes presence frames every few seconds.
|
|
167
|
+
3. **Scanner** (`src/scanner.js`) โ Walks `~/.claude/projects/**/*.jsonl` transcripts for all-time aggregates (active time, prompts, tool calls, tokens, streaks, hour-of-day, top files / projects). Cached at `~/.claude-rpc/aggregate.json` for incremental updates.
|
|
168
|
+
|
|
169
|
+
Persistent state lives in a few well-known places:
|
|
170
|
+
|
|
171
|
+
| Path | What |
|
|
172
|
+
| ---- | ---- |
|
|
173
|
+
| `$TMPDIR/claude-rpc/state.json` | Current session, volatile |
|
|
174
|
+
| `~/.claude-rpc/aggregate.json` | All-time aggregates |
|
|
175
|
+
| `~/.claude-rpc/scan-cache.json` | Per-transcript scan cache |
|
|
176
|
+
| `~/.claude/settings.json` | Hook registrations (managed by `setup`) |
|
|
177
|
+
|
|
178
|
+
<details>
|
|
179
|
+
<summary><b>Configuration reference</b></summary>
|
|
180
|
+
|
|
181
|
+
`config.json` keys, all optional unless noted:
|
|
182
|
+
|
|
183
|
+
| Key | Default | Notes |
|
|
184
|
+
| ------------------------- | ------- | ------------------------------------------------------------------- |
|
|
185
|
+
| `clientId` | โ | **Required.** Discord application ID |
|
|
186
|
+
| `updateIntervalMs` | `4000` | How often the daemon pushes to Discord |
|
|
187
|
+
| `rotationIntervalMs` | `12000` | How fast rotation frames cycle |
|
|
188
|
+
| `rescanIntervalSec` | `300` | How often transcripts are re-aggregated |
|
|
189
|
+
| `idleThresholdSec` | `60` | No activity for this long โ status `idle` |
|
|
190
|
+
| `staleSessionMin` | `5` | No activity for this long (minutes) โ status `stale`; presence is cleared |
|
|
191
|
+
| `notificationWindowSec` | `8` | How long the `notification` status sticks |
|
|
192
|
+
| `showElapsed` | `true` | Include the elapsed timer |
|
|
193
|
+
| `activityType` | `0` | `0` Playing, `2` Listening, `3` Watching, `5` Competing |
|
|
194
|
+
| `statusAssets` | `{}` | Image per status (working / thinking / idle / stale / notification) |
|
|
195
|
+
| `presence.largeImageKey` | โ | Fallback large image when no `statusAssets` match |
|
|
196
|
+
| `presence.largeImageText` | โ | Tooltip on hover |
|
|
197
|
+
| `presence.smallImageKey` | โ | Small badge in the corner of the large image |
|
|
198
|
+
| `presence.smallImageText` | โ | Tooltip on hover |
|
|
199
|
+
| `presence.rotation` | `[]` | Array of frames, each `{ details, state, requires? }` |
|
|
200
|
+
| `presence.buttons` | `[]` | Up to 2 `{ label, url }` buttons |
|
|
201
|
+
| `statusIcons` | `{}` | Small image key per status (empty string hides it) |
|
|
202
|
+
|
|
203
|
+
### Rotation frames
|
|
204
|
+
|
|
205
|
+
```jsonc
|
|
206
|
+
{
|
|
207
|
+
"presence": {
|
|
208
|
+
"rotation": [
|
|
209
|
+
{ "details": "{statusVerbose} in {project}", "state": "{modelPretty}" },
|
|
210
|
+
{ "details": "{currentToolPretty} ยท {currentFilePretty}",
|
|
211
|
+
"state": "{tokensFmt} tokens",
|
|
212
|
+
"requires": ["currentFile"] },
|
|
213
|
+
{ "details": "Today ยท {todayHours}",
|
|
214
|
+
"state": "{todayPromptsLabel}",
|
|
215
|
+
"requires": ["todayActiveMs"] }
|
|
216
|
+
]
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Each frame has:
|
|
222
|
+
|
|
223
|
+
- `details` โ bold first line (Discord max 128 chars)
|
|
224
|
+
- `state` โ lighter second line (Discord max 128 chars)
|
|
225
|
+
- `requires` *(optional)* โ a variable name or array of names. The frame is skipped if any required variable is empty / `0`. Lets you have context-dependent frames (e.g. only show the *current tool* frame when there's actually a tool running).
|
|
226
|
+
|
|
227
|
+
</details>
|
|
228
|
+
|
|
229
|
+
<details>
|
|
230
|
+
<summary><b>Template variables</b></summary>
|
|
231
|
+
|
|
232
|
+
Both `details` and `state` (and button labels and URLs) support `{name}` substitution.
|
|
233
|
+
|
|
234
|
+
| Variable | Sample |
|
|
235
|
+
| ----------------------- | ------------------ |
|
|
236
|
+
| `{statusVerbose}` | `Working` |
|
|
237
|
+
| `{project}` | `claude-rpc` |
|
|
238
|
+
| `{modelPretty}` | `Opus 4.7` |
|
|
239
|
+
| `{currentToolPretty}` | `Edit` |
|
|
240
|
+
| `{currentFilePretty}` | `src/app/page.tsx` |
|
|
241
|
+
| `{tokensFmt}` | `2.3k` |
|
|
242
|
+
| `{messagesLabel}` | `8 prompts` |
|
|
243
|
+
| `{projectSessionLabel}` | `Session #1` |
|
|
244
|
+
| `{projectHours}` | `22m` |
|
|
245
|
+
| `{todayHours}` | `56m` |
|
|
246
|
+
| `{weekHours}` | `3.1h` |
|
|
247
|
+
| `{streakLabel}` | `7-day streak` |
|
|
248
|
+
| `{daysSinceFirstLabel}` | `Day 31` |
|
|
249
|
+
| `{allHours}` | `52h` |
|
|
250
|
+
| `{allTokensFmt}` | `2.82B` |
|
|
251
|
+
| `{peakHour}` | `22:00` |
|
|
252
|
+
| `{topEditedFile}` | `index.html` |
|
|
253
|
+
| `{linesAddedFmt}` | `24k` |
|
|
254
|
+
| `{todayLinesAddedFmt}` | `320` |
|
|
255
|
+
| `{linesNetFmt}` | `+18k` |
|
|
256
|
+
| `{topLanguage}` | `TypeScript` |
|
|
257
|
+
| `{languagesLabel}` | `TypeScript ยท Python ยท Rust` |
|
|
258
|
+
| `{topBashCmdLabel}` | `git ร 820` |
|
|
259
|
+
| `{topDomainLabel}` | `docs.anthropic.com ร 28` |
|
|
260
|
+
| `{subagentLabel}` | `Explore ร 18` |
|
|
261
|
+
| `{mcpToolPercentLabel}` | `12% MCP` |
|
|
262
|
+
| `{todayCostFmt}` | `$1.23` |
|
|
263
|
+
| `{allCostFmt}` | `$89.42` |
|
|
264
|
+
| `{weekdayLabel}` | `Thursday` |
|
|
265
|
+
| `{startTimeLabel}` | `started 09:14` |
|
|
266
|
+
|
|
267
|
+
Run `node ./src/cli.js preview` to see every frame rendered with your real data, including which ones would be hidden by their `requires`.
|
|
268
|
+
|
|
269
|
+
</details>
|
|
270
|
+
|
|
271
|
+
## Badges
|
|
272
|
+
|
|
273
|
+
Generate a Shields-style SVG you can drop into a README:
|
|
274
|
+
|
|
275
|
+
```sh
|
|
276
|
+
claude-rpc badge --metric hours --range 7d --out claude-hours.svg
|
|
277
|
+
claude-rpc badge --metric streak --out claude-streak.svg
|
|
278
|
+
claude-rpc badge --metric cost --range 30d --out claude-cost.svg
|
|
279
|
+
claude-rpc badge --metric lines --range all --out claude-lines.svg
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
While the daemon's `serve` command is running, the same data is also available live at:
|
|
283
|
+
|
|
284
|
+
```
|
|
285
|
+
http://127.0.0.1:47474/api/badge.svg?metric=hours&range=7d
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Cost numbers come from `src/pricing.js`, seeded with **approximate** public list prices for Anthropic models. Edit that file to override โ your actual Claude Code subscription bill is unrelated.
|
|
289
|
+
|
|
290
|
+
## Troubleshooting
|
|
291
|
+
|
|
292
|
+
**Discord doesn't pick up presence.** The Discord *desktop* app must be running. The browser client doesn't expose the local IPC bridge. Verify `clientId` matches your Discord application, and run `claude-rpc tail` to watch the daemon log live.
|
|
293
|
+
|
|
294
|
+
**Hooks don't fire.** Run `claude-rpc setup` and check the `hooks` section of `~/.claude/settings.json`. Restart Claude Code afterwards so it re-reads the hook config.
|
|
295
|
+
|
|
296
|
+
**Elapsed timer resets on rotation.** Update to the current version. Older builds passed timestamps in seconds; Discord expects milliseconds.
|
|
297
|
+
|
|
298
|
+
## License
|
|
299
|
+
|
|
300
|
+
[MIT](LICENSE) ยฉ Archer Simmons
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"clientId": "1506443909406920948",
|
|
3
|
+
"appName": "Claude Code",
|
|
4
|
+
"updateIntervalMs": 4000,
|
|
5
|
+
"rotationIntervalMs": 12000,
|
|
6
|
+
"rescanIntervalSec": 300,
|
|
7
|
+
"idleThresholdSec": 60,
|
|
8
|
+
"staleSessionMin": 5,
|
|
9
|
+
"hideWhenStale": true,
|
|
10
|
+
"notificationWindowSec": 8,
|
|
11
|
+
"showElapsed": true,
|
|
12
|
+
"activityType": 0,
|
|
13
|
+
"statusAssets": {
|
|
14
|
+
"working": "https://cdn.qualit.ly/clawd-working-building.gif",
|
|
15
|
+
"thinking": "https://cdn.qualit.ly/clawd-working-typing.gif",
|
|
16
|
+
"idle": "https://cdn.qualit.ly/clawd-sleeping.gif",
|
|
17
|
+
"stale": "https://cdn.qualit.ly/clawd-sleeping.gif",
|
|
18
|
+
"notification": "https://cdn.qualit.ly/clawd-notification.gif"
|
|
19
|
+
},
|
|
20
|
+
"presence": {
|
|
21
|
+
"largeImageKey": "https://cdn.qualit.ly/clawd-sleeping.gif",
|
|
22
|
+
"largeImageText": "{modelPretty} ยท {allHours} on Claude ยท {streakLabel}",
|
|
23
|
+
"smallImageKey": "{statusIcon}",
|
|
24
|
+
"smallImageText": "{statusVerbose}",
|
|
25
|
+
"byStatus": {
|
|
26
|
+
"working": {
|
|
27
|
+
"details": "Working in {project}",
|
|
28
|
+
"state": "{currentToolPretty} ยท {currentFilePretty} ยท {tokensFmt} tokens",
|
|
29
|
+
"largeImageText": "Working on a {fileLang} file"
|
|
30
|
+
},
|
|
31
|
+
"thinking": {
|
|
32
|
+
"details": "Thinking in {project}",
|
|
33
|
+
"state": "{modelPretty} ยท {messagesLabel} ยท {tokensFmt} tokens",
|
|
34
|
+
"largeImageText": "Reasoning with {modelPretty}"
|
|
35
|
+
},
|
|
36
|
+
"notification": {
|
|
37
|
+
"details": "Waiting on you ยท {project}",
|
|
38
|
+
"state": "{modelPretty} ยท {messagesLabel}",
|
|
39
|
+
"largeImageText": "Permission needed"
|
|
40
|
+
},
|
|
41
|
+
"idle": {
|
|
42
|
+
"details": "Idle in {project}",
|
|
43
|
+
"state": "{modelPretty} ยท {todayHours} today",
|
|
44
|
+
"largeImageText": "Idle ยท {modelPretty}",
|
|
45
|
+
"rotation": [
|
|
46
|
+
{ "details": "This week ยท {weekHours}", "state": "{weekPromptsLabel} ยท {weekTokensFmt} tokens", "requires": ["weekActiveMs"] },
|
|
47
|
+
{ "details": "{streakLabel}", "state": "{daysSinceFirstLabel} ยท {allSessionsLabel}", "requires": ["streakIsMilestone"] },
|
|
48
|
+
{ "details": "Hotspot ยท {topEditedFile}", "state": "{topEditedCountLabel} all-time", "requires": ["topEditedCount"] },
|
|
49
|
+
{ "details": "{allHours} on Claude all-time", "state": "{allSessionsLabel} ยท {allMessagesFmt} prompts", "requires": ["allSessions"] },
|
|
50
|
+
{ "details": "Lifetime ยท {allTokensFmt} tokens", "state": "{allToolsFmt} tool calls ยท {allFilesFmt} files", "requires": ["allTools"] },
|
|
51
|
+
{ "details": "Code churn ยท {linesAddedFmt} added","state": "{linesNetFmt} net ยท {topLanguage}", "requires": ["topLanguage"] },
|
|
52
|
+
{ "details": "Cost ยท {todayCostFmt} today", "state": "{allCostFmt} all-time", "requires": ["allCost"] }
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"buttons": [
|
|
57
|
+
{ "label": "Claude Code", "url": "https://claude.com/claude-code" }
|
|
58
|
+
]
|
|
59
|
+
},
|
|
60
|
+
"statusIcons": {
|
|
61
|
+
"working": "working",
|
|
62
|
+
"thinking": "thinking",
|
|
63
|
+
"idle": "idle",
|
|
64
|
+
"notification": "",
|
|
65
|
+
"stale": ""
|
|
66
|
+
}
|
|
67
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-rpc",
|
|
3
|
+
"version": "0.3.8",
|
|
4
|
+
"description": "Discord Rich Presence for Claude Code โ live model, project, tokens, and lifetime stats driven by Claude Code's hook system.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Archer Simmons",
|
|
8
|
+
"bin": {
|
|
9
|
+
"claude-rpc": "./bin/claude-rpc.js"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"start": "node ./src/daemon.js",
|
|
13
|
+
"setup": "node ./src/cli.js setup",
|
|
14
|
+
"status": "node ./src/cli.js status",
|
|
15
|
+
"stop": "node ./src/cli.js stop",
|
|
16
|
+
"hook": "node ./src/hook.js",
|
|
17
|
+
"serve": "node ./src/cli.js serve",
|
|
18
|
+
"scan": "node ./src/cli.js scan",
|
|
19
|
+
"insights": "node ./src/cli.js insights",
|
|
20
|
+
"badge": "node ./src/cli.js badge",
|
|
21
|
+
"dashboard": "npm --prefix dashboard start",
|
|
22
|
+
"build:exe": "node ./scripts/build-exe.js",
|
|
23
|
+
"prep:dashboard": "node ./scripts/prep-dashboard.js",
|
|
24
|
+
"dist:mac": "npm run build:exe && npm run prep:dashboard && npm --prefix dashboard run dist:mac",
|
|
25
|
+
"dist:win": "npm run build:exe && npm run prep:dashboard && npm --prefix dashboard run dist:win"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@xhayper/discord-rpc": "^1.2.1"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"esbuild": "^0.24.0",
|
|
32
|
+
"postject": "^1.0.0-alpha.6"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18"
|
|
36
|
+
},
|
|
37
|
+
"keywords": [
|
|
38
|
+
"claude",
|
|
39
|
+
"claude-code",
|
|
40
|
+
"discord",
|
|
41
|
+
"discord-rpc",
|
|
42
|
+
"rich-presence",
|
|
43
|
+
"anthropic",
|
|
44
|
+
"cli"
|
|
45
|
+
],
|
|
46
|
+
"files": [
|
|
47
|
+
"bin",
|
|
48
|
+
"src",
|
|
49
|
+
"config.example.json",
|
|
50
|
+
"LICENSE",
|
|
51
|
+
"README.md"
|
|
52
|
+
]
|
|
53
|
+
}
|
package/src/badge.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Shields-style SVG badge generator. Pure function: aggregate + flags โ SVG string.
|
|
2
|
+
// Consumed by `claude-rpc badge` and `GET /api/badge.svg`.
|
|
3
|
+
|
|
4
|
+
import { dayKey } from './scanner.js';
|
|
5
|
+
import { fmtCost } from './pricing.js';
|
|
6
|
+
|
|
7
|
+
const COLORS = {
|
|
8
|
+
hours: { left: '#555', right: '#4c1' }, // green
|
|
9
|
+
streak: { left: '#555', right: '#fe7d37' }, // orange
|
|
10
|
+
cost: { left: '#555', right: '#3a7' }, // teal-green
|
|
11
|
+
lines: { left: '#555', right: '#08c' }, // blue
|
|
12
|
+
prompts:{ left: '#555', right: '#5865F2' }, // discord blurple
|
|
13
|
+
tokens: { left: '#555', right: '#a55' }, // dim red
|
|
14
|
+
files: { left: '#555', right: '#aa6' }, // olive
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function pickWindow(byDay, range) {
|
|
18
|
+
if (!byDay) return [];
|
|
19
|
+
if (range === 'all') return Object.entries(byDay);
|
|
20
|
+
const days = parseInt(range, 10);
|
|
21
|
+
if (!Number.isFinite(days) || days <= 0) return Object.entries(byDay);
|
|
22
|
+
const today = new Date(); today.setHours(0, 0, 0, 0);
|
|
23
|
+
const out = [];
|
|
24
|
+
for (let i = 0; i < days; i++) {
|
|
25
|
+
const d = new Date(today); d.setDate(d.getDate() - i);
|
|
26
|
+
const k = dayKey(d.getTime());
|
|
27
|
+
if (byDay[k]) out.push([k, byDay[k]]);
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function fmtHoursLabel(ms) {
|
|
33
|
+
if (!ms) return '0h';
|
|
34
|
+
const h = ms / 3_600_000;
|
|
35
|
+
if (h < 1) return `${Math.round(h * 60)}m`;
|
|
36
|
+
if (h < 10) return `${h.toFixed(1)}h`;
|
|
37
|
+
return `${Math.round(h)}h`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function fmtNum(n) {
|
|
41
|
+
if (!n) return '0';
|
|
42
|
+
if (n < 1000) return String(n);
|
|
43
|
+
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
|
|
44
|
+
if (n < 1_000_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
|
|
45
|
+
return `${(n / 1_000_000_000).toFixed(2)}B`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Compute label/value pair for the requested metric.
|
|
49
|
+
function valueFor(aggregate, metric, range) {
|
|
50
|
+
const a = aggregate || {};
|
|
51
|
+
const window = pickWindow(a.byDay, range);
|
|
52
|
+
|
|
53
|
+
const rangeLabel = range === 'all' ? 'all-time' : range;
|
|
54
|
+
|
|
55
|
+
switch (metric) {
|
|
56
|
+
case 'hours': {
|
|
57
|
+
const ms = window.reduce((s, [, d]) => s + (d.activeMs || 0), 0);
|
|
58
|
+
return { label: `claude ยท ${rangeLabel}`, value: fmtHoursLabel(ms) };
|
|
59
|
+
}
|
|
60
|
+
case 'streak': {
|
|
61
|
+
return { label: 'streak', value: `${a.streak || 0} days` };
|
|
62
|
+
}
|
|
63
|
+
case 'cost': {
|
|
64
|
+
const cost = window.reduce((s, [, d]) => s + (d.cost || 0), 0);
|
|
65
|
+
return { label: `claude cost ยท ${rangeLabel}`, value: fmtCost(cost) };
|
|
66
|
+
}
|
|
67
|
+
case 'lines': {
|
|
68
|
+
const lines = window.reduce((s, [, d]) => s + (d.linesAdded || 0), 0);
|
|
69
|
+
return { label: `lines ยท ${rangeLabel}`, value: fmtNum(lines) };
|
|
70
|
+
}
|
|
71
|
+
case 'prompts': {
|
|
72
|
+
const p = window.reduce((s, [, d]) => s + (d.userMessages || 0), 0);
|
|
73
|
+
return { label: `prompts ยท ${rangeLabel}`, value: fmtNum(p) };
|
|
74
|
+
}
|
|
75
|
+
case 'tokens': {
|
|
76
|
+
const t = window.reduce((s, [, d]) =>
|
|
77
|
+
s + (d.inputTokens || 0) + (d.outputTokens || 0)
|
|
78
|
+
+ (d.cacheReadTokens || 0) + (d.cacheWriteTokens || 0), 0);
|
|
79
|
+
return { label: `tokens ยท ${rangeLabel}`, value: fmtNum(t) };
|
|
80
|
+
}
|
|
81
|
+
case 'files': {
|
|
82
|
+
return { label: 'files touched', value: fmtNum(a.uniqueFiles || 0) };
|
|
83
|
+
}
|
|
84
|
+
default:
|
|
85
|
+
return { label: metric, value: 'โ' };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function escapeXml(s) {
|
|
90
|
+
return String(s)
|
|
91
|
+
.replace(/&/g, '&')
|
|
92
|
+
.replace(/</g, '<')
|
|
93
|
+
.replace(/>/g, '>')
|
|
94
|
+
.replace(/"/g, '"');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Approximate text width in pixels for Verdana 11px. Good enough for badges.
|
|
98
|
+
// Widens slightly to compensate for variable-width glyphs.
|
|
99
|
+
function textWidth(s) {
|
|
100
|
+
let w = 0;
|
|
101
|
+
for (const ch of String(s)) {
|
|
102
|
+
if (/[il1.\s]/.test(ch)) w += 4;
|
|
103
|
+
else if (/[A-Z]/.test(ch)) w += 8;
|
|
104
|
+
else w += 6.5;
|
|
105
|
+
}
|
|
106
|
+
return Math.ceil(w);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function renderBadge({ label, value, color }) {
|
|
110
|
+
const PAD = 8;
|
|
111
|
+
const labelW = textWidth(label) + PAD * 2;
|
|
112
|
+
const valueW = textWidth(value) + PAD * 2;
|
|
113
|
+
const total = labelW + valueW;
|
|
114
|
+
const leftColor = color?.left || '#555';
|
|
115
|
+
const rightColor = color?.right || '#4c1';
|
|
116
|
+
|
|
117
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${total}" height="20" role="img" aria-label="${escapeXml(label)}: ${escapeXml(value)}">
|
|
118
|
+
<title>${escapeXml(label)}: ${escapeXml(value)}</title>
|
|
119
|
+
<linearGradient id="s" x2="0" y2="100%">
|
|
120
|
+
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
|
121
|
+
<stop offset="1" stop-opacity=".1"/>
|
|
122
|
+
</linearGradient>
|
|
123
|
+
<clipPath id="r"><rect width="${total}" height="20" rx="3" fill="#fff"/></clipPath>
|
|
124
|
+
<g clip-path="url(#r)">
|
|
125
|
+
<rect width="${labelW}" height="20" fill="${leftColor}"/>
|
|
126
|
+
<rect x="${labelW}" width="${valueW}" height="20" fill="${rightColor}"/>
|
|
127
|
+
<rect width="${total}" height="20" fill="url(#s)"/>
|
|
128
|
+
</g>
|
|
129
|
+
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110">
|
|
130
|
+
<text aria-hidden="true" x="${(labelW * 10) / 2}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${(labelW - PAD * 2) * 10}">${escapeXml(label)}</text>
|
|
131
|
+
<text x="${(labelW * 10) / 2}" y="140" transform="scale(.1)" fill="#fff" textLength="${(labelW - PAD * 2) * 10}">${escapeXml(label)}</text>
|
|
132
|
+
<text aria-hidden="true" x="${(labelW + valueW / 2) * 10}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${(valueW - PAD * 2) * 10}">${escapeXml(value)}</text>
|
|
133
|
+
<text x="${(labelW + valueW / 2) * 10}" y="140" transform="scale(.1)" fill="#fff" textLength="${(valueW - PAD * 2) * 10}">${escapeXml(value)}</text>
|
|
134
|
+
</g>
|
|
135
|
+
</svg>`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Top-level convenience: aggregate + flags โ SVG string.
|
|
139
|
+
export function badgeSvg({ aggregate, metric = 'hours', range = '7d', label, color }) {
|
|
140
|
+
const v = valueFor(aggregate, metric, range);
|
|
141
|
+
const finalLabel = label ?? v.label;
|
|
142
|
+
const finalColor = color ?? COLORS[metric] ?? COLORS.hours;
|
|
143
|
+
return renderBadge({ label: finalLabel, value: v.value, color: finalColor });
|
|
144
|
+
}
|