cc-cream 0.1.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/CHANGELOG.md +40 -0
- package/LICENSE +21 -0
- package/README.md +238 -0
- package/package.json +61 -0
- package/src/cc-cream.js +77 -0
- package/src/config.js +74 -0
- package/src/defaults.js +44 -0
- package/src/install.js +204 -0
- package/src/render.js +34 -0
- package/src/segments.js +173 -0
- package/src/state.js +57 -0
- package/src/ttl.js +26 -0
- package/src/utils.js +80 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to cc-cream are documented here. Format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
|
|
5
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [0.1.0] — 2026-05-29
|
|
8
|
+
|
|
9
|
+
Initial public release. cc-cream reads the JSON Claude Code pipes to its status
|
|
10
|
+
line and prints a colored ≤3-row bar — zero tokens, the model never sees it.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Status-line engine** — fourteen configurable segments across up to three
|
|
14
|
+
rows: `ctx`, `cache`, `write`, `ttl`, `effort`, `thinking`, `api_ratio`,
|
|
15
|
+
`cost` (row 1); `5h`, `7d`, `burn`, `peak` (row 2, hidden for API users);
|
|
16
|
+
`model`, `session_name` (row 3). Node built-ins only, no runtime dependencies.
|
|
17
|
+
- **Per-session state** keyed by `session_id` for burn-rate projection, cost
|
|
18
|
+
delta on `/clear`, and cache-drop detection.
|
|
19
|
+
- **Configuration** via `~/.claude/cc-cream.json` — every display decision
|
|
20
|
+
(on/off, row, order, thresholds, colors) is data-driven, with per-field and
|
|
21
|
+
whole-file fallback. Degrades gracefully: malformed input never crashes.
|
|
22
|
+
- **Distribution as a Claude Code plugin** — `.claude-plugin/plugin.json` and a
|
|
23
|
+
self-hosted `marketplace.json`. Install with
|
|
24
|
+
`/plugin marketplace add bart-turczynski/cc-cream` then `/plugin install cc-cream`.
|
|
25
|
+
- **`/cc-cream:setup` command** that wires the status line into `settings.json`
|
|
26
|
+
with a self-resolving cache-glob command, so `/plugin update` applies new
|
|
27
|
+
versions automatically with no network calls and no re-run of setup.
|
|
28
|
+
- **npm distribution** — `cc-cream` bin with a node shebang; installable via
|
|
29
|
+
`npx -y cc-cream@latest`.
|
|
30
|
+
- **Consent-based installer** (`src/install.js`) for the manual / GitHub path:
|
|
31
|
+
copies the runtime into `~/.claude/cc-cream` and writes one `statusLine` block,
|
|
32
|
+
preserving any existing configuration and asking before replacing it.
|
|
33
|
+
- **Docs & trust** — user-facing README, `SECURITY.md`, `CONTRIBUTING.md`, and a
|
|
34
|
+
prominent disclosure: no network calls, no telemetry, no runtime dependencies.
|
|
35
|
+
|
|
36
|
+
### Notes
|
|
37
|
+
- Supports **macOS and Linux**; Windows is a planned fast-follow.
|
|
38
|
+
- Requires Claude Code **2.1.132+** (`effort` / `thinking` need 2.1.145+).
|
|
39
|
+
|
|
40
|
+
[0.1.0]: https://github.com/bart-turczynski/cc-cream/releases/tag/v0.1.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bart Turczynski
|
|
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,238 @@
|
|
|
1
|
+
# cc-cream
|
|
2
|
+
|
|
3
|
+
**C.R.E.A.M. — Cache Rules Everything Around Me.**
|
|
4
|
+
|
|
5
|
+
A lightweight status-line tool for [Claude Code](https://claude.com/claude-code)
|
|
6
|
+
that turns the JSON Claude Code pipes to its status line into a glanceable,
|
|
7
|
+
colored ≤3-row bar. The model **never sees the output** — it costs **zero tokens**.
|
|
8
|
+
|
|
9
|
+
## What it shows
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
ctx:3% [30k] | cache:55% | ~$0.10
|
|
13
|
+
5h:22% ↺ 0m | 7d:36% ↺ Sat 21:00
|
|
14
|
+
Opus 4.7 (1M context) | example session
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
> Rendered from a live Claude Code subscriber session (fixtures/subscriber.golden.json).
|
|
18
|
+
> A proper screenshot or asciinema recording will appear here after public release.
|
|
19
|
+
|
|
20
|
+
- **Row 1** — this session: context-window occupancy, cache hit rate, session cost
|
|
21
|
+
- **Row 2** — rate-limit budget windows: 5h and 7d usage + reset countdown (subscribers only; API users get one row)
|
|
22
|
+
- **Row 3** — identity: model name and optional session name
|
|
23
|
+
|
|
24
|
+
The bar helps you avoid rate limits, keep the cache warm, and catch context fill
|
|
25
|
+
before the model degrades — with cache economics as the organizing story.
|
|
26
|
+
|
|
27
|
+
## Trust and data posture
|
|
28
|
+
|
|
29
|
+
- **No network calls.** The engine is a pure stdin → stdout transformer. It never
|
|
30
|
+
opens a socket, fetches a URL, or calls home.
|
|
31
|
+
- **No telemetry.** Nothing is collected, reported, or logged anywhere.
|
|
32
|
+
- **No runtime dependencies.** Node built-ins only. `npm install` is for dev tools
|
|
33
|
+
(Cucumber, Biome) — nothing that runs at render time.
|
|
34
|
+
- **Zero tokens.** Claude Code reads the bar output to display it; the model never
|
|
35
|
+
receives it.
|
|
36
|
+
|
|
37
|
+
The only I/O is reading the JSON blob Claude Code pipes on stdin and writing one
|
|
38
|
+
session-state file to `~/.claude/cc-cream-state.json` (keyed by session ID,
|
|
39
|
+
contains cost and rate-limit samples used for the burn projection).
|
|
40
|
+
|
|
41
|
+
## Requirements
|
|
42
|
+
|
|
43
|
+
- **Node.js** — already present; Claude Code is a Node app.
|
|
44
|
+
- **Claude Code ≥ 2.1.132** (released 2026-05-06). The cache figure requires
|
|
45
|
+
`context_window.current_usage`, which landed in that release.
|
|
46
|
+
- The `effort` and `thinking` segments additionally require **Claude Code ≥ 2.1.145**;
|
|
47
|
+
they stay hidden below that version.
|
|
48
|
+
|
|
49
|
+
## Platform support
|
|
50
|
+
|
|
51
|
+
v1 supports **macOS and Linux**. Windows support is a planned fast-follow; the
|
|
52
|
+
engine is pure Node and the only blocker is the statusLine shell-command wiring.
|
|
53
|
+
|
|
54
|
+
## Install
|
|
55
|
+
|
|
56
|
+
### Option 1 — Claude Code plugin (community catalog / self-hosted marketplace)
|
|
57
|
+
|
|
58
|
+
If cc-cream is listed in the community catalog:
|
|
59
|
+
```bash
|
|
60
|
+
/plugin install cc-cream
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
To use the self-hosted marketplace directly:
|
|
64
|
+
```bash
|
|
65
|
+
/plugin marketplace add bart-turczynski/cc-cream
|
|
66
|
+
/plugin install cc-cream
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Then wire it into your settings in one step:
|
|
70
|
+
```
|
|
71
|
+
/cc-cream:setup
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The `/cc-cream:setup` command runs the consent installer, which writes the
|
|
75
|
+
`statusLine` block to `~/.claude/settings.json`. Updates are automatic: when
|
|
76
|
+
`/plugin update` drops a new version into the cache, the next render picks it
|
|
77
|
+
up without any further action.
|
|
78
|
+
|
|
79
|
+
### Option 2 — npm / npx
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
npx -y cc-cream@latest
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Or install globally:
|
|
86
|
+
```bash
|
|
87
|
+
npm install -g cc-cream
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Then run the consent installer to wire it into Claude Code:
|
|
91
|
+
```bash
|
|
92
|
+
node $(npm root -g)/cc-cream/src/install.js
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Option 3 — Manual GitHub clone
|
|
96
|
+
|
|
97
|
+
Download and install by cloning the repository, then run the consent installer:
|
|
98
|
+
```bash
|
|
99
|
+
git clone https://github.com/bart-turczynski/cc-cream.git
|
|
100
|
+
node cc-cream/src/install.js
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The installer:
|
|
104
|
+
- Detects an existing `statusLine` and **asks before replacing it**
|
|
105
|
+
- **Preserves any `padding`** you have set
|
|
106
|
+
- Is **idempotent** — re-running when cc-cream is already installed changes nothing
|
|
107
|
+
- States the trust and restart requirement
|
|
108
|
+
|
|
109
|
+
After install, Claude Code must be **trusted** for the directory (if prompted),
|
|
110
|
+
and you may need to **restart** it for the bar to appear.
|
|
111
|
+
|
|
112
|
+
## Configuration
|
|
113
|
+
|
|
114
|
+
Every display decision is read from `~/.claude/cc-cream.json`. Edit it by hand
|
|
115
|
+
or ask Claude to. It is strict JSON with no comments. **Every field falls back to
|
|
116
|
+
its built-in default if missing or malformed** — a typo degrades one value rather
|
|
117
|
+
than breaking the bar; a whole-file parse error falls back to all defaults.
|
|
118
|
+
|
|
119
|
+
```json
|
|
120
|
+
{
|
|
121
|
+
"numbers": "compact",
|
|
122
|
+
"ttl": "auto",
|
|
123
|
+
"percentage": "consumed",
|
|
124
|
+
"segments": {
|
|
125
|
+
"ctx": { "on": true, "row": 1, "order": 2, "amber": 30, "orange": 40, "red": 50, "basis": "window", "ceiling": 200000, "display": "basis" },
|
|
126
|
+
"cache": { "on": true, "row": 1, "order": 3 },
|
|
127
|
+
"write": { "on": false, "row": 1, "order": 3.5 },
|
|
128
|
+
"ttl": { "on": true, "row": 1, "order": 4, "amber": 50, "red": 80 },
|
|
129
|
+
"cost": { "on": true, "row": 1, "order": 5 },
|
|
130
|
+
"effort": { "on": false, "row": 1, "order": 6 },
|
|
131
|
+
"thinking": { "on": false, "row": 1, "order": 7 },
|
|
132
|
+
"api_ratio": { "on": false, "row": 1, "order": 8 },
|
|
133
|
+
"5h": { "on": true, "row": 2, "order": 1, "amber": 75, "red": 90 },
|
|
134
|
+
"burn": { "on": true, "row": 2, "order": 1.5 },
|
|
135
|
+
"7d": { "on": true, "row": 2, "order": 2, "amber": 75, "red": 90 },
|
|
136
|
+
"peak": { "on": true, "row": 2, "order": 3, "start": 5, "end": 11 },
|
|
137
|
+
"model": { "on": true, "row": 3, "order": 0.5 },
|
|
138
|
+
"session_name": { "on": false, "row": 3, "order": 1 }
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Global keys
|
|
144
|
+
|
|
145
|
+
- `numbers`: `compact` (e.g. `38k`) or `exact` (`38000`) for token magnitudes.
|
|
146
|
+
- `ttl`: cache time-to-live used to color the `ttl` segment — `auto` (recommended),
|
|
147
|
+
`60`, or `5` minutes. `auto` infers from rate-limit data when available.
|
|
148
|
+
- `percentage`: `consumed` (default) counts up — `ctx:19%` means 19% of the window
|
|
149
|
+
is used, `5h:67%` means 67% of the 5h budget is gone. `remaining` flips the
|
|
150
|
+
budget/occupancy segments to count down (`ctx:81%`, `5h:33%`). Only `ctx`, `5h`
|
|
151
|
+
and `7d` flip; `cache%` (a hit-rate, not a budget) and `ttl` (a countdown) are
|
|
152
|
+
unaffected. **Thresholds are always expressed in consumed terms regardless of
|
|
153
|
+
this setting.**
|
|
154
|
+
|
|
155
|
+
### Per-segment keys
|
|
156
|
+
|
|
157
|
+
Every segment accepts:
|
|
158
|
+
- `on` (boolean) — whether to show the segment
|
|
159
|
+
- `row` (1, 2, or 3) — which row to place it on
|
|
160
|
+
- `order` (any number) — lower = further left within the row
|
|
161
|
+
|
|
162
|
+
Colored segments additionally accept threshold keys. Thresholds mark the
|
|
163
|
+
**lower bound** where that color begins.
|
|
164
|
+
|
|
165
|
+
### Segment catalog
|
|
166
|
+
|
|
167
|
+
| Segment | Default | Example | Meaning | Color |
|
|
168
|
+
|---|---|---|---|---|
|
|
169
|
+
| `ctx` | on, row 1 | `ctx:19% [38k]` | context-window occupancy + input-token magnitude | `<30` green · `30–40` amber · `40–50` orange · `≥50` red |
|
|
170
|
+
| `cache` | on, row 1 | `cache:95%` | last-turn cache hit rate (reads / total tokens) | neutral |
|
|
171
|
+
| `write` | **off**, row 1 | `write:4%` | last-turn cache creation rate (new writes / total tokens) | neutral |
|
|
172
|
+
| `ttl` | on, row 1 | `ttl:00:52` | time remaining before cache expires (counts down to 00:00) | `<50%` green · `50–80%` amber · `≥80%` red |
|
|
173
|
+
| `cost` | on, row 1 | `~$4.50` | session cost incl. subagents; `~` = CC's estimate | neutral; hidden when zero |
|
|
174
|
+
| `effort` | **off**, row 1 | `effort:high` | reasoning effort level (requires CC ≥ 2.1.145) | neutral |
|
|
175
|
+
| `thinking` | **off**, row 1 | `think:on` | thinking mode indicator (requires CC ≥ 2.1.145) | neutral |
|
|
176
|
+
| `api_ratio` | **off**, row 1 | `∿ api:74%` | fraction of wall time spent on API calls | neutral |
|
|
177
|
+
| `5h` | on, row 2 | `5h:23% ↺ 2h14m` | 5-hour rate-limit window + reset countdown | `≥75` amber · `≥90` red |
|
|
178
|
+
| `burn` | on, row 2 | `~38m` | estimated minutes until 5h cap at current pace | neutral; hidden when ETA > 5h or no prior sample |
|
|
179
|
+
| `7d` | on, row 2 | `7d:41% ↺ 4d` | weekly rate-limit window + reset countdown | same as 5h |
|
|
180
|
+
| `peak` | on, row 2 | `peak` | weekday Pacific-time window where 5h drains faster | amber; hidden outside window |
|
|
181
|
+
| `model` | on, row 3 | `Sonnet 4.6` | current model name | none |
|
|
182
|
+
| `session_name` | **off**, row 3 | `My project session` | conversation name from CC | none |
|
|
183
|
+
|
|
184
|
+
Any segment hides cleanly when its source field is absent — API users have no
|
|
185
|
+
`rate_limits`; `current_usage` is null right after `/compact`; etc.
|
|
186
|
+
|
|
187
|
+
Row 2 is hidden entirely for API users (no `rate_limits` in stdin).
|
|
188
|
+
Row 3 suppresses itself when all its segments are hidden.
|
|
189
|
+
|
|
190
|
+
### Row 1 layout
|
|
191
|
+
|
|
192
|
+
Row 1 has two zones separated by ` | `:
|
|
193
|
+
|
|
194
|
+
```
|
|
195
|
+
[ctx · cache · write · ttl · effort · thinking · api_ratio] | [cost]
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Segments within zone 1 are also separated by ` | `. Segments moved off their
|
|
199
|
+
default row via config must land in a zone to appear on row 1.
|
|
200
|
+
|
|
201
|
+
### `ctx`-specific keys
|
|
202
|
+
|
|
203
|
+
- `basis`: `window` (default) colors based on `used_percentage` of the real context
|
|
204
|
+
window. `ceiling` colors based on `total_input_tokens / ceiling`, so the warning
|
|
205
|
+
fires at the same absolute token count on any window size. On a 1M-context model
|
|
206
|
+
the window basis stays green well past where quality degrades — set `ceiling` if
|
|
207
|
+
you want an early warning that doesn't scale with the window.
|
|
208
|
+
- `ceiling`: token count the `ceiling` basis measures against. Default `200000`.
|
|
209
|
+
- `display`: with `basis: "ceiling"`, `basis` (default) shows the % toward the
|
|
210
|
+
ceiling so number and color agree; `window` pins it to CC's window figure but
|
|
211
|
+
still colors by the ceiling. No effect under `basis: "window"`.
|
|
212
|
+
|
|
213
|
+
### `ctx` thresholds
|
|
214
|
+
|
|
215
|
+
Default: `amber: 30`, `orange: 40`, `red: 50` (percent consumed).
|
|
216
|
+
|
|
217
|
+
### `ttl` thresholds
|
|
218
|
+
|
|
219
|
+
Default: `amber: 50`, `red: 80` (percent of the resolved TTL consumed).
|
|
220
|
+
|
|
221
|
+
### `5h` / `7d` thresholds
|
|
222
|
+
|
|
223
|
+
Default: `amber: 75`, `red: 90` (absolute `used_percentage`).
|
|
224
|
+
|
|
225
|
+
### `peak`-specific keys
|
|
226
|
+
|
|
227
|
+
- `start` / `end`: hours in Pacific time (0–23, exclusive end) bounding
|
|
228
|
+
Anthropic's faster-drain window. Defaults `5`–`11`. Weekday-only (Mon–Fri) and
|
|
229
|
+
the `America/Los_Angeles` timezone are hardcoded policy facts, not config.
|
|
230
|
+
|
|
231
|
+
## Development
|
|
232
|
+
|
|
233
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for how to run the tests. The runtime
|
|
234
|
+
uses only Node built-ins — no runtime dependencies.
|
|
235
|
+
|
|
236
|
+
## License
|
|
237
|
+
|
|
238
|
+
MIT — see [LICENSE](LICENSE).
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cc-cream",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Claude Code cache/context/cost status-line tool",
|
|
5
|
+
"directories": {
|
|
6
|
+
"doc": "docs"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"lint": "biome lint src/",
|
|
10
|
+
"knip": "knip",
|
|
11
|
+
"validate": "command -v claude >/dev/null 2>&1 && claude plugin validate . || echo 'cc-cream: claude CLI not found — skipping plugin validation'",
|
|
12
|
+
"pretest": "npm run lint && npm run knip && npm run validate",
|
|
13
|
+
"test": "cucumber-js",
|
|
14
|
+
"test:manual": "cucumber-js --profile manual",
|
|
15
|
+
"coverage": "c8 cucumber-js",
|
|
16
|
+
"watch": "cucumber-js --watch",
|
|
17
|
+
"prepare": "simple-git-hooks",
|
|
18
|
+
"prepublishOnly": "npm test"
|
|
19
|
+
},
|
|
20
|
+
"simple-git-hooks": {
|
|
21
|
+
"pre-push": "npm run coverage"
|
|
22
|
+
},
|
|
23
|
+
"bin": {
|
|
24
|
+
"cc-cream": "src/cc-cream.js"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"src/",
|
|
28
|
+
"LICENSE",
|
|
29
|
+
"README.md",
|
|
30
|
+
"CHANGELOG.md"
|
|
31
|
+
],
|
|
32
|
+
"keywords": [
|
|
33
|
+
"claude-code",
|
|
34
|
+
"status-line",
|
|
35
|
+
"cache",
|
|
36
|
+
"context",
|
|
37
|
+
"cost",
|
|
38
|
+
"developer-tools"
|
|
39
|
+
],
|
|
40
|
+
"author": "Bart Turczynski <support@spoonkeyworks.com>",
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"type": "module",
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=18"
|
|
45
|
+
},
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "git+https://github.com/bart-turczynski/cc-cream.git"
|
|
49
|
+
},
|
|
50
|
+
"bugs": {
|
|
51
|
+
"url": "https://github.com/bart-turczynski/cc-cream/issues"
|
|
52
|
+
},
|
|
53
|
+
"homepage": "https://github.com/bart-turczynski/cc-cream#readme",
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@biomejs/biome": "^2.4.15",
|
|
56
|
+
"@cucumber/cucumber": "^12.9.0",
|
|
57
|
+
"c8": "^11.0.0",
|
|
58
|
+
"knip": "^6.14.2",
|
|
59
|
+
"simple-git-hooks": "^2.13.1"
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/cc-cream.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// cc-cream — Claude Code status-line engine.
|
|
3
|
+
// Reads the session JSON Claude Code pipes on stdin and prints a colored
|
|
4
|
+
// <=3-row bar. Hard rule: degrade, never crash.
|
|
5
|
+
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import process from 'node:process';
|
|
9
|
+
import { pathToFileURL } from 'node:url';
|
|
10
|
+
import { loadConfig, readConfigFile } from './config.js';
|
|
11
|
+
import { render } from './render.js';
|
|
12
|
+
import {
|
|
13
|
+
getSessionState,
|
|
14
|
+
nextSessionPatch,
|
|
15
|
+
patchSessionState,
|
|
16
|
+
readState,
|
|
17
|
+
writeState,
|
|
18
|
+
} from './state.js';
|
|
19
|
+
|
|
20
|
+
export { DEFAULTS } from './defaults.js';
|
|
21
|
+
export { loadConfig } from './config.js';
|
|
22
|
+
export { render } from './render.js';
|
|
23
|
+
export { resolveTtl } from './ttl.js';
|
|
24
|
+
export { countdown, isPeak } from './utils.js';
|
|
25
|
+
export {
|
|
26
|
+
getSessionState,
|
|
27
|
+
nextSessionPatch,
|
|
28
|
+
patchSessionState,
|
|
29
|
+
readState,
|
|
30
|
+
writeState,
|
|
31
|
+
} from './state.js';
|
|
32
|
+
|
|
33
|
+
async function readStdin() {
|
|
34
|
+
const chunks = [];
|
|
35
|
+
for await (const c of process.stdin) chunks.push(c);
|
|
36
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseSession(raw) {
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(raw);
|
|
42
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
|
|
43
|
+
} catch {
|
|
44
|
+
// malformed/empty stdin -> render with no data -> empty bar
|
|
45
|
+
}
|
|
46
|
+
return {};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function nowFromEnv(env) {
|
|
50
|
+
const rawNow = env.CC_CREAM_NOW;
|
|
51
|
+
return rawNow && Number.isFinite(Number(rawNow)) ? Number(rawNow) : Date.now();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function main() {
|
|
55
|
+
const data = parseSession(await readStdin());
|
|
56
|
+
const cfg = loadConfig(readConfigFile());
|
|
57
|
+
const now = nowFromEnv(process.env);
|
|
58
|
+
|
|
59
|
+
const sessionId = typeof data.session_id === 'string' && data.session_id ? data.session_id : null;
|
|
60
|
+
const stateFile = path.join(os.homedir(), '.claude', 'cc-cream-state.json');
|
|
61
|
+
const state = sessionId ? readState(stateFile) : {};
|
|
62
|
+
const prevSessionState = getSessionState(state, sessionId);
|
|
63
|
+
|
|
64
|
+
const out = render(data, cfg, process.env, now, prevSessionState);
|
|
65
|
+
if (out) process.stdout.write(`${out}\n`);
|
|
66
|
+
|
|
67
|
+
if (sessionId) {
|
|
68
|
+
const patch = nextSessionPatch(data, prevSessionState, cfg, now);
|
|
69
|
+
writeState(stateFile, patchSessionState(state, sessionId, patch));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
process.exit(0);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
|
|
76
|
+
main();
|
|
77
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { DEFAULTS } from './defaults.js';
|
|
5
|
+
import { clone, isNum, numOr } from './utils.js';
|
|
6
|
+
|
|
7
|
+
const boolOr = (v, d) => (typeof v === 'boolean' ? v : d);
|
|
8
|
+
const rowOr = (v, d) => (v === 1 || v === 2 || v === 3 ? v : d);
|
|
9
|
+
const posOr = (v, d) => (isNum(v) && v > 0 ? v : d); // a ceiling of 0/neg would divide-by-zero
|
|
10
|
+
const basisOr = (v, d) => (v === 'window' || v === 'ceiling' ? v : d);
|
|
11
|
+
const ctxDisplayOr = (v, d) => (v === 'basis' || v === 'window' ? v : d);
|
|
12
|
+
const hourOr = (v, d) => (isNum(v) && v >= 0 && v <= 23 ? v : d);
|
|
13
|
+
const percentageOr = (v, d) => (v === 'consumed' || v === 'remaining' ? v : d);
|
|
14
|
+
|
|
15
|
+
function ttlOr(v, d) {
|
|
16
|
+
if (v === 'auto') return 'auto';
|
|
17
|
+
if (v === 60 || v === '60') return 60;
|
|
18
|
+
if (v === 5 || v === '5') return 5;
|
|
19
|
+
return d;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function mergeConfig(parsed) {
|
|
23
|
+
const cfg = clone(DEFAULTS);
|
|
24
|
+
cfg.numbers = parsed.numbers === 'compact' || parsed.numbers === 'exact' ? parsed.numbers : DEFAULTS.numbers;
|
|
25
|
+
cfg.ttl = ttlOr(parsed.ttl, DEFAULTS.ttl);
|
|
26
|
+
cfg.percentage = percentageOr(parsed.percentage, DEFAULTS.percentage);
|
|
27
|
+
|
|
28
|
+
const segs = parsed.segments;
|
|
29
|
+
if (segs && typeof segs === 'object' && !Array.isArray(segs)) {
|
|
30
|
+
for (const id of Object.keys(DEFAULTS.segments)) {
|
|
31
|
+
const def = DEFAULTS.segments[id];
|
|
32
|
+
const s = segs[id];
|
|
33
|
+
const out = clone(def);
|
|
34
|
+
if (s && typeof s === 'object' && !Array.isArray(s)) {
|
|
35
|
+
out.on = boolOr(s.on, def.on);
|
|
36
|
+
out.row = rowOr(s.row, def.row);
|
|
37
|
+
out.order = numOr(s.order, def.order);
|
|
38
|
+
if ('amber' in def) out.amber = numOr(s.amber, def.amber);
|
|
39
|
+
if ('orange' in def) out.orange = numOr(s.orange, def.orange);
|
|
40
|
+
if ('red' in def) out.red = numOr(s.red, def.red);
|
|
41
|
+
if ('drop' in def) out.drop = posOr(s.drop, def.drop);
|
|
42
|
+
if ('drop_recover' in def) out.drop_recover = posOr(s.drop_recover, def.drop_recover);
|
|
43
|
+
if ('basis' in def) out.basis = basisOr(s.basis, def.basis);
|
|
44
|
+
if ('ceiling' in def) out.ceiling = posOr(s.ceiling, def.ceiling);
|
|
45
|
+
if ('display' in def) out.display = ctxDisplayOr(s.display, def.display);
|
|
46
|
+
if ('start' in def) out.start = hourOr(s.start, def.start);
|
|
47
|
+
if ('end' in def) out.end = hourOr(s.end, def.end);
|
|
48
|
+
}
|
|
49
|
+
cfg.segments[id] = out;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return cfg;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// raw === null/undefined (no file) -> all defaults. Parse error -> all defaults.
|
|
56
|
+
export function loadConfig(raw) {
|
|
57
|
+
if (raw == null) return clone(DEFAULTS);
|
|
58
|
+
let parsed;
|
|
59
|
+
try {
|
|
60
|
+
parsed = JSON.parse(raw);
|
|
61
|
+
} catch {
|
|
62
|
+
return clone(DEFAULTS);
|
|
63
|
+
}
|
|
64
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return clone(DEFAULTS);
|
|
65
|
+
return mergeConfig(parsed);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function readConfigFile() {
|
|
69
|
+
try {
|
|
70
|
+
return fs.readFileSync(path.join(os.homedir(), '.claude', 'cc-cream.json'), 'utf8');
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/defaults.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Built-in defaults (PRD §6, minus the dropped `width` key — §14.2).
|
|
2
|
+
// Each colored segment names the LOWER BOUND where a color begins; the color
|
|
3
|
+
// function tests `red` first, then `orange` (ctx only), then `amber`, else green.
|
|
4
|
+
export const DEFAULTS = {
|
|
5
|
+
numbers: 'compact', // 'compact' | 'exact'
|
|
6
|
+
ttl: 'auto', // 'auto' | 60 | 5
|
|
7
|
+
percentage: 'consumed', // 'consumed' | 'remaining' — display-only flip of ctx/5h/7d (PRDv2 §3)
|
|
8
|
+
segments: {
|
|
9
|
+
model: { on: true, row: 3, order: 0.5 },
|
|
10
|
+
// `basis` picks the fullness reference the color (and, by default, the
|
|
11
|
+
// shown %) measures against: 'window' = used_percentage of the real window
|
|
12
|
+
// (no-regression default); 'ceiling' = total_input_tokens / `ceiling`, so
|
|
13
|
+
// the warning fires at a fixed absolute size on any window. `display`
|
|
14
|
+
// governs the shown %: 'basis' tracks the coloring basis (number and color
|
|
15
|
+
// agree), 'window' pins it to CC's window figure regardless (PRD §4.4).
|
|
16
|
+
ctx: { on: true, row: 1, order: 2, amber: 30, orange: 40, red: 50, basis: 'window', ceiling: 200000, display: 'basis' },
|
|
17
|
+
cache: { on: true, row: 1, order: 3, drop: 20, drop_recover: 80 },
|
|
18
|
+
ttl: { on: true, row: 1, order: 4, amber: 50, red: 80 },
|
|
19
|
+
cost: { on: true, row: 1, order: 5 },
|
|
20
|
+
'5h': { on: true, row: 2, order: 1, amber: 75, red: 90 },
|
|
21
|
+
'7d': { on: true, row: 2, order: 2, amber: 75, red: 90 },
|
|
22
|
+
// peak: amber "peak" word during Anthropic's faster-drain window (PRDv2 §2).
|
|
23
|
+
// start/end are Pacific-time hours (0–23, exclusive end); weekday (Mon–Fri)
|
|
24
|
+
// and the America/Los_Angeles timezone are hardcoded policy facts, not config.
|
|
25
|
+
peak: { on: true, row: 2, order: 3, start: 5, end: 11 },
|
|
26
|
+
burn: { on: true, row: 2, order: 1.5 },
|
|
27
|
+
effort: { on: false, row: 1, order: 6 },
|
|
28
|
+
thinking: { on: false, row: 1, order: 7 },
|
|
29
|
+
api_ratio: { on: false, row: 1, order: 8 },
|
|
30
|
+
session_name: { on: false, row: 3, order: 1 },
|
|
31
|
+
write: { on: false, row: 1, order: 3.5 },
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Row 1 renders as visual zones separated by " | " (PRD §4.3).
|
|
36
|
+
// Empty zones drop out, so a model-only bar is just the name with no separators.
|
|
37
|
+
export const ROW1_ZONES = [['ctx', 'cache', 'write', 'ttl', 'effort', 'thinking', 'api_ratio'], ['cost']];
|
|
38
|
+
|
|
39
|
+
export const ANSI = {
|
|
40
|
+
red: '\x1b[31m',
|
|
41
|
+
green: '\x1b[32m',
|
|
42
|
+
amber: '\x1b[33m',
|
|
43
|
+
orange: '\x1b[38;5;208m',
|
|
44
|
+
};
|
package/src/install.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// cc-cream consent-based installer (PRD §7, §14.1). It copies the runtime
|
|
3
|
+
// modules into ~/.claude/cc-cream and writes one `statusLine` block into the
|
|
4
|
+
// user's settings.json after showing the change. It detects and confirms before
|
|
5
|
+
// replacing an existing line, preserves any user `padding`, and surfaces the
|
|
6
|
+
// trust/restart requirement.
|
|
7
|
+
//
|
|
8
|
+
// The pure `plan()` function does all the decision-making (no I/O) so it is
|
|
9
|
+
// testable; the CLI wrapper at the bottom handles reading/prompting/writing.
|
|
10
|
+
|
|
11
|
+
import { execFileSync } from 'node:child_process';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import process from 'node:process';
|
|
16
|
+
import readline from 'node:readline';
|
|
17
|
+
import { pathToFileURL } from 'node:url';
|
|
18
|
+
|
|
19
|
+
const TRUST_NOTE =
|
|
20
|
+
'Claude Code must be trusted and possibly restarted for the status line to appear.';
|
|
21
|
+
|
|
22
|
+
// The cache-glob auto-update command (docs/RELEASE_PLAN.md "Auto-update mechanism").
|
|
23
|
+
// `nodePath` is the ABSOLUTE node binary, resolved once at setup time — the
|
|
24
|
+
// statusLine subprocess may not inherit the user's PATH, so a bare `node` is
|
|
25
|
+
// unsafe. The `-d .../*/` glob yields directory paths with a trailing slash, so
|
|
26
|
+
// `src/cc-cream.js` concatenates directly. `sort -V | tail -1` picks the highest
|
|
27
|
+
// installed version, so `/plugin update` is applied live with no re-run of setup.
|
|
28
|
+
export function autoUpdateCommand(nodePath) {
|
|
29
|
+
return `${nodePath} "$(ls -1d "\${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/*/cc-cream/*/ 2>/dev/null | sort -V | tail -1)src/cc-cream.js"`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// `desired` is considered already installed if it matches the planned command
|
|
33
|
+
// verbatim (so switching strategy or node path re-plans), at refreshInterval 60.
|
|
34
|
+
function isInstalled(existing, command) {
|
|
35
|
+
return (
|
|
36
|
+
!!existing &&
|
|
37
|
+
typeof existing === 'object' &&
|
|
38
|
+
existing.type === 'command' &&
|
|
39
|
+
typeof existing.command === 'string' &&
|
|
40
|
+
existing.command === command &&
|
|
41
|
+
existing.refreshInterval === 60
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Decide what to do. Returns { settings, changed, messages, needsConsent }.
|
|
46
|
+
// `consent` is the user's yes/no when an existing statusLine must be replaced.
|
|
47
|
+
//
|
|
48
|
+
// Two command strategies:
|
|
49
|
+
// - manual (default): `node <entrypoint>` pointing at the copied-to-home runtime.
|
|
50
|
+
// - plugin: the cache-glob auto-update command, using the absolute `nodePath`.
|
|
51
|
+
// Pass `{ plugin: true, nodePath }` to select it; the plugin cache IS the
|
|
52
|
+
// install, so no files are copied to home in that mode.
|
|
53
|
+
export function plan(settings, { entrypoint, consent, plugin = false, nodePath } = {}) {
|
|
54
|
+
const s = settings && typeof settings === 'object' ? settings : {};
|
|
55
|
+
const existing = s.statusLine;
|
|
56
|
+
const messages = [];
|
|
57
|
+
|
|
58
|
+
const command = plugin
|
|
59
|
+
? autoUpdateCommand(nodePath)
|
|
60
|
+
: `node ${entrypoint}`;
|
|
61
|
+
const desired = { type: 'command', command, refreshInterval: 60 };
|
|
62
|
+
// Preserve any user padding — it shrinks the 80-col budget (PRD §7).
|
|
63
|
+
if (existing && typeof existing === 'object' && existing.padding !== undefined) {
|
|
64
|
+
desired.padding = existing.padding;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (isInstalled(existing, command)) {
|
|
68
|
+
messages.push('cc-cream is already installed — no changes needed.');
|
|
69
|
+
return { settings: s, changed: false, messages, needsConsent: false };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// An existing (different) statusLine must be confirmed before replacing.
|
|
73
|
+
const hasExisting = existing && typeof existing === 'object';
|
|
74
|
+
if (hasExisting) {
|
|
75
|
+
messages.push(`An existing statusLine is configured:\n ${JSON.stringify(existing)}`);
|
|
76
|
+
messages.push('Replace it with cc-cream?');
|
|
77
|
+
if (consent !== true) {
|
|
78
|
+
messages.push('Declined — your existing statusLine is unchanged.');
|
|
79
|
+
return { settings: s, changed: false, messages, needsConsent: true };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
messages.push(`Will set statusLine to:\n ${JSON.stringify(desired)}`);
|
|
84
|
+
messages.push(TRUST_NOTE);
|
|
85
|
+
return { settings: { ...s, statusLine: desired }, changed: true, messages, needsConsent: hasExisting };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// CLI wrapper.
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
function settingsPath() {
|
|
92
|
+
return path.join(os.homedir(), '.claude', 'settings.json');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function destinationPath() {
|
|
96
|
+
return path.join(os.homedir(), '.claude', 'cc-cream', 'cc-cream.js');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function runtimeFiles(sourceFile) {
|
|
100
|
+
const sourceDir = path.dirname(sourceFile);
|
|
101
|
+
return fs.readdirSync(sourceDir)
|
|
102
|
+
.filter((name) => name.endsWith('.js') && name !== 'install.js')
|
|
103
|
+
.map((name) => path.join(sourceDir, name));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function copyRuntimeFiles(sourceFile, destDir) {
|
|
107
|
+
let copied = false;
|
|
108
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
109
|
+
for (const file of runtimeFiles(sourceFile)) {
|
|
110
|
+
const dest = path.join(destDir, path.basename(file));
|
|
111
|
+
const needsCopy = !fs.existsSync(dest) || fs.statSync(file).mtime > fs.statSync(dest).mtime;
|
|
112
|
+
if (needsCopy) {
|
|
113
|
+
fs.copyFileSync(file, dest);
|
|
114
|
+
copied = true;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return copied;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Resolve the absolute node binary to bake into the statusLine command. The
|
|
121
|
+
// status line runs as a detached subprocess that may not inherit the user's
|
|
122
|
+
// PATH, so a bare `node` is unsafe. We prefer the shell's `command -v node`
|
|
123
|
+
// (the path the user's interactive shell would pick), falling back to
|
|
124
|
+
// process.execPath (the node currently running setup) if that fails.
|
|
125
|
+
function resolveNodePath() {
|
|
126
|
+
try {
|
|
127
|
+
const found = execFileSync('command', ['-v', 'node'], {
|
|
128
|
+
shell: true,
|
|
129
|
+
encoding: 'utf8',
|
|
130
|
+
}).trim();
|
|
131
|
+
if (found) return found;
|
|
132
|
+
} catch {
|
|
133
|
+
// fall through
|
|
134
|
+
}
|
|
135
|
+
return process.execPath;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function ask(question) {
|
|
139
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
140
|
+
return new Promise((resolve) => rl.question(`${question} [y/N] `, (a) => {
|
|
141
|
+
rl.close();
|
|
142
|
+
resolve(/^y(es)?$/i.test(a.trim()));
|
|
143
|
+
}));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function main() {
|
|
147
|
+
const args = process.argv.slice(2);
|
|
148
|
+
const plugin = args.includes('--plugin');
|
|
149
|
+
// First non-flag arg is an optional local source path (manual mode only).
|
|
150
|
+
const positional = args.filter((a) => !a.startsWith('--'));
|
|
151
|
+
|
|
152
|
+
const file = settingsPath();
|
|
153
|
+
let settings = {};
|
|
154
|
+
try {
|
|
155
|
+
settings = JSON.parse(fs.readFileSync(file, 'utf8')) || {};
|
|
156
|
+
} catch {
|
|
157
|
+
settings = {}; // missing or malformed -> start fresh, don't clobber blindly below
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// planOpts holds whatever the chosen strategy needs to build its command.
|
|
161
|
+
let planOpts;
|
|
162
|
+
if (plugin) {
|
|
163
|
+
// Plugin mode: the plugin cache IS the install — do NOT copy to home. The
|
|
164
|
+
// command self-resolves the latest cached version on every render.
|
|
165
|
+
planOpts = { plugin: true, nodePath: resolveNodePath() };
|
|
166
|
+
} else {
|
|
167
|
+
// Manual / GitHub mode: copy the runtime into ~/.claude/cc-cream and point
|
|
168
|
+
// the statusLine at that copied entrypoint.
|
|
169
|
+
const sourceFile = positional[0]
|
|
170
|
+
? path.resolve(positional[0])
|
|
171
|
+
: path.resolve(path.dirname(new URL(import.meta.url).pathname), 'cc-cream.js');
|
|
172
|
+
|
|
173
|
+
if (!fs.existsSync(sourceFile)) {
|
|
174
|
+
console.error(`Error: cc-cream.js not found at ${sourceFile}`);
|
|
175
|
+
process.exit(1);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const dest = destinationPath();
|
|
179
|
+
const destDir = path.dirname(dest);
|
|
180
|
+
if (copyRuntimeFiles(sourceFile, destDir)) {
|
|
181
|
+
console.log(`Copied cc-cream runtime files to ${destDir}`);
|
|
182
|
+
}
|
|
183
|
+
planOpts = { entrypoint: dest };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let result = plan(settings, planOpts);
|
|
187
|
+
// If a replace needs consent, ask now and re-plan with the answer.
|
|
188
|
+
if (!result.changed && result.needsConsent) {
|
|
189
|
+
for (const m of result.messages) console.log(m);
|
|
190
|
+
const yes = await ask('Replace it with cc-cream?');
|
|
191
|
+
result = plan(settings, { ...planOpts, consent: yes });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
for (const m of result.messages) console.log(m);
|
|
195
|
+
if (result.changed) {
|
|
196
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
197
|
+
fs.writeFileSync(file, `${JSON.stringify(result.settings, null, 2)}\n`);
|
|
198
|
+
console.log(`\nWrote ${file}.`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
|
|
203
|
+
main();
|
|
204
|
+
}
|
package/src/render.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { ROW1_ZONES } from './defaults.js';
|
|
2
|
+
import { renderSegments } from './segments.js';
|
|
3
|
+
import { resolveTtl } from './ttl.js';
|
|
4
|
+
import { paint } from './utils.js';
|
|
5
|
+
|
|
6
|
+
// Assemble enabled+visible segments into up to three rows.
|
|
7
|
+
export function render(data, cfg, env, now, prevSessionState = null) {
|
|
8
|
+
const ttlMin = resolveTtl({ rateLimits: data?.rate_limits, config: cfg, env });
|
|
9
|
+
// CC_CREAM_TZ is an internal test/diagnostic seam, not a documented config key.
|
|
10
|
+
const tz = env?.CC_CREAM_TZ || 'America/Los_Angeles';
|
|
11
|
+
const segs = renderSegments(data, cfg, ttlMin, now, prevSessionState, tz);
|
|
12
|
+
|
|
13
|
+
const visible = (id, row) => cfg.segments[id]?.on && segs[id] && cfg.segments[id].row === row;
|
|
14
|
+
const byOrder = (a, b) => cfg.segments[a].order - cfg.segments[b].order;
|
|
15
|
+
const draw = (id) => paint(segs[id].text, segs[id].color);
|
|
16
|
+
|
|
17
|
+
const row1 = ROW1_ZONES.map((zone) => zone.filter((id) => visible(id, 1)).sort(byOrder).map(draw).join(' | '))
|
|
18
|
+
.filter((z) => z.length > 0)
|
|
19
|
+
.join(' | ');
|
|
20
|
+
|
|
21
|
+
const row2 = Object.keys(cfg.segments)
|
|
22
|
+
.filter((id) => visible(id, 2))
|
|
23
|
+
.sort(byOrder)
|
|
24
|
+
.map(draw)
|
|
25
|
+
.join(' | ');
|
|
26
|
+
|
|
27
|
+
const row3 = Object.keys(cfg.segments)
|
|
28
|
+
.filter((id) => visible(id, 3))
|
|
29
|
+
.sort(byOrder)
|
|
30
|
+
.map(draw)
|
|
31
|
+
.join(' | ');
|
|
32
|
+
|
|
33
|
+
return [row1, row2, row3].filter((r) => r.length > 0).join('\n');
|
|
34
|
+
}
|
package/src/segments.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { band, countdown, flipPct, fmtNum, isNum, isPeak, numOr, pad2 } from './utils.js';
|
|
3
|
+
import { hasWindow } from './ttl.js';
|
|
4
|
+
|
|
5
|
+
function magnitudeTokens(cw) {
|
|
6
|
+
// PRD §4.1 assumes `total_input_tokens`; fall back to the input-only sum of
|
|
7
|
+
// current_usage so the magnitude survives a rename.
|
|
8
|
+
if (isNum(cw.total_input_tokens)) return cw.total_input_tokens;
|
|
9
|
+
const u = cw.current_usage;
|
|
10
|
+
if (u && typeof u === 'object') {
|
|
11
|
+
const sum = numOr(u.cache_read_input_tokens, 0) + numOr(u.cache_creation_input_tokens, 0) + numOr(u.input_tokens, 0);
|
|
12
|
+
if (sum > 0) return sum;
|
|
13
|
+
}
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function segModel(data) {
|
|
18
|
+
const v = data?.model?.display_name;
|
|
19
|
+
if (typeof v !== 'string' || v === '') return null;
|
|
20
|
+
return { text: v, color: null };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function segCtx(data, cfg) {
|
|
24
|
+
const cw = data?.context_window;
|
|
25
|
+
if (!cw || typeof cw !== 'object') return null;
|
|
26
|
+
const winPct = cw.used_percentage;
|
|
27
|
+
if (!isNum(winPct)) return null;
|
|
28
|
+
const s = cfg.segments.ctx;
|
|
29
|
+
const mag = magnitudeTokens(cw);
|
|
30
|
+
|
|
31
|
+
let colorPct = winPct;
|
|
32
|
+
let ceilingPct;
|
|
33
|
+
if (s.basis === 'ceiling' && isNum(mag) && s.ceiling > 0) {
|
|
34
|
+
ceilingPct = (mag / s.ceiling) * 100;
|
|
35
|
+
colorPct = ceilingPct;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const shownPct = ceilingPct != null && s.display !== 'window' ? ceilingPct : winPct;
|
|
39
|
+
|
|
40
|
+
let text = `ctx:${flipPct(Math.round(shownPct), cfg)}%`;
|
|
41
|
+
if (isNum(mag)) text += ` [${fmtNum(mag, cfg.numbers)}]`;
|
|
42
|
+
return { text, color: band(colorPct, s.amber, s.orange, s.red) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function segCache(data, cfg, prevCachePct, recovering) {
|
|
46
|
+
const u = data?.context_window?.current_usage;
|
|
47
|
+
if (!u || typeof u !== 'object') return null;
|
|
48
|
+
const read = numOr(u.cache_read_input_tokens, 0);
|
|
49
|
+
const denom = read + numOr(u.cache_creation_input_tokens, 0) + numOr(u.input_tokens, 0);
|
|
50
|
+
if (denom <= 0) return null;
|
|
51
|
+
const pct = Math.round((read / denom) * 100);
|
|
52
|
+
const s = cfg.segments.cache;
|
|
53
|
+
const freshDrop = isNum(prevCachePct) && (prevCachePct - pct) >= s.drop;
|
|
54
|
+
const stillRecovering = recovering === true && pct < s.drop_recover;
|
|
55
|
+
const color = (freshDrop || stillRecovering) ? 'red' : null;
|
|
56
|
+
return { text: `cache:${pct}%`, color };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function segTtl(data, cfg, ttlMin, now) {
|
|
60
|
+
const tp = data?.transcript_path;
|
|
61
|
+
if (typeof tp !== 'string' || tp === '') return null;
|
|
62
|
+
let mtimeMs;
|
|
63
|
+
try {
|
|
64
|
+
mtimeMs = fs.statSync(tp).mtimeMs;
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const elapsedMin = Math.floor(Math.max(0, now - mtimeMs) / 60000);
|
|
69
|
+
const remainingMin = Math.max(0, ttlMin - elapsedMin);
|
|
70
|
+
const text = `ttl:${pad2(Math.floor(remainingMin / 60))}:${pad2(remainingMin % 60)}`;
|
|
71
|
+
const s = cfg.segments.ttl;
|
|
72
|
+
const pctTtl = ttlMin > 0 ? (elapsedMin / ttlMin) * 100 : 0;
|
|
73
|
+
return { text, color: band(pctTtl, s.amber, s.red) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function segCost(data) {
|
|
77
|
+
const c = data?.cost?.total_cost_usd;
|
|
78
|
+
if (!isNum(c) || c <= 0) return null;
|
|
79
|
+
return { text: `~$${c.toFixed(2)}`, color: null };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function segRate(window, label, cfg, segId, now) {
|
|
83
|
+
if (!window || typeof window !== 'object') return null;
|
|
84
|
+
const pct = window.used_percentage;
|
|
85
|
+
if (!isNum(pct)) return null;
|
|
86
|
+
const s = cfg.segments[segId];
|
|
87
|
+
const color = pct >= s.red ? 'red' : pct >= s.amber ? 'amber' : null;
|
|
88
|
+
const cd = countdown(window.resets_at, now);
|
|
89
|
+
const head = `${label}:${flipPct(Math.round(pct), cfg)}%`;
|
|
90
|
+
const text = cd ? `${head} ↺ ${cd}` : head;
|
|
91
|
+
return { text, color };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function segEffort(data) {
|
|
95
|
+
const lvl = data?.effort?.level;
|
|
96
|
+
if (typeof lvl !== 'string' || lvl === '') return null;
|
|
97
|
+
return { text: `effort:${lvl}`, color: null };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function segThinking(data) {
|
|
101
|
+
const t = data?.thinking?.enabled;
|
|
102
|
+
if (typeof t !== 'boolean') return null;
|
|
103
|
+
return { text: `think:${t ? 'on' : 'off'}`, color: null };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function segApiRatio(data) {
|
|
107
|
+
const api = data?.cost?.total_api_duration_ms;
|
|
108
|
+
const total = data?.cost?.total_duration_ms;
|
|
109
|
+
if (!isNum(api) || !isNum(total) || total <= 0) return null;
|
|
110
|
+
const pct = Math.round(Math.min(100, (api / total) * 100));
|
|
111
|
+
return { text: `∿ api:${pct}%`, color: null };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function segSessionName(data) {
|
|
115
|
+
const name = data?.session_name;
|
|
116
|
+
if (typeof name !== 'string' || name === '') return null;
|
|
117
|
+
return { text: name, color: null };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function segCacheWrite(data) {
|
|
121
|
+
const u = data?.context_window?.current_usage;
|
|
122
|
+
if (!u || typeof u !== 'object') return null;
|
|
123
|
+
const creation = numOr(u.cache_creation_input_tokens, 0);
|
|
124
|
+
const denom = creation + numOr(u.cache_read_input_tokens, 0) + numOr(u.input_tokens, 0);
|
|
125
|
+
if (denom <= 0) return null;
|
|
126
|
+
return { text: `write:${Math.round((creation / denom) * 100)}%`, color: null };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function segPeak(data, cfg, now, tz) {
|
|
130
|
+
// peak rides the account-budget row, so it shows only when that row has windows.
|
|
131
|
+
if (!hasWindow(data?.rate_limits)) return null;
|
|
132
|
+
if (!isPeak(now, cfg, tz)) return null;
|
|
133
|
+
return { text: 'peak', color: 'amber' };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function segBurn(fiveHour, prev, now) {
|
|
137
|
+
if (!fiveHour || !isNum(fiveHour.used_percentage)) return null;
|
|
138
|
+
if (!prev || !isNum(prev.five_hour_pct) || !isNum(prev.ts)) return null;
|
|
139
|
+
const deltaPct = fiveHour.used_percentage - prev.five_hour_pct;
|
|
140
|
+
const deltaMs = now - prev.ts;
|
|
141
|
+
if (deltaPct <= 0 || deltaMs <= 0) return null;
|
|
142
|
+
const remaining = 100 - fiveHour.used_percentage;
|
|
143
|
+
if (remaining <= 0) return null;
|
|
144
|
+
const minEta = Math.ceil((remaining / deltaPct) * deltaMs / 60000);
|
|
145
|
+
if (!Number.isFinite(minEta) || minEta >= 300) return null;
|
|
146
|
+
const h = Math.floor(minEta / 60);
|
|
147
|
+
const m = minEta % 60;
|
|
148
|
+
return { text: h >= 1 ? `~${h}h${pad2(m)}m` : `~${minEta}m`, color: null };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function renderSegments(data, cfg, ttlMin, now, prevSessionState = null, tz = 'America/Los_Angeles') {
|
|
152
|
+
return {
|
|
153
|
+
model: segModel(data),
|
|
154
|
+
ctx: segCtx(data, cfg),
|
|
155
|
+
cache: segCache(
|
|
156
|
+
data,
|
|
157
|
+
cfg,
|
|
158
|
+
prevSessionState && isNum(prevSessionState.cache_pct) ? prevSessionState.cache_pct : undefined,
|
|
159
|
+
prevSessionState?.recovering === true,
|
|
160
|
+
),
|
|
161
|
+
ttl: segTtl(data, cfg, ttlMin, now),
|
|
162
|
+
cost: segCost(data),
|
|
163
|
+
'5h': segRate(data?.rate_limits?.five_hour, '5h', cfg, '5h', now),
|
|
164
|
+
'7d': segRate(data?.rate_limits?.seven_day, '7d', cfg, '7d', now),
|
|
165
|
+
peak: segPeak(data, cfg, now, tz),
|
|
166
|
+
burn: segBurn(data?.rate_limits?.five_hour, prevSessionState, now),
|
|
167
|
+
effort: segEffort(data),
|
|
168
|
+
thinking: segThinking(data),
|
|
169
|
+
api_ratio: segApiRatio(data),
|
|
170
|
+
session_name: segSessionName(data),
|
|
171
|
+
write: segCacheWrite(data),
|
|
172
|
+
};
|
|
173
|
+
}
|
package/src/state.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { isNum, numOr } from './utils.js';
|
|
3
|
+
|
|
4
|
+
export function readState(stateFilePath) {
|
|
5
|
+
try {
|
|
6
|
+
const raw = fs.readFileSync(stateFilePath, 'utf8');
|
|
7
|
+
const parsed = JSON.parse(raw);
|
|
8
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
|
|
9
|
+
return {};
|
|
10
|
+
} catch {
|
|
11
|
+
return {};
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function writeState(stateFilePath, state) {
|
|
16
|
+
try {
|
|
17
|
+
fs.writeFileSync(stateFilePath, JSON.stringify(state));
|
|
18
|
+
} catch {
|
|
19
|
+
// degrade silently — stateless render is fine
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getSessionState(state, sessionId) {
|
|
24
|
+
if (!sessionId || typeof sessionId !== 'string') return null;
|
|
25
|
+
const sessions = state?.sessions;
|
|
26
|
+
if (!sessions || typeof sessions !== 'object') return null;
|
|
27
|
+
return sessions[sessionId] ?? null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function patchSessionState(state, sessionId, patch) {
|
|
31
|
+
if (!sessionId || typeof sessionId !== 'string') return state;
|
|
32
|
+
const sessions = { ...(state?.sessions ?? {}) };
|
|
33
|
+
sessions[sessionId] = { ...(sessions[sessionId] ?? {}), ...patch };
|
|
34
|
+
return { ...state, sessions };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function nextSessionPatch(data, prevSessionState, cfg, now) {
|
|
38
|
+
const patch = { ts: now };
|
|
39
|
+
const cost = data?.cost?.total_cost_usd;
|
|
40
|
+
if (isNum(cost)) patch.cost = cost;
|
|
41
|
+
const cu = data?.context_window?.current_usage;
|
|
42
|
+
if (cu && typeof cu === 'object') {
|
|
43
|
+
const read = numOr(cu.cache_read_input_tokens, 0);
|
|
44
|
+
const denom = read + numOr(cu.cache_creation_input_tokens, 0) + numOr(cu.input_tokens, 0);
|
|
45
|
+
if (denom > 0) {
|
|
46
|
+
const currentCachePct = Math.round((read / denom) * 100);
|
|
47
|
+
patch.cache_pct = currentCachePct;
|
|
48
|
+
const prevCachePct = prevSessionState && isNum(prevSessionState.cache_pct) ? prevSessionState.cache_pct : undefined;
|
|
49
|
+
const wasRecovering = prevSessionState?.recovering === true;
|
|
50
|
+
const freshDrop = isNum(prevCachePct) && (prevCachePct - currentCachePct) >= cfg.segments.cache.drop;
|
|
51
|
+
patch.recovering = freshDrop || (wasRecovering && currentCachePct < cfg.segments.cache.drop_recover);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const fh = data?.rate_limits?.five_hour;
|
|
55
|
+
if (fh && isNum(fh.used_percentage)) patch.five_hour_pct = fh.used_percentage;
|
|
56
|
+
return patch;
|
|
57
|
+
}
|
package/src/ttl.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { numOr } from './utils.js';
|
|
2
|
+
|
|
3
|
+
const envOn = (v) => typeof v === 'string' && v !== '' && v !== '0' && v.toLowerCase() !== 'false';
|
|
4
|
+
|
|
5
|
+
export function hasWindow(rl) {
|
|
6
|
+
return !!(rl && typeof rl === 'object' && (rl.five_hour || rl.seven_day));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function overCap(rl) {
|
|
10
|
+
return [rl.five_hour, rl.seven_day].some((w) => w && numOr(w.used_percentage, 0) >= 100);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function resolveTtl({ rateLimits, config, env }) {
|
|
14
|
+
const e = env || {};
|
|
15
|
+
// FORCE override wins over everything (PRD §10).
|
|
16
|
+
if (envOn(e.FORCE_PROMPT_CACHING_5M)) return 5;
|
|
17
|
+
// Explicit config pin.
|
|
18
|
+
const pin = config ? config.ttl : 'auto';
|
|
19
|
+
if (pin === 5) return 5;
|
|
20
|
+
if (pin === 60) return 60;
|
|
21
|
+
// auto resolution.
|
|
22
|
+
if (hasWindow(rateLimits)) {
|
|
23
|
+
return overCap(rateLimits) ? 5 : 60; // subscriber
|
|
24
|
+
}
|
|
25
|
+
return envOn(e.ENABLE_PROMPT_CACHING_1H) ? 60 : 5; // API user
|
|
26
|
+
}
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { ANSI } from './defaults.js';
|
|
2
|
+
|
|
3
|
+
export const clone = (o) => JSON.parse(JSON.stringify(o));
|
|
4
|
+
export const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
5
|
+
export const numOr = (v, d) => (isNum(v) ? v : d);
|
|
6
|
+
export const pad2 = (n) => String(n).padStart(2, '0');
|
|
7
|
+
|
|
8
|
+
export function fmtNum(n, mode) {
|
|
9
|
+
if (mode === 'exact') return String(n);
|
|
10
|
+
if (n >= 1_000_000) return `${Math.round(n / 1_000_000)}M`;
|
|
11
|
+
if (n >= 1000) return `${Math.round(n / 1000)}k`;
|
|
12
|
+
return String(n);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function paint(text, color) {
|
|
16
|
+
return color && ANSI[color] ? `${ANSI[color]}${text}\x1b[0m` : text;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// 3-arg form: band(value, amber, red) — used by ttl / rate limits.
|
|
20
|
+
// 4-arg form: band(value, amber, orange, red) — used by ctx.
|
|
21
|
+
// Orange is skipped when it falls at or below amber.
|
|
22
|
+
export function band(value, amber, orangeOrRed, red) {
|
|
23
|
+
if (red === undefined) { red = orangeOrRed; orangeOrRed = undefined; }
|
|
24
|
+
if (value >= red) return 'red';
|
|
25
|
+
if (orangeOrRed !== undefined && orangeOrRed > amber && value >= orangeOrRed) return 'orange';
|
|
26
|
+
if (value >= amber) return 'amber';
|
|
27
|
+
return 'green';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Normalize a resets_at value to epoch ms. Claude Code sends a Unix timestamp
|
|
31
|
+
// in seconds; also tolerate ms and ISO.
|
|
32
|
+
function toEpochMs(v) {
|
|
33
|
+
if (typeof v === 'number' && Number.isFinite(v)) return v < 1e11 ? v * 1000 : v;
|
|
34
|
+
if (typeof v === 'string') {
|
|
35
|
+
if (/^\d+$/.test(v)) return toEpochMs(Number(v));
|
|
36
|
+
const t = Date.parse(v);
|
|
37
|
+
return Number.isNaN(t) ? NaN : t;
|
|
38
|
+
}
|
|
39
|
+
return NaN;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Display-only percentage flip (PRDv2 §3).
|
|
43
|
+
export const flipPct = (consumedShown, cfg) => (
|
|
44
|
+
cfg.percentage === 'remaining' ? 100 - consumedShown : consumedShown
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// True during Anthropic's faster-drain "peak" window (PRDv2 §2): a weekday
|
|
48
|
+
// (Mon–Fri) within [start, end) Pacific-time hours.
|
|
49
|
+
export function isPeak(now, cfg, tz = 'America/Los_Angeles') {
|
|
50
|
+
const s = cfg?.segments?.peak ?? {};
|
|
51
|
+
const start = isNum(s.start) ? s.start : 5;
|
|
52
|
+
const end = isNum(s.end) ? s.end : 11;
|
|
53
|
+
try {
|
|
54
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
55
|
+
timeZone: tz, hour: 'numeric', hour12: false, weekday: 'short',
|
|
56
|
+
}).formatToParts(new Date(now));
|
|
57
|
+
const p = Object.fromEntries(parts.map((x) => [x.type, x.value]));
|
|
58
|
+
const hour = Number(p.hour) % 24; // some ICU builds emit "24" for midnight
|
|
59
|
+
return !['Sat', 'Sun'].includes(p.weekday) && hour >= start && hour < end;
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// resets_at - now, on the §4.4 format ladder: >=1d -> "Fri 23:45", >=1h -> HhMMm, else MMm.
|
|
66
|
+
export function countdown(resetsAt, now) {
|
|
67
|
+
const t = toEpochMs(resetsAt);
|
|
68
|
+
if (Number.isNaN(t)) return '';
|
|
69
|
+
const totalMin = Math.max(0, Math.floor((t - now) / 60000));
|
|
70
|
+
const days = Math.floor(totalMin / 1440);
|
|
71
|
+
if (days >= 1) {
|
|
72
|
+
const d = new Date(t);
|
|
73
|
+
const weekday = d.toLocaleDateString(undefined, { weekday: 'short' });
|
|
74
|
+
const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false });
|
|
75
|
+
return `${weekday} ${time}`;
|
|
76
|
+
}
|
|
77
|
+
const hours = Math.floor(totalMin / 60);
|
|
78
|
+
if (hours >= 1) return `${hours}h${pad2(totalMin % 60)}m`;
|
|
79
|
+
return `${totalMin}m`;
|
|
80
|
+
}
|