cc-cream 0.1.18 → 0.2.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 +14 -0
- package/README.md +22 -0
- package/package.json +1 -1
- package/src/cc-cream.js +65 -4
- package/src/config.js +91 -16
- package/src/install.js +95 -69
- package/src/render.js +12 -3
- package/src/segments.js +8 -22
- package/src/settings.js +64 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,20 @@ All notable changes to cc-cream are documented here. Format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
|
|
5
5
|
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.2.0] — 2026-05-30
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **`cc-cream-setup --check-config`** lints `~/.claude/cc-cream.json` and reports unknown keys and out-of-domain values — the fields the renderer silently falls back to defaults for. Exits non-zero when there's something to fix, so a typo'd key ("`ambre`", "`colour`") is no longer a silent no-op.
|
|
11
|
+
- **`CC_CREAM_DEBUG=1` opt-in diagnostics.** When the bar is unexpectedly empty or short, set it to log — to `~/.claude/cc-cream-debug.log` (override with `CC_CREAM_DEBUG_LOG`) — which on-by-default segments rendered and which were dropped, plus the resolved TTL window and stdin size. Claude Code discards status-line stderr, so the channel is a file; **stdout stays untouched** (zero tokens). Off by default: no file, no overhead.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- **The plugin status line no longer resolves its version with a shell glob.** The wired command was `ls … | grep -E … | sort -V | tail -1` over the plugin cache, run on every render — it depended on GNU `sort -V` (not guaranteed in the status-line subprocess, notably on macOS) and reverse-engineered Claude Code's undocumented cache layout. `${CLAUDE_PLUGIN_ROOT}` doesn't expand in the status-line command context, so the command can't discover the current version itself. Instead the command now bakes the current version's **absolute** `cc-cream.js` path, and the `SessionStart` hook — which *does* receive `${CLAUDE_PLUGIN_ROOT}` — re-pins it after a `/plugin update`. Both install modes now share one command shape (`[ -f "<entrypoint>" ] || exit 0; exec "<node>" "<entrypoint>"`); the `[ -f … ]` guard preserves the silent exit-0 when the plugin cache is deleted out from under a stale line.
|
|
15
|
+
|
|
16
|
+
### Internal
|
|
17
|
+
- **Settings.json read/parse/atomic-write logic is shared** between the installer and the `SessionStart` hook via a new `src/settings.js`, instead of a copy in each.
|
|
18
|
+
- **Segment rendering is now pure.** The TTL anchor (including its `transcript_path` `statSync`) is resolved once in the I/O layer (`cc-cream.js`) and injected into `render()`, so `src/segments.js` no longer performs any filesystem access.
|
|
19
|
+
- **Config normalization is now a single schema table** (`src/config.js`) instead of an ad-hoc per-field conditional ladder. The same table powers `--check-config`, so validation rules live in one place.
|
|
20
|
+
|
|
7
21
|
## [0.1.18] — 2026-05-29
|
|
8
22
|
|
|
9
23
|
### Security
|
package/README.md
CHANGED
|
@@ -173,6 +173,13 @@ or ask Claude to. It is strict JSON with no comments. **Every field falls back t
|
|
|
173
173
|
its built-in default if missing or malformed** — a typo degrades one value rather
|
|
174
174
|
than breaking the bar; a whole-file parse error falls back to all defaults.
|
|
175
175
|
|
|
176
|
+
Because unknown or out-of-range fields are silently ignored, run the doctor after
|
|
177
|
+
editing by hand to catch typos:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
cc-cream-setup --check-config # reports unknown keys / out-of-domain values; exits non-zero if any
|
|
181
|
+
```
|
|
182
|
+
|
|
176
183
|
```json
|
|
177
184
|
{
|
|
178
185
|
"numbers": "compact",
|
|
@@ -285,6 +292,21 @@ Default: `amber: 75`, `red: 90` (absolute `used_percentage`).
|
|
|
285
292
|
Anthropic's faster-drain window. Defaults `5`–`11`. Weekday-only (Mon–Fri) and
|
|
286
293
|
the `America/Los_Angeles` timezone are hardcoded policy facts, not config.
|
|
287
294
|
|
|
295
|
+
## Troubleshooting
|
|
296
|
+
|
|
297
|
+
cc-cream is built to degrade silently — if a stdin field is missing or malformed
|
|
298
|
+
it just hides that segment rather than crashing. When the bar is unexpectedly
|
|
299
|
+
empty or shorter than you expect, turn on diagnostics:
|
|
300
|
+
|
|
301
|
+
```bash
|
|
302
|
+
export CC_CREAM_DEBUG=1 # then trigger a render in Claude Code
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
Each render appends a line to `~/.claude/cc-cream-debug.log` (override the path
|
|
306
|
+
with `CC_CREAM_DEBUG_LOG`) listing which segments rendered, which were dropped,
|
|
307
|
+
the resolved TTL window, and the stdin size. It never writes to the status line
|
|
308
|
+
itself, so it costs zero tokens. Unset the variable to turn it off.
|
|
309
|
+
|
|
288
310
|
## Development
|
|
289
311
|
|
|
290
312
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for how to run the tests. The runtime
|
package/package.json
CHANGED
package/src/cc-cream.js
CHANGED
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
// Reads the session JSON Claude Code pipes on stdin and prints a colored
|
|
4
4
|
// <=3-row bar. Hard rule: degrade, never crash.
|
|
5
5
|
|
|
6
|
+
import fs from 'node:fs';
|
|
6
7
|
import os from 'node:os';
|
|
7
8
|
import path from 'node:path';
|
|
8
9
|
import process from 'node:process';
|
|
9
10
|
import { loadConfig, readConfigFile } from './config.js';
|
|
10
|
-
import { render } from './render.js';
|
|
11
|
+
import { buildSegments, render } from './render.js';
|
|
11
12
|
import {
|
|
12
13
|
getSessionState,
|
|
13
14
|
nextSessionPatch,
|
|
@@ -15,7 +16,7 @@ import {
|
|
|
15
16
|
readState,
|
|
16
17
|
writeState,
|
|
17
18
|
} from './state.js';
|
|
18
|
-
import { isEntrypoint } from './utils.js';
|
|
19
|
+
import { isEntrypoint, isNum } from './utils.js';
|
|
19
20
|
|
|
20
21
|
export { DEFAULTS } from './defaults.js';
|
|
21
22
|
export { loadConfig } from './config.js';
|
|
@@ -51,8 +52,63 @@ function nowFromEnv(env) {
|
|
|
51
52
|
return rawNow && Number.isFinite(Number(rawNow)) ? Number(rawNow) : Date.now();
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
// Resolve when the cache TTL window last reset, in epoch ms (or null to hide the
|
|
56
|
+
// ttl segment). This is the ONLY filesystem read on the render path — kept here
|
|
57
|
+
// in the I/O layer so render.js and the segments stay pure. Priority: token
|
|
58
|
+
// growth this turn (reset is now) → the last recorded API timestamp → the
|
|
59
|
+
// transcript file's mtime as a last resort.
|
|
60
|
+
function resolveTtlAnchor(data, prevSessionState, now) {
|
|
61
|
+
const curTokens = data?.context_window?.total_input_tokens;
|
|
62
|
+
const prevTokens = prevSessionState?.total_input_tokens;
|
|
63
|
+
if (isNum(curTokens) && isNum(prevTokens) && curTokens > prevTokens) return now;
|
|
64
|
+
if (isNum(prevSessionState?.last_api_ts)) return prevSessionState.last_api_ts;
|
|
65
|
+
const tp = data?.transcript_path;
|
|
66
|
+
if (typeof tp !== 'string' || tp === '') return null;
|
|
67
|
+
try {
|
|
68
|
+
return fs.statSync(tp).mtimeMs;
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// CC_CREAM_DEBUG is opt-in diagnostics. Claude Code SILENTLY DISCARDS statusLine
|
|
75
|
+
// stderr (it's only surfaced under `claude --debug`, first invocation), so the
|
|
76
|
+
// channel is a log FILE — never stdout, which would cost tokens / corrupt the
|
|
77
|
+
// bar. CC_CREAM_DEBUG_LOG overrides the path (used by tests).
|
|
78
|
+
const debugEnabled = (env) => {
|
|
79
|
+
const v = env.CC_CREAM_DEBUG;
|
|
80
|
+
return typeof v === 'string' && v !== '' && v !== '0' && v.toLowerCase() !== 'false';
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
function writeDebug(env, lines) {
|
|
84
|
+
const file = env.CC_CREAM_DEBUG_LOG || path.join(os.homedir(), '.claude', 'cc-cream-debug.log');
|
|
85
|
+
try {
|
|
86
|
+
fs.appendFileSync(file, `${lines.join('\n')}\n`);
|
|
87
|
+
} catch {
|
|
88
|
+
// diagnostics must never affect the render — swallow any write failure
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Record why the bar looks the way it does: which on-by-config segments rendered
|
|
93
|
+
// and which were dropped (the usual reason a bar is shorter/emptier than
|
|
94
|
+
// expected — a missing or malformed stdin field). Recomputes the segment map
|
|
95
|
+
// through buildSegments() so it can never diverge from what render() drew.
|
|
96
|
+
function logDebug(env, { data, cfg, now, prevSessionState, sessionId, rawLen, ttlAnchorMs, out }) {
|
|
97
|
+
const { ttlMin, segs } = buildSegments(data, cfg, env, now, prevSessionState, ttlAnchorMs);
|
|
98
|
+
const onIds = Object.keys(cfg.segments).filter((id) => cfg.segments[id].on);
|
|
99
|
+
const visible = onIds.filter((id) => segs[id]);
|
|
100
|
+
const hidden = onIds.filter((id) => !segs[id]);
|
|
101
|
+
writeDebug(env, [
|
|
102
|
+
`[${new Date(now).toISOString()}] session=${sessionId ?? 'none'} stdinBytes=${rawLen} ttlMin=${ttlMin} ttlAnchor=${ttlAnchorMs ?? 'none'}`,
|
|
103
|
+
` output=${out ? JSON.stringify(out) : '<empty>'}`,
|
|
104
|
+
` visible=[${visible.join(',')}]`,
|
|
105
|
+
` hidden(on-but-absent)=[${hidden.join(',')}]`,
|
|
106
|
+
]);
|
|
107
|
+
}
|
|
108
|
+
|
|
54
109
|
async function main() {
|
|
55
|
-
const
|
|
110
|
+
const raw = await readStdin();
|
|
111
|
+
const data = parseSession(raw);
|
|
56
112
|
const cfg = loadConfig(readConfigFile());
|
|
57
113
|
const now = nowFromEnv(process.env);
|
|
58
114
|
|
|
@@ -61,9 +117,14 @@ async function main() {
|
|
|
61
117
|
const state = sessionId ? readState(stateFile) : {};
|
|
62
118
|
const prevSessionState = getSessionState(state, sessionId);
|
|
63
119
|
|
|
64
|
-
const
|
|
120
|
+
const ttlAnchorMs = resolveTtlAnchor(data, prevSessionState, now);
|
|
121
|
+
const out = render(data, cfg, process.env, now, prevSessionState, ttlAnchorMs);
|
|
65
122
|
if (out) process.stdout.write(`${out}\n`);
|
|
66
123
|
|
|
124
|
+
if (debugEnabled(process.env)) {
|
|
125
|
+
logDebug(process.env, { data, cfg, now, prevSessionState, sessionId, rawLen: raw.length, ttlAnchorMs, out });
|
|
126
|
+
}
|
|
127
|
+
|
|
67
128
|
if (sessionId) {
|
|
68
129
|
const patch = nextSessionPatch(data, prevSessionState, cfg, now);
|
|
69
130
|
writeState(stateFile, patchSessionState(state, sessionId, patch));
|
package/src/config.js
CHANGED
|
@@ -4,6 +4,10 @@ import path from 'node:path';
|
|
|
4
4
|
import { DEFAULTS } from './defaults.js';
|
|
5
5
|
import { clone, isNum, numOr } from './utils.js';
|
|
6
6
|
|
|
7
|
+
// Each normalizer is `(value, fallback) => value | fallback`: it returns the
|
|
8
|
+
// value when it's in-domain, else the fallback. The same table drives BOTH the
|
|
9
|
+
// forgiving merge (fallback = the default) and the --check-config doctor
|
|
10
|
+
// (fallback = a sentinel, so a returned sentinel means "rejected").
|
|
7
11
|
const boolOr = (v, d) => (typeof v === 'boolean' ? v : d);
|
|
8
12
|
const rowOr = (v, d) => (v === 1 || v === 2 || v === 3 ? v : d);
|
|
9
13
|
const posOr = (v, d) => (isNum(v) && v > 0 ? v : d); // a ceiling of 0/neg would divide-by-zero
|
|
@@ -11,6 +15,7 @@ const basisOr = (v, d) => (v === 'window' || v === 'ceiling' ? v : d);
|
|
|
11
15
|
const ctxDisplayOr = (v, d) => (v === 'basis' || v === 'window' ? v : d);
|
|
12
16
|
const hourOr = (v, d) => (isNum(v) && v >= 0 && v <= 23 ? v : d);
|
|
13
17
|
const percentageOr = (v, d) => (v === 'consumed' || v === 'remaining' ? v : d);
|
|
18
|
+
const numbersOr = (v, d) => (v === 'compact' || v === 'exact' ? v : d);
|
|
14
19
|
|
|
15
20
|
function ttlOr(v, d) {
|
|
16
21
|
if (v === 'auto') return 'auto';
|
|
@@ -19,11 +24,37 @@ function ttlOr(v, d) {
|
|
|
19
24
|
return d;
|
|
20
25
|
}
|
|
21
26
|
|
|
27
|
+
// Top-level config keys and their normalizers (besides `segments`, handled below).
|
|
28
|
+
const TOP_LEVEL = {
|
|
29
|
+
numbers: numbersOr,
|
|
30
|
+
ttl: ttlOr,
|
|
31
|
+
percentage: percentageOr,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Per-segment field normalizers. The set of fields VALID for a given segment is
|
|
35
|
+
// that segment's own keys in DEFAULTS (so `ctx` accepts `ceiling`, `peak`
|
|
36
|
+
// accepts `start`/`end`, etc.); this table just says how each is normalized.
|
|
37
|
+
const SEGMENT_FIELDS = {
|
|
38
|
+
on: boolOr,
|
|
39
|
+
row: rowOr,
|
|
40
|
+
order: numOr,
|
|
41
|
+
amber: numOr,
|
|
42
|
+
orange: numOr,
|
|
43
|
+
red: numOr,
|
|
44
|
+
drop: posOr,
|
|
45
|
+
drop_recover: posOr,
|
|
46
|
+
basis: basisOr,
|
|
47
|
+
ceiling: posOr,
|
|
48
|
+
display: ctxDisplayOr,
|
|
49
|
+
start: hourOr,
|
|
50
|
+
end: hourOr,
|
|
51
|
+
};
|
|
52
|
+
|
|
22
53
|
function mergeConfig(parsed) {
|
|
23
54
|
const cfg = clone(DEFAULTS);
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
55
|
+
for (const [key, norm] of Object.entries(TOP_LEVEL)) {
|
|
56
|
+
cfg[key] = norm(parsed[key], DEFAULTS[key]);
|
|
57
|
+
}
|
|
27
58
|
|
|
28
59
|
const segs = parsed.segments;
|
|
29
60
|
if (segs && typeof segs === 'object' && !Array.isArray(segs)) {
|
|
@@ -32,19 +63,10 @@ function mergeConfig(parsed) {
|
|
|
32
63
|
const s = segs[id];
|
|
33
64
|
const out = clone(def);
|
|
34
65
|
if (s && typeof s === 'object' && !Array.isArray(s)) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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);
|
|
66
|
+
for (const field of Object.keys(def)) {
|
|
67
|
+
const norm = SEGMENT_FIELDS[field];
|
|
68
|
+
if (norm) out[field] = norm(s[field], def[field]);
|
|
69
|
+
}
|
|
48
70
|
}
|
|
49
71
|
cfg.segments[id] = out;
|
|
50
72
|
}
|
|
@@ -65,6 +87,59 @@ export function loadConfig(raw) {
|
|
|
65
87
|
return mergeConfig(parsed);
|
|
66
88
|
}
|
|
67
89
|
|
|
90
|
+
// Diagnose a parsed config object: report unknown keys and out-of-domain values
|
|
91
|
+
// using the same schema table the merge uses. Returns a list of human-readable
|
|
92
|
+
// problems (empty = clean). The runtime silently ignores these (per-field
|
|
93
|
+
// fallback); the doctor surfaces them so a typo'd key isn't a silent no-op.
|
|
94
|
+
export function checkConfig(parsed) {
|
|
95
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
96
|
+
return ['cc-cream.json must be a JSON object.'];
|
|
97
|
+
}
|
|
98
|
+
const problems = [];
|
|
99
|
+
const INVALID = Symbol('invalid');
|
|
100
|
+
|
|
101
|
+
for (const key of Object.keys(parsed)) {
|
|
102
|
+
if (key === 'segments') continue;
|
|
103
|
+
const norm = TOP_LEVEL[key];
|
|
104
|
+
if (!norm) {
|
|
105
|
+
problems.push(`unknown top-level key: "${key}"`);
|
|
106
|
+
} else if (norm(parsed[key], INVALID) === INVALID) {
|
|
107
|
+
problems.push(`out-of-domain value for "${key}": ${JSON.stringify(parsed[key])}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const segs = parsed.segments;
|
|
112
|
+
if (segs !== undefined) {
|
|
113
|
+
if (!segs || typeof segs !== 'object' || Array.isArray(segs)) {
|
|
114
|
+
problems.push('"segments" must be an object.');
|
|
115
|
+
} else {
|
|
116
|
+
for (const id of Object.keys(segs)) {
|
|
117
|
+
if (!(id in DEFAULTS.segments)) {
|
|
118
|
+
problems.push(`unknown segment: "${id}"`);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const s = segs[id];
|
|
122
|
+
if (!s || typeof s !== 'object' || Array.isArray(s)) {
|
|
123
|
+
problems.push(`segment "${id}" must be an object.`);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const def = DEFAULTS.segments[id];
|
|
127
|
+
for (const field of Object.keys(s)) {
|
|
128
|
+
if (!(field in def)) {
|
|
129
|
+
problems.push(`unknown field on "${id}": "${field}"`);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const norm = SEGMENT_FIELDS[field];
|
|
133
|
+
if (norm && norm(s[field], INVALID) === INVALID) {
|
|
134
|
+
problems.push(`out-of-domain value for "${id}.${field}": ${JSON.stringify(s[field])}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return problems;
|
|
141
|
+
}
|
|
142
|
+
|
|
68
143
|
export function readConfigFile() {
|
|
69
144
|
try {
|
|
70
145
|
return fs.readFileSync(path.join(os.homedir(), '.claude', 'cc-cream.json'), 'utf8');
|
package/src/install.js
CHANGED
|
@@ -14,34 +14,42 @@ import os from 'node:os';
|
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import process from 'node:process';
|
|
16
16
|
import readline from 'node:readline';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { checkConfig } from './config.js';
|
|
19
|
+
import { isSafeToWrite, readSettings as readSettingsFile, writeFileAtomic } from './settings.js';
|
|
17
20
|
import { isEntrypoint } from './utils.js';
|
|
18
21
|
|
|
22
|
+
export { writeFileAtomic } from './settings.js';
|
|
23
|
+
|
|
19
24
|
const TRUST_NOTE =
|
|
20
25
|
'Claude Code must be trusted and possibly restarted for the status line to appear.';
|
|
21
26
|
|
|
22
|
-
// The
|
|
27
|
+
// The statusLine command: a missing-file guard, then exec node on an ABSOLUTE
|
|
28
|
+
// entrypoint. Both install modes use this one shape — only the entrypoint path
|
|
29
|
+
// differs (plugin cache vs the copied home runtime).
|
|
30
|
+
//
|
|
23
31
|
// `nodePath` is the ABSOLUTE node binary, resolved once at setup time — the
|
|
24
32
|
// statusLine subprocess may not inherit the user's PATH, so a bare `node` is
|
|
25
|
-
// unsafe.
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
33
|
+
// unsafe. `entrypoint` is the absolute path to cc-cream.js.
|
|
34
|
+
//
|
|
35
|
+
// Why no version resolution here: `${CLAUDE_PLUGIN_ROOT}` does NOT expand in the
|
|
36
|
+
// statusLine command context (only in hook/MCP/command contexts — verified), so
|
|
37
|
+
// the command can't discover the current plugin version at render time. Instead
|
|
38
|
+
// the SessionStart hook — which DOES get `${CLAUDE_PLUGIN_ROOT}` — bakes the
|
|
39
|
+
// current version's absolute path here and re-pins it after `/plugin update`.
|
|
31
40
|
//
|
|
32
|
-
// The
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
// exit 0 instead of running a bare relative `src/cc-cream.js` that crashes with
|
|
41
|
+
// The `[ -f "<entrypoint>" ] || exit 0` guard degrades to a silent exit 0 when
|
|
42
|
+
// the entrypoint is gone — the state left behind if a user runs `/plugin
|
|
43
|
+
// uninstall cc-cream` WITHOUT first running `/cc-cream:uninstall`, so a stale
|
|
44
|
+
// statusLine outlives the deleted cache. Without it, node would crash with
|
|
37
45
|
// MODULE_NOT_FOUND on every render. "Degrade, never crash" (CLAUDE.md). `exec`
|
|
38
46
|
// replaces the shell so stdin/stdout pass straight through to the renderer.
|
|
39
|
-
export function
|
|
40
|
-
return `
|
|
47
|
+
export function statusLineCommand(nodePath, entrypoint) {
|
|
48
|
+
return `[ -f "${entrypoint}" ] || exit 0; exec "${nodePath}" "${entrypoint}"`;
|
|
41
49
|
}
|
|
42
50
|
|
|
43
51
|
// `desired` is considered already installed if it matches the planned command
|
|
44
|
-
// verbatim (so
|
|
52
|
+
// verbatim (so a changed version path or node path re-plans), at refreshInterval 60.
|
|
45
53
|
function isInstalled(existing, command) {
|
|
46
54
|
return (
|
|
47
55
|
!!existing &&
|
|
@@ -90,21 +98,19 @@ export function planUninstall(settings) {
|
|
|
90
98
|
}
|
|
91
99
|
|
|
92
100
|
// Decide what to do. Returns { settings, changed, messages, needsConsent }.
|
|
93
|
-
// `consent` is the user's yes/no when
|
|
101
|
+
// `consent` is the user's yes/no when a FOREIGN statusLine must be replaced.
|
|
94
102
|
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
// -
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
export function plan(settings, { entrypoint, consent,
|
|
103
|
+
// Both install modes produce the same command shape (statusLineCommand); only
|
|
104
|
+
// the entrypoint differs:
|
|
105
|
+
// - manual (default): the copied-to-home runtime (~/.claude/cc-cream/cc-cream.js).
|
|
106
|
+
// - plugin: the versioned plugin-cache entrypoint (install.js's sibling).
|
|
107
|
+
// Pass `{ entrypoint, nodePath }` for either.
|
|
108
|
+
export function plan(settings, { entrypoint, consent, nodePath } = {}) {
|
|
101
109
|
const s = settings && typeof settings === 'object' ? settings : {};
|
|
102
110
|
const existing = s.statusLine;
|
|
103
111
|
const messages = [];
|
|
104
112
|
|
|
105
|
-
const command =
|
|
106
|
-
? autoUpdateCommand(nodePath)
|
|
107
|
-
: `node ${entrypoint}`;
|
|
113
|
+
const command = statusLineCommand(nodePath, entrypoint);
|
|
108
114
|
const desired = { type: 'command', command, refreshInterval: 60 };
|
|
109
115
|
// Preserve any user padding — it shrinks the 80-col budget (PRD §7).
|
|
110
116
|
if (existing && typeof existing === 'object' && existing.padding !== undefined) {
|
|
@@ -116,9 +122,11 @@ export function plan(settings, { entrypoint, consent, plugin = false, nodePath }
|
|
|
116
122
|
return { settings: s, changed: false, messages, needsConsent: false };
|
|
117
123
|
}
|
|
118
124
|
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
125
|
+
// A FOREIGN statusLine must be confirmed before replacing. Replacing our OWN
|
|
126
|
+
// out-of-date line (e.g. re-pinning to a new version after /plugin update)
|
|
127
|
+
// needs no consent — it's ours to maintain.
|
|
128
|
+
const hasForeign = existing && typeof existing === 'object' && !isCcCreamStatusLine(existing);
|
|
129
|
+
if (hasForeign) {
|
|
122
130
|
messages.push('An existing statusLine is configured.');
|
|
123
131
|
messages.push('Replace it with cc-cream?');
|
|
124
132
|
if (consent !== true) {
|
|
@@ -129,7 +137,7 @@ export function plan(settings, { entrypoint, consent, plugin = false, nodePath }
|
|
|
129
137
|
|
|
130
138
|
messages.push('Setting the cc-cream statusLine.');
|
|
131
139
|
messages.push(TRUST_NOTE);
|
|
132
|
-
return { settings: { ...s, statusLine: desired }, changed: true, messages, needsConsent:
|
|
140
|
+
return { settings: { ...s, statusLine: desired }, changed: true, messages, needsConsent: hasForeign };
|
|
133
141
|
}
|
|
134
142
|
|
|
135
143
|
// ---------------------------------------------------------------------------
|
|
@@ -143,46 +151,26 @@ function destinationPath() {
|
|
|
143
151
|
return path.join(os.homedir(), '.claude', 'cc-cream', 'cc-cream.js');
|
|
144
152
|
}
|
|
145
153
|
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
export function writeFileAtomic(file, contents) {
|
|
152
|
-
const tmp = `${file}.tmp-${process.pid}`;
|
|
153
|
-
fs.writeFileSync(tmp, contents);
|
|
154
|
-
try {
|
|
155
|
-
fs.renameSync(tmp, file);
|
|
156
|
-
} catch (err) {
|
|
157
|
-
try { fs.rmSync(tmp, { force: true }); } catch {}
|
|
158
|
-
throw err;
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// Read settings.json safely. A MISSING or empty file -> {} (fresh start, nothing
|
|
163
|
-
// to lose). A file that exists with content but fails to parse, or parses to a
|
|
164
|
-
// non-object, is REFUSED: we exit rather than overwrite and erase the user's
|
|
165
|
-
// other settings (permissions, hooks, plugins...). This guards the one path
|
|
166
|
-
// where a blind write would be destructive.
|
|
154
|
+
// Read settings.json safely for the CLI. A MISSING or empty file -> {} (fresh
|
|
155
|
+
// start, nothing to lose); a valid object is returned as-is. Any other state
|
|
156
|
+
// (corrupt JSON, a non-object, or an unreadable file) is REFUSED: we exit rather
|
|
157
|
+
// than overwrite and erase the user's other settings (permissions, hooks,
|
|
158
|
+
// plugins...). This guards the one path where a blind write would be destructive.
|
|
167
159
|
function readSettings(file) {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
if (
|
|
171
|
-
let parsed;
|
|
172
|
-
try {
|
|
173
|
-
parsed = JSON.parse(raw);
|
|
174
|
-
} catch {
|
|
160
|
+
const { state, value } = readSettingsFile(file);
|
|
161
|
+
if (isSafeToWrite(state)) return value;
|
|
162
|
+
if (state === 'corrupt') {
|
|
175
163
|
console.error(`Error: ${file} is not valid JSON.`);
|
|
176
164
|
console.error('Refusing to write it — that would erase your other settings.');
|
|
177
165
|
console.error('Fix the JSON (or move the file aside) and re-run.');
|
|
178
|
-
|
|
179
|
-
}
|
|
180
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
166
|
+
} else if (state === 'nonobject') {
|
|
181
167
|
console.error(`Error: ${file} does not contain a JSON object.`);
|
|
182
168
|
console.error('Refusing to overwrite it. Move it aside and re-run if intended.');
|
|
183
|
-
|
|
169
|
+
} else {
|
|
170
|
+
console.error(`Error: cannot read ${file}.`);
|
|
171
|
+
console.error('Refusing to overwrite it. Fix permissions (or move it aside) and re-run.');
|
|
184
172
|
}
|
|
185
|
-
|
|
173
|
+
process.exit(1);
|
|
186
174
|
}
|
|
187
175
|
|
|
188
176
|
function runtimeFiles(sourceFile) {
|
|
@@ -276,8 +264,43 @@ async function uninstall({ purge }) {
|
|
|
276
264
|
console.log('\nRestart Claude Code to drop the bar.');
|
|
277
265
|
}
|
|
278
266
|
|
|
267
|
+
// `cc-cream-setup --check-config`: lint ~/.claude/cc-cream.json against the
|
|
268
|
+
// config schema, reporting unknown keys and out-of-domain values (which the
|
|
269
|
+
// renderer silently ignores). Exits non-zero when problems are found.
|
|
270
|
+
function checkConfigCli() {
|
|
271
|
+
const file = path.join(os.homedir(), '.claude', 'cc-cream.json');
|
|
272
|
+
if (!fs.existsSync(file)) {
|
|
273
|
+
console.log(`No config at ${file} — cc-cream uses its defaults. Nothing to check.`);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
277
|
+
if (raw.trim() === '') {
|
|
278
|
+
console.log(`${file} is empty — cc-cream uses its defaults. Nothing to check.`);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
let parsed;
|
|
282
|
+
try {
|
|
283
|
+
parsed = JSON.parse(raw);
|
|
284
|
+
} catch {
|
|
285
|
+
console.error(`Error: ${file} is not valid JSON — the whole file is ignored.`);
|
|
286
|
+
process.exit(1);
|
|
287
|
+
}
|
|
288
|
+
const problems = checkConfig(parsed);
|
|
289
|
+
if (problems.length === 0) {
|
|
290
|
+
console.log(`${file}: OK — config looks good.`);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
console.error(`${file}: ${problems.length} problem(s) — each falls back to the default:`);
|
|
294
|
+
for (const p of problems) console.error(` - ${p}`);
|
|
295
|
+
process.exit(1);
|
|
296
|
+
}
|
|
297
|
+
|
|
279
298
|
async function main() {
|
|
280
299
|
const args = process.argv.slice(2);
|
|
300
|
+
if (args.includes('--check-config')) {
|
|
301
|
+
checkConfigCli();
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
281
304
|
if (args.includes('--uninstall')) {
|
|
282
305
|
await uninstall({ purge: args.includes('--purge') });
|
|
283
306
|
return;
|
|
@@ -290,18 +313,21 @@ async function main() {
|
|
|
290
313
|
const file = settingsPath();
|
|
291
314
|
const settings = readSettings(file);
|
|
292
315
|
|
|
293
|
-
// planOpts holds
|
|
316
|
+
// planOpts holds the entrypoint + node path the command bakes in.
|
|
294
317
|
let planOpts;
|
|
295
318
|
if (plugin) {
|
|
296
|
-
// Plugin mode: the plugin cache IS the install — do NOT copy to home.
|
|
297
|
-
//
|
|
298
|
-
|
|
319
|
+
// Plugin mode: the plugin cache IS the install — do NOT copy to home. Point
|
|
320
|
+
// the statusLine at this install.js's sibling cc-cream.js, i.e. the current
|
|
321
|
+
// version's absolute path in the plugin cache. The SessionStart hook re-pins
|
|
322
|
+
// it after a /plugin update (the command can't self-resolve the version).
|
|
323
|
+
const entrypoint = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'cc-cream.js');
|
|
324
|
+
planOpts = { entrypoint, nodePath: resolveNodePath() };
|
|
299
325
|
} else {
|
|
300
326
|
// Manual / GitHub mode: copy the runtime into ~/.claude/cc-cream and point
|
|
301
|
-
// the statusLine at that copied entrypoint.
|
|
327
|
+
// the statusLine at that copied (stable) entrypoint.
|
|
302
328
|
const sourceFile = positional[0]
|
|
303
329
|
? path.resolve(positional[0])
|
|
304
|
-
: path.resolve(path.dirname(
|
|
330
|
+
: path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'cc-cream.js');
|
|
305
331
|
|
|
306
332
|
if (!fs.existsSync(sourceFile)) {
|
|
307
333
|
console.error(`Error: cc-cream.js not found at ${sourceFile}`);
|
|
@@ -313,7 +339,7 @@ async function main() {
|
|
|
313
339
|
if (copyRuntimeFiles(sourceFile, destDir)) {
|
|
314
340
|
console.log(`Copied cc-cream runtime files to ${destDir}`);
|
|
315
341
|
}
|
|
316
|
-
planOpts = { entrypoint: dest };
|
|
342
|
+
planOpts = { entrypoint: dest, nodePath: resolveNodePath() };
|
|
317
343
|
}
|
|
318
344
|
|
|
319
345
|
let result = plan(settings, planOpts);
|
package/src/render.js
CHANGED
|
@@ -3,12 +3,21 @@ import { renderSegments } from './segments.js';
|
|
|
3
3
|
import { resolveTtl } from './ttl.js';
|
|
4
4
|
import { paint } from './utils.js';
|
|
5
5
|
|
|
6
|
-
//
|
|
7
|
-
|
|
6
|
+
// Build the raw segment map (id → {text,color}|null) plus the resolved ttlMin.
|
|
7
|
+
// Shared by render() and the CC_CREAM_DEBUG diagnostics so the segment logic runs
|
|
8
|
+
// through exactly one path. `ttlAnchorMs` is injected by the I/O layer.
|
|
9
|
+
export function buildSegments(data, cfg, env, now, prevSessionState = null, ttlAnchorMs = null) {
|
|
8
10
|
const ttlMin = resolveTtl({ rateLimits: data?.rate_limits, config: cfg, env });
|
|
9
11
|
// CC_CREAM_TZ is an internal test/diagnostic seam, not a documented config key.
|
|
10
12
|
const tz = env?.CC_CREAM_TZ || 'America/Los_Angeles';
|
|
11
|
-
|
|
13
|
+
return { ttlMin, segs: renderSegments(data, cfg, ttlMin, now, prevSessionState, tz, ttlAnchorMs) };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Assemble enabled+visible segments into up to three rows. `ttlAnchorMs` (the
|
|
17
|
+
// resolved cache-window reset time) is computed by the I/O layer and injected so
|
|
18
|
+
// render and the segments stay free of filesystem access.
|
|
19
|
+
export function render(data, cfg, env, now, prevSessionState = null, ttlAnchorMs = null) {
|
|
20
|
+
const { segs } = buildSegments(data, cfg, env, now, prevSessionState, ttlAnchorMs);
|
|
12
21
|
|
|
13
22
|
const visible = (id, row) => cfg.segments[id]?.on && segs[id] && cfg.segments[id].row === row;
|
|
14
23
|
const byOrder = (a, b) => cfg.segments[a].order - cfg.segments[b].order;
|
package/src/segments.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
1
|
import { band, countdown, flipPct, fmtNum, isNum, isPeak, numOr, pad2 } from './utils.js';
|
|
3
2
|
import { hasWindow } from './ttl.js';
|
|
4
3
|
|
|
@@ -56,25 +55,12 @@ function segCache(data, cfg, prevCachePct, recovering) {
|
|
|
56
55
|
return { text: `cache:${pct}%`, color };
|
|
57
56
|
}
|
|
58
57
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (tokensGrew) {
|
|
66
|
-
anchorMs = now;
|
|
67
|
-
} else if (isNum(prevSessionState?.last_api_ts)) {
|
|
68
|
-
anchorMs = prevSessionState.last_api_ts;
|
|
69
|
-
} else {
|
|
70
|
-
const tp = data?.transcript_path;
|
|
71
|
-
if (typeof tp !== 'string' || tp === '') return null;
|
|
72
|
-
try {
|
|
73
|
-
anchorMs = fs.statSync(tp).mtimeMs;
|
|
74
|
-
} catch {
|
|
75
|
-
return null;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
58
|
+
// Pure: the TTL anchor (when the cache window last reset) is resolved upstream in
|
|
59
|
+
// the I/O layer — see resolveTtlAnchor() in cc-cream.js — and injected as
|
|
60
|
+
// `anchorMs`, so this segment does no filesystem access. A null anchor (no token
|
|
61
|
+
// growth, no last_api_ts, no readable transcript) hides the segment.
|
|
62
|
+
function segTtl(cfg, ttlMin, now, anchorMs) {
|
|
63
|
+
if (!isNum(anchorMs)) return null;
|
|
78
64
|
const elapsedMin = Math.floor(Math.max(0, now - anchorMs) / 60000);
|
|
79
65
|
const remainingMin = Math.max(0, ttlMin - elapsedMin);
|
|
80
66
|
const text = `ttl:${pad2(Math.floor(remainingMin / 60))}:${pad2(remainingMin % 60)}`;
|
|
@@ -158,7 +144,7 @@ function segBurn(fiveHour, prev, now) {
|
|
|
158
144
|
return { text: h >= 1 ? `~${h}h${pad2(m)}m` : `~${minEta}m`, color: null };
|
|
159
145
|
}
|
|
160
146
|
|
|
161
|
-
export function renderSegments(data, cfg, ttlMin, now, prevSessionState = null, tz = 'America/Los_Angeles') {
|
|
147
|
+
export function renderSegments(data, cfg, ttlMin, now, prevSessionState = null, tz = 'America/Los_Angeles', ttlAnchorMs = null) {
|
|
162
148
|
return {
|
|
163
149
|
model: segModel(data),
|
|
164
150
|
ctx: segCtx(data, cfg),
|
|
@@ -168,7 +154,7 @@ export function renderSegments(data, cfg, ttlMin, now, prevSessionState = null,
|
|
|
168
154
|
prevSessionState && isNum(prevSessionState.cache_pct) ? prevSessionState.cache_pct : undefined,
|
|
169
155
|
prevSessionState?.recovering === true,
|
|
170
156
|
),
|
|
171
|
-
ttl: segTtl(
|
|
157
|
+
ttl: segTtl(cfg, ttlMin, now, ttlAnchorMs),
|
|
172
158
|
cost: segCost(data),
|
|
173
159
|
'5h': segRate(data?.rate_limits?.five_hour, '5h', cfg, '5h', now),
|
|
174
160
|
'7d': segRate(data?.rate_limits?.seven_day, '7d', cfg, '7d', now),
|
package/src/settings.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Shared settings.json I/O for the installer (src/install.js) and the
|
|
2
|
+
// SessionStart hook (hooks/auto-setup.js). Both must read settings.json without
|
|
3
|
+
// ever destroying it, and write it atomically. Previously each carried its own
|
|
4
|
+
// copy of this safety-critical parsing; they live here once so they can't drift.
|
|
5
|
+
//
|
|
6
|
+
// `readSettings` does NOT decide policy — it classifies the file and lets each
|
|
7
|
+
// caller choose what to do (the installer refuses + exits on a corrupt file; the
|
|
8
|
+
// hook stays silent and leaves it alone). That split is the whole reason this is
|
|
9
|
+
// a classifier returning `{ state, value }` rather than a function that throws.
|
|
10
|
+
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import process from 'node:process';
|
|
13
|
+
|
|
14
|
+
// Read and classify settings.json. Returns `{ state, value }`:
|
|
15
|
+
// missing — file absent → value {}
|
|
16
|
+
// empty — present but whitespace → value {}
|
|
17
|
+
// object — valid JSON object → value <parsed>
|
|
18
|
+
// nonobject — valid JSON, not an object → value null
|
|
19
|
+
// corrupt — invalid JSON → value null
|
|
20
|
+
// unreadable— read failed (perms, …) → value null
|
|
21
|
+
// The destructive-write guard lives in the caller: a non-{missing,empty,object}
|
|
22
|
+
// state means "we cannot safely overwrite this — it holds the user's other
|
|
23
|
+
// config (permissions, hooks, plugins, MCP)".
|
|
24
|
+
export function readSettings(file) {
|
|
25
|
+
let raw;
|
|
26
|
+
try {
|
|
27
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
28
|
+
} catch (err) {
|
|
29
|
+
return err.code === 'ENOENT' ? { state: 'missing', value: {} } : { state: 'unreadable', value: null };
|
|
30
|
+
}
|
|
31
|
+
if (raw.trim() === '') return { state: 'empty', value: {} };
|
|
32
|
+
let parsed;
|
|
33
|
+
try {
|
|
34
|
+
parsed = JSON.parse(raw);
|
|
35
|
+
} catch {
|
|
36
|
+
return { state: 'corrupt', value: null };
|
|
37
|
+
}
|
|
38
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
39
|
+
return { state: 'nonobject', value: null };
|
|
40
|
+
}
|
|
41
|
+
return { state: 'object', value: parsed };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// True when `state` is safe to start from and overwrite (the file holds nothing
|
|
45
|
+
// to lose, or a well-formed object we can extend).
|
|
46
|
+
export function isSafeToWrite(state) {
|
|
47
|
+
return state === 'missing' || state === 'empty' || state === 'object';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Write `contents` to `file` atomically: write a sibling temp file, then rename
|
|
51
|
+
// over the target (rename is atomic within a filesystem). settings.json holds
|
|
52
|
+
// the user's permissions/hooks/plugins/MCP config — a direct writeFileSync that
|
|
53
|
+
// is interrupted (crash, ENOSPC) could truncate it and erase all of that. The
|
|
54
|
+
// temp file shares the target's directory so the rename never crosses devices.
|
|
55
|
+
export function writeFileAtomic(file, contents) {
|
|
56
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
57
|
+
fs.writeFileSync(tmp, contents);
|
|
58
|
+
try {
|
|
59
|
+
fs.renameSync(tmp, file);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
try { fs.rmSync(tmp, { force: true }); } catch {}
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
64
|
+
}
|