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 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
+ [![CI](https://img.shields.io/github/actions/workflow/status/bart-turczynski/cc-cream/ci.yml?branch=main&label=CI)](https://github.com/bart-turczynski/cc-cream/actions/workflows/ci.yml)
4
+ [![npm version](https://img.shields.io/npm/v/cc-cream)](https://www.npmjs.com/package/cc-cream)
5
+ [![Socket Badge](https://socket.dev/api/badge/npm/package/cc-cream)](https://socket.dev/npm/package/cc-cream)
6
+ [![install size](https://img.shields.io/bundlephobia/minzip/cc-cream)](https://bundlephobia.com/package/cc-cream)
7
+ [![License: MIT](https://img.shields.io/npm/l/cc-cream)](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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-cream",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "Claude Code cache/context/cost status-line tool",
5
5
  "directories": {
6
6
  "doc": "docs"
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
- // Robust "is this module the entrypoint?" check. Node's ESM loader canonicalizes
77
- // import.meta.url (symlinks resolved), but process.argv[1] stays as it was invoked.
78
- // A plain href comparison therefore fails silently when cc-cream runs from a
79
- // symlinked path — e.g. a ~/.claude managed by a dotfile manager (stow/chezmoi/yadm)
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 { pathToFileURL } from 'node:url';
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
- fs.writeFileSync(file, `${JSON.stringify(result.settings, null, 2)}\n`);
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
- fs.writeFileSync(file, `${JSON.stringify(result.settings, null, 2)}\n`);
342
+ writeFileAtomic(file, `${JSON.stringify(result.settings, null, 2)}\n`);
327
343
  console.log(`\nWrote ${file}.`);
328
344
  }
329
345
  }
330
346
 
331
- if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
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(stateFilePath, JSON.stringify(state));
34
+ fs.writeFileSync(tmp, JSON.stringify(state));
35
+ fs.renameSync(tmp, stateFilePath);
18
36
  } catch {
19
- // degrade silently stateless render is fine
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] ?? null;
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
- return color && ANSI[color] ? `${ANSI[color]}${text}\x1b[0m` : text;
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.