cc-cream 0.1.17 → 0.1.18
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 +13 -0
- package/package.json +1 -1
- package/src/install.js +19 -3
- package/src/state.js +37 -6
- package/src/utils.js +12 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,19 @@ 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.1.18] — 2026-05-29
|
|
8
|
+
|
|
9
|
+
### Security
|
|
10
|
+
- **Status-line text is now stripped of terminal control characters before output.** Three stdin-derived fields — `model.display_name`, `session_name`, and `effort.level` — were written to the terminal verbatim on every render. Because `session_name` can be derived from conversation content (which may include untrusted material), an embedded ANSI/OSC escape sequence would have been interpreted by the terminal (window-title/clipboard rewrites via OSC, or cursor/erase sequences that spoof or hide output). `paint()` now passes every segment through a `sanitize()` pass that drops C0/C1 control bytes (incl. ESC, BEL, DEL) while preserving the tool's own color codes, which are added afterward. The bar is purely visual, so the strip is lossless.
|
|
11
|
+
- **A crafted `session_id` can no longer corrupt the session-state map.** `session_id` is used as an object key; values of `__proto__`/`constructor`/`prototype` are now rejected in `getSessionState`/`patchSessionState`, and reads use `Object.hasOwn`.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- **Writes to `settings.json` (and the state file) are now atomic.** `install.js`, the `SessionStart` auto-setup hook, and `state.js` wrote via a direct `writeFileSync` over the live file; an interruption (crash, `ENOSPC`) mid-write could truncate `settings.json` and erase the user's permissions/hooks/plugins/MCP config — the very loss `readSettings` works to avoid. They now write a sibling temp file and `rename` it over the target (atomic within a filesystem; the temp shares the target's directory so the rename never crosses devices).
|
|
15
|
+
- **The plugin auto-update command now quotes the node binary path** (`exec "${nodePath}"`), so a node path containing spaces no longer breaks the status line.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- **The session-state map is capped at 50 entries**, evicting the least-recently-touched sessions. It previously gained one key per `session_id` and was never pruned, growing without bound.
|
|
19
|
+
|
|
7
20
|
## [0.1.17] — 2026-05-29
|
|
8
21
|
|
|
9
22
|
### Fixed
|
|
@@ -142,6 +155,7 @@ line and prints a colored ≤3-row bar — zero tokens, the model never sees it.
|
|
|
142
155
|
- Supports **macOS and Linux**; Windows is a planned fast-follow.
|
|
143
156
|
- Requires Claude Code **2.1.132+** (`effort` / `thinking` need 2.1.145+).
|
|
144
157
|
|
|
158
|
+
[0.1.18]: https://github.com/bart-turczynski/cc-cream/compare/v0.1.17...v0.1.18
|
|
145
159
|
[0.1.17]: https://github.com/bart-turczynski/cc-cream/compare/v0.1.16...v0.1.17
|
|
146
160
|
[0.1.16]: https://github.com/bart-turczynski/cc-cream/compare/v0.1.15...v0.1.16
|
|
147
161
|
[0.1.15]: https://github.com/bart-turczynski/cc-cream/compare/v0.1.14...v0.1.15
|
package/README.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# cc-cream
|
|
2
2
|
|
|
3
|
+
[](https://github.com/bart-turczynski/cc-cream/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/cc-cream)
|
|
5
|
+
[](https://socket.dev/npm/package/cc-cream)
|
|
6
|
+
[](https://bundlephobia.com/package/cc-cream)
|
|
7
|
+
[](https://github.com/bart-turczynski/cc-cream/blob/main/LICENSE)
|
|
8
|
+
|
|
3
9
|
**C.R.E.A.M. — Cache Rules Everything Around Me.**
|
|
4
10
|
|
|
5
11
|
A lightweight status-line tool for [Claude Code](https://claude.com/claude-code)
|
|
@@ -116,6 +122,13 @@ The installer:
|
|
|
116
122
|
After install, Claude Code must be **trusted** for the directory (if prompted),
|
|
117
123
|
and you may need to **restart** it for the bar to appear.
|
|
118
124
|
|
|
125
|
+
> **Pick one install method.** If you wire cc-cream via npm/manual (Options 2–3)
|
|
126
|
+
> and *then* install the plugin, the plugin won't take over the existing wiring —
|
|
127
|
+
> it points at your home copy, so `/plugin update` won't auto-update the bar. To
|
|
128
|
+
> switch to the auto-updating plugin, run `/cc-cream:setup` (or `cc-cream-setup
|
|
129
|
+
> --uninstall` first). Nothing breaks either way; it just stays on whichever
|
|
130
|
+
> method wired it.
|
|
131
|
+
|
|
119
132
|
### Uninstall
|
|
120
133
|
|
|
121
134
|
Plugin users — two steps, **in this order** (Claude Code can't clean
|
package/package.json
CHANGED
package/src/install.js
CHANGED
|
@@ -37,7 +37,7 @@ const TRUST_NOTE =
|
|
|
37
37
|
// MODULE_NOT_FOUND on every render. "Degrade, never crash" (CLAUDE.md). `exec`
|
|
38
38
|
// replaces the shell so stdin/stdout pass straight through to the renderer.
|
|
39
39
|
export function autoUpdateCommand(nodePath) {
|
|
40
|
-
return `d="$(ls -1d "\${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/*/cc-cream/*/ 2>/dev/null | grep -E '/[0-9]+(\\.[0-9]+)+/$' | sort -V | tail -1)"; [ -z "$d" ] && exit 0; exec ${nodePath} "\${d}src/cc-cream.js"`;
|
|
40
|
+
return `d="$(ls -1d "\${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/*/cc-cream/*/ 2>/dev/null | grep -E '/[0-9]+(\\.[0-9]+)+/$' | sort -V | tail -1)"; [ -z "$d" ] && exit 0; exec "${nodePath}" "\${d}src/cc-cream.js"`;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
// `desired` is considered already installed if it matches the planned command
|
|
@@ -143,6 +143,22 @@ function destinationPath() {
|
|
|
143
143
|
return path.join(os.homedir(), '.claude', 'cc-cream', 'cc-cream.js');
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
// Write `contents` to `file` atomically: write a sibling temp file, then rename
|
|
147
|
+
// over the target (rename is atomic within a filesystem). settings.json holds
|
|
148
|
+
// the user's permissions/hooks/plugins/MCP config — a direct writeFileSync that
|
|
149
|
+
// is interrupted (crash, ENOSPC) could truncate it and erase all of that. The
|
|
150
|
+
// temp file shares the target's directory so the rename never crosses devices.
|
|
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
|
+
|
|
146
162
|
// Read settings.json safely. A MISSING or empty file -> {} (fresh start, nothing
|
|
147
163
|
// to lose). A file that exists with content but fails to parse, or parses to a
|
|
148
164
|
// non-object, is REFUSED: we exit rather than overwrite and erase the user's
|
|
@@ -221,7 +237,7 @@ async function uninstall({ purge }) {
|
|
|
221
237
|
const result = planUninstall(settings);
|
|
222
238
|
for (const m of result.messages) console.log(m);
|
|
223
239
|
if (result.changed) {
|
|
224
|
-
|
|
240
|
+
writeFileAtomic(file, `${JSON.stringify(result.settings, null, 2)}\n`);
|
|
225
241
|
console.log(`\nUpdated ${file}.`);
|
|
226
242
|
}
|
|
227
243
|
|
|
@@ -323,7 +339,7 @@ async function main() {
|
|
|
323
339
|
for (const m of result.messages) console.log(m);
|
|
324
340
|
if (result.changed) {
|
|
325
341
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
326
|
-
|
|
342
|
+
writeFileAtomic(file, `${JSON.stringify(result.settings, null, 2)}\n`);
|
|
327
343
|
console.log(`\nWrote ${file}.`);
|
|
328
344
|
}
|
|
329
345
|
}
|
package/src/state.js
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import process from 'node:process';
|
|
2
3
|
import { isNum, numOr } from './utils.js';
|
|
3
4
|
|
|
5
|
+
// Cap on retained per-session entries. The state file gains one key per
|
|
6
|
+
// session_id and is never otherwise pruned, so without a cap it grows without
|
|
7
|
+
// bound. We keep the most-recently-touched sessions (by `ts`) and drop the rest.
|
|
8
|
+
const MAX_SESSIONS = 50;
|
|
9
|
+
|
|
10
|
+
// session_id is used as an object key. A value of __proto__/constructor/prototype
|
|
11
|
+
// would mutate the object's prototype instead of storing data; reject those so a
|
|
12
|
+
// crafted or poisoned id can't corrupt the session map.
|
|
13
|
+
const isUnsafeKey = (k) => k === '__proto__' || k === 'constructor' || k === 'prototype';
|
|
14
|
+
|
|
4
15
|
export function readState(stateFilePath) {
|
|
5
16
|
try {
|
|
6
17
|
const raw = fs.readFileSync(stateFilePath, 'utf8');
|
|
@@ -13,25 +24,45 @@ export function readState(stateFilePath) {
|
|
|
13
24
|
}
|
|
14
25
|
|
|
15
26
|
export function writeState(stateFilePath, state) {
|
|
27
|
+
// Atomic write: a direct writeFileSync interrupted mid-write (crash, ENOSPC)
|
|
28
|
+
// would truncate the state file. Write a sibling temp file, then rename over
|
|
29
|
+
// the target (atomic within a filesystem; the temp shares the target's dir so
|
|
30
|
+
// the rename never crosses devices). State is regenerable, so any failure
|
|
31
|
+
// degrades silently — a stateless render is fine.
|
|
32
|
+
const tmp = `${stateFilePath}.tmp-${process.pid}`;
|
|
16
33
|
try {
|
|
17
|
-
fs.writeFileSync(
|
|
34
|
+
fs.writeFileSync(tmp, JSON.stringify(state));
|
|
35
|
+
fs.renameSync(tmp, stateFilePath);
|
|
18
36
|
} catch {
|
|
19
|
-
|
|
37
|
+
try { fs.rmSync(tmp, { force: true }); } catch {}
|
|
20
38
|
}
|
|
21
39
|
}
|
|
22
40
|
|
|
23
41
|
export function getSessionState(state, sessionId) {
|
|
24
|
-
if (!sessionId || typeof sessionId !== 'string') return null;
|
|
42
|
+
if (!sessionId || typeof sessionId !== 'string' || isUnsafeKey(sessionId)) return null;
|
|
25
43
|
const sessions = state?.sessions;
|
|
26
44
|
if (!sessions || typeof sessions !== 'object') return null;
|
|
27
|
-
return sessions[sessionId]
|
|
45
|
+
return Object.hasOwn(sessions, sessionId) ? sessions[sessionId] : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Keep at most MAX_SESSIONS entries, evicting the lowest `ts` (oldest touched)
|
|
49
|
+
// first. Sessions without a numeric ts sort oldest.
|
|
50
|
+
function prune(sessions) {
|
|
51
|
+
const keys = Object.keys(sessions);
|
|
52
|
+
if (keys.length <= MAX_SESSIONS) return sessions;
|
|
53
|
+
const keep = keys
|
|
54
|
+
.sort((a, b) => numOr(sessions[b]?.ts, 0) - numOr(sessions[a]?.ts, 0))
|
|
55
|
+
.slice(0, MAX_SESSIONS);
|
|
56
|
+
const out = {};
|
|
57
|
+
for (const k of keep) out[k] = sessions[k];
|
|
58
|
+
return out;
|
|
28
59
|
}
|
|
29
60
|
|
|
30
61
|
export function patchSessionState(state, sessionId, patch) {
|
|
31
|
-
if (!sessionId || typeof sessionId !== 'string') return state;
|
|
62
|
+
if (!sessionId || typeof sessionId !== 'string' || isUnsafeKey(sessionId)) return state;
|
|
32
63
|
const sessions = { ...(state?.sessions ?? {}) };
|
|
33
64
|
sessions[sessionId] = { ...(sessions[sessionId] ?? {}), ...patch };
|
|
34
|
-
return { ...state, sessions };
|
|
65
|
+
return { ...state, sessions: prune(sessions) };
|
|
35
66
|
}
|
|
36
67
|
|
|
37
68
|
export function nextSessionPatch(data, prevSessionState, cfg, now) {
|
package/src/utils.js
CHANGED
|
@@ -32,8 +32,19 @@ export function fmtNum(n, mode) {
|
|
|
32
32
|
return String(n);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// Strip C0/C1 control characters (incl. ESC, BEL, DEL) from any text bound for
|
|
36
|
+
// the terminal. stdin fields like session_name, model.display_name, and
|
|
37
|
+
// effort.level are echoed into the status line verbatim; without this, escape
|
|
38
|
+
// sequences smuggled into them would be interpreted by the terminal (title/OSC
|
|
39
|
+
// rewrites, clipboard writes, cursor moves that spoof or hide output). The bar
|
|
40
|
+
// is purely visual, so dropping control bytes is lossless. The tool's own ANSI
|
|
41
|
+
// color codes are added AFTER sanitizing, so they survive.
|
|
42
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping control bytes is the intent
|
|
43
|
+
const sanitize = (text) => String(text).replace(/[\x00-\x1f\x7f-\x9f]/g, '');
|
|
44
|
+
|
|
35
45
|
export function paint(text, color) {
|
|
36
|
-
|
|
46
|
+
const clean = sanitize(text);
|
|
47
|
+
return color && ANSI[color] ? `${ANSI[color]}${clean}\x1b[0m` : clean;
|
|
37
48
|
}
|
|
38
49
|
|
|
39
50
|
// 3-arg form: band(value, amber, red) — used by ttl / rate limits.
|