cc-cream 0.1.16 → 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 +23 -0
- package/README.md +13 -0
- package/package.json +1 -1
- package/src/cc-cream.js +5 -20
- package/src/install.js +24 -5
- package/src/state.js +37 -6
- package/src/utils.js +32 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,27 @@ 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
|
+
|
|
20
|
+
## [0.1.17] — 2026-05-29
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- **`cc-cream-setup` and the `/cc-cream:*` slash commands silently did nothing when `~/.claude` is a symlink.** `install.js` had the same symlink-fragile entrypoint guard fixed in the renderer for 0.1.16 (`import.meta.url` is canonicalized by Node's ESM loader; `process.argv[1]` is not), so running it from a symlinked path skipped `main()` entirely — exit 0, no output, settings.json untouched. The "am-I-the-entrypoint?" check is now a single symlink-robust helper (`isEntrypoint` in `src/utils.js`) shared by both `cc-cream.js` and `install.js`. Caught by the new install-journey smoke tests.
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
- **End-to-end install/uninstall journey smoke tests** (`features/27-install-journey.feature`, CREAM-fxsusmgd). They stage a real plugin cache the way `/plugin install` lays it out, run the actual `SessionStart` hook and `install.js` as child processes, and execute the baked statusLine command through `sh -c` exactly as Claude Code does — guarding the *seams* unit specs can't: cache layout, the settings.json lifecycle, command order, the empty-cache guard (0.1.15), and symlinked config dirs (0.1.16). CI-safe; no live `claude` CLI needed.
|
|
27
|
+
|
|
7
28
|
## [0.1.16] — 2026-05-29
|
|
8
29
|
|
|
9
30
|
### Fixed
|
|
@@ -134,6 +155,8 @@ line and prints a colored ≤3-row bar — zero tokens, the model never sees it.
|
|
|
134
155
|
- Supports **macOS and Linux**; Windows is a planned fast-follow.
|
|
135
156
|
- Requires Claude Code **2.1.132+** (`effort` / `thinking` need 2.1.145+).
|
|
136
157
|
|
|
158
|
+
[0.1.18]: https://github.com/bart-turczynski/cc-cream/compare/v0.1.17...v0.1.18
|
|
159
|
+
[0.1.17]: https://github.com/bart-turczynski/cc-cream/compare/v0.1.16...v0.1.17
|
|
137
160
|
[0.1.16]: https://github.com/bart-turczynski/cc-cream/compare/v0.1.15...v0.1.16
|
|
138
161
|
[0.1.15]: https://github.com/bart-turczynski/cc-cream/compare/v0.1.14...v0.1.15
|
|
139
162
|
[0.1.6]: https://github.com/bart-turczynski/cc-cream/compare/v0.1.5...v0.1.6
|
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/cc-cream.js
CHANGED
|
@@ -3,11 +3,9 @@
|
|
|
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 { realpathSync } from 'node:fs';
|
|
7
6
|
import os from 'node:os';
|
|
8
7
|
import path from 'node:path';
|
|
9
8
|
import process from 'node:process';
|
|
10
|
-
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
11
9
|
import { loadConfig, readConfigFile } from './config.js';
|
|
12
10
|
import { render } from './render.js';
|
|
13
11
|
import {
|
|
@@ -17,6 +15,7 @@ import {
|
|
|
17
15
|
readState,
|
|
18
16
|
writeState,
|
|
19
17
|
} from './state.js';
|
|
18
|
+
import { isEntrypoint } from './utils.js';
|
|
20
19
|
|
|
21
20
|
export { DEFAULTS } from './defaults.js';
|
|
22
21
|
export { loadConfig } from './config.js';
|
|
@@ -73,23 +72,9 @@ async function main() {
|
|
|
73
72
|
process.exit(0);
|
|
74
73
|
}
|
|
75
74
|
|
|
76
|
-
//
|
|
77
|
-
// import.meta.url (
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
// or synced via iCloud/Dropbox — skipping main() so the bar renders NOTHING with no
|
|
81
|
-
// error. Comparing realpaths makes the symlinked and canonical paths match. Falls
|
|
82
|
-
// back to the href comparison if realpath fails (e.g. a path that no longer exists).
|
|
83
|
-
function isEntrypoint() {
|
|
84
|
-
const arg = process.argv[1];
|
|
85
|
-
if (!arg) return false;
|
|
86
|
-
try {
|
|
87
|
-
return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(arg);
|
|
88
|
-
} catch {
|
|
89
|
-
return import.meta.url === pathToFileURL(arg).href;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (isEntrypoint()) {
|
|
75
|
+
// isEntrypoint (src/utils.js) is symlink-robust — see its comment. A plain
|
|
76
|
+
// import.meta.url === pathToFileURL(argv[1]) check fails under a symlinked path and
|
|
77
|
+
// renders nothing with no error.
|
|
78
|
+
if (isEntrypoint(import.meta.url)) {
|
|
94
79
|
main();
|
|
95
80
|
}
|
package/src/install.js
CHANGED
|
@@ -14,7 +14,7 @@ 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 {
|
|
17
|
+
import { isEntrypoint } from './utils.js';
|
|
18
18
|
|
|
19
19
|
const TRUST_NOTE =
|
|
20
20
|
'Claude Code must be trusted and possibly restarted for the status line to appear.';
|
|
@@ -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,11 +339,14 @@ 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
|
}
|
|
330
346
|
|
|
331
|
-
|
|
347
|
+
// isEntrypoint (src/utils.js) is symlink-robust: a plain href comparison fails when
|
|
348
|
+
// install.js runs from a symlinked path (e.g. a dotfile-managed ~/.claude), which
|
|
349
|
+
// would make `cc-cream-setup` / the slash commands silently do nothing.
|
|
350
|
+
if (isEntrypoint(import.meta.url)) {
|
|
332
351
|
main();
|
|
333
352
|
}
|
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
|
@@ -1,5 +1,25 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
1
4
|
import { ANSI } from './defaults.js';
|
|
2
5
|
|
|
6
|
+
// Robust "is this module the process entrypoint?" check, shared by every module
|
|
7
|
+
// that may run both as a script and as an import (cc-cream.js, install.js). Node's
|
|
8
|
+
// ESM loader canonicalizes import.meta.url (symlinks resolved) but leaves
|
|
9
|
+
// process.argv[1] as-invoked, so a plain href comparison fails when the module runs
|
|
10
|
+
// from a symlinked path — e.g. a ~/.claude managed by a dotfile manager
|
|
11
|
+
// (stow/chezmoi/yadm) or synced via iCloud/Dropbox — silently skipping main().
|
|
12
|
+
// Comparing realpaths fixes it; falls back to the href compare if realpath throws
|
|
13
|
+
// (e.g. a path that no longer exists). Pass the caller's import.meta.url.
|
|
14
|
+
export function isEntrypoint(metaUrl, arg = process.argv[1]) {
|
|
15
|
+
if (!arg) return false;
|
|
16
|
+
try {
|
|
17
|
+
return realpathSync(fileURLToPath(metaUrl)) === realpathSync(arg);
|
|
18
|
+
} catch {
|
|
19
|
+
return metaUrl === pathToFileURL(arg).href;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
3
23
|
export const clone = (o) => JSON.parse(JSON.stringify(o));
|
|
4
24
|
export const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
5
25
|
export const numOr = (v, d) => (isNum(v) ? v : d);
|
|
@@ -12,8 +32,19 @@ export function fmtNum(n, mode) {
|
|
|
12
32
|
return String(n);
|
|
13
33
|
}
|
|
14
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
|
+
|
|
15
45
|
export function paint(text, color) {
|
|
16
|
-
|
|
46
|
+
const clean = sanitize(text);
|
|
47
|
+
return color && ANSI[color] ? `${ANSI[color]}${clean}\x1b[0m` : clean;
|
|
17
48
|
}
|
|
18
49
|
|
|
19
50
|
// 3-arg form: band(value, amber, red) — used by ttl / rate limits.
|