cc-cream 0.1.18 → 0.3.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 CHANGED
@@ -4,6 +4,38 @@ 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
+ ## [Unreleased]
8
+
9
+ ## [0.3.0] — 2026-05-30
10
+
11
+ ### Added
12
+ - **`cc-cream-setup --status` — a read-only footprint report.** Because no Claude Code host removal path drops our `statusLine` or garbage-collects the version cache, users couldn't easily tell whether cc-cream had fully gone away. `--status` reports the whole footprint in one shot: the `statusLine` wiring (flagging a stale/ghost line whose entrypoint is missing), every cached plugin version, the marketplace clone + both registrations, the auto-wire marker, session state, config, and the manual runtime copy — with a "clean slate" verdict when nothing remains and removal guidance when something does (CREAM-zgdcbmfj).
13
+
14
+ ### Fixed
15
+ - **The status bar no longer zombies after the plugin is uninstalled.** No Claude Code host removal path deletes our `statusLine` *or* the version cache: `/plugin uninstall` is partial (it deregisters the plugin but leaves the cache tree and our `statusLine`), so the entrypoint still exists and the `[ -f … ] || exit 0` guard can never fire — the bar kept rendering every session, with no in-product way out (`/cc-cream:uninstall` deregisters with the plugin). The renderer now defends itself: when it detects it's running **from the plugin cache** while cc-cream is **absent from `~/.claude/plugins/installed_plugins.json`**, it exits 0 silently. The check costs one tiny read and runs *only* on the plugin-cache path — manual/npm installs skip it entirely. A corrupt/unreadable registry is treated as "still installed" so a transient glitch can't suppress a live bar (CREAM-uchemxln).
16
+ - **Non-interactive `--force` no longer prints a contradictory "Declined … then replaced" receipt.** The installer's consent path printed the detection-only first plan pass — including a speculative "Declined — your existing statusLine is unchanged." — and then replaced the line anyway. It now resolves consent first and prints a single coherent result (CREAM-hpjebzes).
17
+
18
+ ### Changed
19
+ - **Setup/uninstall copy now matches how the bar actually appears.** The status line shows on the **next message** of a new session — no restart needed; a restart only matters for an already-open session. The installer note, the `SessionStart` hook message, and the uninstall receipt were reworded accordingly (dropping the misleading "Restart Claude Code" framing) (CREAM-wvtiftfw).
20
+ - **`/cc-cream:uninstall` is now self-sufficient.** It auto-cleans the regenerable scratch (the copied runtime and `cc-cream-state.json`) with no prompt — the old interactive artifact prompt was dead code, since both the `!` bang runner and the slash commands run without a TTY. `--purge` additionally removes the user-authored `~/.claude/cc-cream.json`, and `commands/uninstall.md` now forwards `$ARGUMENTS` so `/cc-cream:uninstall --purge` actually reaches the script. The closing receipt enumerates the final state and the leftovers the host *doesn't* clean — the version cache (`rm -rf ~/.claude/plugins/cache/cc-cream`), `/plugin marketplace remove`, the slash commands that linger until restart, and the npm-free cache-path escape hatch (CREAM-lznfgrap, CREAM-wvtiftfw).
21
+
22
+ ### Internal
23
+ - **One-command releases (`npm run release <patch|minor|major>`).** A new `scripts/release.mjs` bumps every version location in lockstep — `package.json`, `package-lock.json`, and `.claude-plugin/plugin.json` — and rolls the CHANGELOG's `[Unreleased]` section into a dated `## [x.y.z]` heading, gates on the test suite, then commits + tags (and pushes + creates the GitHub Release with `--publish`). It removes the hand-syncing every prior release required, where `npm version` touched only the first two and the version-match gate punished the drift. A new CI gate (`features/25`) now also asserts `plugin.json`'s version matches `package.json`, so that manifest can no longer go stale.
24
+
25
+ ## [0.2.0] — 2026-05-30
26
+
27
+ ### Added
28
+ - **`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.
29
+ - **`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.
30
+
31
+ ### Changed
32
+ - **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.
33
+
34
+ ### Internal
35
+ - **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.
36
+ - **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.
37
+ - **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.
38
+
7
39
  ## [0.1.18] — 2026-05-29
8
40
 
9
41
  ### Security
package/README.md CHANGED
@@ -136,36 +136,56 @@ Plugin users — two steps, **in this order** (Claude Code can't clean
136
136
  from the cache):
137
137
  ```
138
138
  /cc-cream:uninstall # 1. removes the statusLine wiring (run this FIRST)
139
- /plugin uninstall cc-cream # 2. drops the plugin from the cache
139
+ /plugin uninstall cc-cream # 2. drops the plugin
140
140
  ```
141
-
142
- > **Order matters.** `/cc-cream:uninstall` lives inside the plugin, so once you
143
- > run `/plugin uninstall` it's gone. The status line itself degrades to nothing
144
- > if the cache is missing (it won't error), but the now-inert `statusLine` block
145
- > lingers in `settings.json`. To clear it after the plugin is already gone, run
146
- > the npm bin (no global install needed):
141
+ `/cc-cream:uninstall` also auto-cleans cc-cream's regenerable scratch (the copied
142
+ runtime and session-state file); add `--purge` (`/cc-cream:uninstall --purge`) to
143
+ also delete your `~/.claude/cc-cream.json` config. The bar disappears on your next
144
+ message restart an already-open session to drop it immediately.
145
+
146
+ > **Order matters but you're covered if you get it wrong.** `/cc-cream:uninstall`
147
+ > lives inside the plugin, so once you run `/plugin uninstall` it's gone. Neither
148
+ > host command clears the `statusLine` block *or* the version cache. The renderer
149
+ > notices when it's running from a cache the host no longer lists as installed and
150
+ > **self-suppresses**, so the bar stops on the next session even though the inert
151
+ > `statusLine` line still lingers in `settings.json`.
152
+ >
153
+ > To clear that leftover line once the plugin is gone, the **guaranteed** route is
154
+ > the copy of the uninstaller still in the cache — npm-free and always present:
155
+ > ```bash
156
+ > node ~/.claude/plugins/cache/cc-cream/cc-cream/<version>/src/install.js --uninstall
157
+ > # add --purge to also remove your config
158
+ > ```
159
+ > The npm bin does the same job, but **not always**: a *freshly published* version
160
+ > is blocked by npm's min-package-age safe-chain guard (it reports "command not
161
+ > found") until it ages in, so use it only if the cache route isn't handy:
147
162
  > ```bash
148
163
  > npx -y -p cc-cream cc-cream-setup --uninstall
149
164
  > ```
150
- > or remove the `statusLine` key from `~/.claude/settings.json` by hand.
165
+ > You can always remove the `statusLine` key from `~/.claude/settings.json` by hand.
151
166
 
152
167
  npm / manual users:
153
168
  ```bash
154
- cc-cream-setup --uninstall # npm (add --purge to also remove runtime + config)
169
+ cc-cream-setup --uninstall # npm (add --purge to also remove the config)
155
170
  node cc-cream/src/install.js --uninstall # manual clone
156
171
  ```
157
172
 
158
173
  Uninstall removes the `statusLine` block **only if it is cc-cream's** — a
159
- statusLine you wired for something else is left untouched. In a terminal it asks
160
- before deleting the copied runtime and session-state files; run **non-interactively**
161
- (as the `/cc-cream:uninstall` slash command does) it leaves those artifacts in
162
- place pass `--purge` to remove them and your `~/.claude/cc-cream.json` config.
163
- Restart Claude Code to clear the bar.
174
+ statusLine you wired for something else is left untouched. It always cleans the
175
+ regenerable scratch (the copied runtime and session state, both recreated on a
176
+ reinstall); `--purge` additionally removes your `~/.claude/cc-cream.json` config.
177
+ The bar clears on your next message (restart an already-open session to drop it now).
164
178
 
165
179
  Likewise, `cc-cream-setup` run non-interactively will overwrite an existing
166
180
  *cc-cream* statusLine but never a foreign one — pass `--force` to replace
167
181
  regardless.
168
182
 
183
+ Not sure what's left behind? `cc-cream-setup --status` prints a read-only
184
+ footprint report — the statusLine wiring, every cached plugin version (the host
185
+ never garbage-collects these), the marketplace clone + registration, the auto-wire
186
+ marker, session state, config, and the manual runtime copy — so you can confirm a
187
+ clean slate or see exactly what to remove.
188
+
169
189
  ## Configuration
170
190
 
171
191
  Every display decision is read from `~/.claude/cc-cream.json`. Edit it by hand
@@ -173,6 +193,13 @@ or ask Claude to. It is strict JSON with no comments. **Every field falls back t
173
193
  its built-in default if missing or malformed** — a typo degrades one value rather
174
194
  than breaking the bar; a whole-file parse error falls back to all defaults.
175
195
 
196
+ Because unknown or out-of-range fields are silently ignored, run the doctor after
197
+ editing by hand to catch typos:
198
+
199
+ ```bash
200
+ cc-cream-setup --check-config # reports unknown keys / out-of-domain values; exits non-zero if any
201
+ ```
202
+
176
203
  ```json
177
204
  {
178
205
  "numbers": "compact",
@@ -285,6 +312,21 @@ Default: `amber: 75`, `red: 90` (absolute `used_percentage`).
285
312
  Anthropic's faster-drain window. Defaults `5`–`11`. Weekday-only (Mon–Fri) and
286
313
  the `America/Los_Angeles` timezone are hardcoded policy facts, not config.
287
314
 
315
+ ## Troubleshooting
316
+
317
+ cc-cream is built to degrade silently — if a stdin field is missing or malformed
318
+ it just hides that segment rather than crashing. When the bar is unexpectedly
319
+ empty or shorter than you expect, turn on diagnostics:
320
+
321
+ ```bash
322
+ export CC_CREAM_DEBUG=1 # then trigger a render in Claude Code
323
+ ```
324
+
325
+ Each render appends a line to `~/.claude/cc-cream-debug.log` (override the path
326
+ with `CC_CREAM_DEBUG_LOG`) listing which segments rendered, which were dropped,
327
+ the resolved TTL window, and the stdin size. It never writes to the status line
328
+ itself, so it costs zero tokens. Unset the variable to turn it off.
329
+
288
330
  ## Development
289
331
 
290
332
  See [CONTRIBUTING.md](CONTRIBUTING.md) for how to run the tests. The runtime
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-cream",
3
- "version": "0.1.18",
3
+ "version": "0.3.0",
4
4
  "description": "Claude Code cache/context/cost status-line tool",
5
5
  "directories": {
6
6
  "doc": "docs"
@@ -16,6 +16,7 @@
16
16
  "coverage": "c8 cucumber-js",
17
17
  "watch": "cucumber-js --watch",
18
18
  "hooks": "simple-git-hooks",
19
+ "release": "node scripts/release.mjs",
19
20
  "prepublishOnly": "npm test"
20
21
  },
21
22
  "simple-git-hooks": {
package/src/cc-cream.js CHANGED
@@ -3,11 +3,13 @@
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';
10
+ import { fileURLToPath } from 'node:url';
9
11
  import { loadConfig, readConfigFile } from './config.js';
10
- import { render } from './render.js';
12
+ import { buildSegments, render } from './render.js';
11
13
  import {
12
14
  getSessionState,
13
15
  nextSessionPatch,
@@ -15,7 +17,7 @@ import {
15
17
  readState,
16
18
  writeState,
17
19
  } from './state.js';
18
- import { isEntrypoint } from './utils.js';
20
+ import { isEntrypoint, isNum } from './utils.js';
19
21
 
20
22
  export { DEFAULTS } from './defaults.js';
21
23
  export { loadConfig } from './config.js';
@@ -51,8 +53,139 @@ function nowFromEnv(env) {
51
53
  return rawNow && Number.isFinite(Number(rawNow)) ? Number(rawNow) : Date.now();
52
54
  }
53
55
 
56
+ // Resolve when the cache TTL window last reset, in epoch ms (or null to hide the
57
+ // ttl segment). This is the ONLY filesystem read on the render path — kept here
58
+ // in the I/O layer so render.js and the segments stay pure. Priority: token
59
+ // growth this turn (reset is now) → the last recorded API timestamp → the
60
+ // transcript file's mtime as a last resort.
61
+ function resolveTtlAnchor(data, prevSessionState, now) {
62
+ const curTokens = data?.context_window?.total_input_tokens;
63
+ const prevTokens = prevSessionState?.total_input_tokens;
64
+ if (isNum(curTokens) && isNum(prevTokens) && curTokens > prevTokens) return now;
65
+ if (isNum(prevSessionState?.last_api_ts)) return prevSessionState.last_api_ts;
66
+ const tp = data?.transcript_path;
67
+ if (typeof tp !== 'string' || tp === '') return null;
68
+ try {
69
+ return fs.statSync(tp).mtimeMs;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ // CC_CREAM_DEBUG is opt-in diagnostics. Claude Code SILENTLY DISCARDS statusLine
76
+ // stderr (it's only surfaced under `claude --debug`, first invocation), so the
77
+ // channel is a log FILE — never stdout, which would cost tokens / corrupt the
78
+ // bar. CC_CREAM_DEBUG_LOG overrides the path (used by tests).
79
+ const debugEnabled = (env) => {
80
+ const v = env.CC_CREAM_DEBUG;
81
+ return typeof v === 'string' && v !== '' && v !== '0' && v.toLowerCase() !== 'false';
82
+ };
83
+
84
+ function writeDebug(env, lines) {
85
+ const file = env.CC_CREAM_DEBUG_LOG || path.join(os.homedir(), '.claude', 'cc-cream-debug.log');
86
+ try {
87
+ fs.appendFileSync(file, `${lines.join('\n')}\n`);
88
+ } catch {
89
+ // diagnostics must never affect the render — swallow any write failure
90
+ }
91
+ }
92
+
93
+ // Record why the bar looks the way it does: which on-by-config segments rendered
94
+ // and which were dropped (the usual reason a bar is shorter/emptier than
95
+ // expected — a missing or malformed stdin field). Recomputes the segment map
96
+ // through buildSegments() so it can never diverge from what render() drew.
97
+ function logDebug(env, { data, cfg, now, prevSessionState, sessionId, rawLen, ttlAnchorMs, out }) {
98
+ const { ttlMin, segs } = buildSegments(data, cfg, env, now, prevSessionState, ttlAnchorMs);
99
+ const onIds = Object.keys(cfg.segments).filter((id) => cfg.segments[id].on);
100
+ const visible = onIds.filter((id) => segs[id]);
101
+ const hidden = onIds.filter((id) => !segs[id]);
102
+ writeDebug(env, [
103
+ `[${new Date(now).toISOString()}] session=${sessionId ?? 'none'} stdinBytes=${rawLen} ttlMin=${ttlMin} ttlAnchor=${ttlAnchorMs ?? 'none'}`,
104
+ ` output=${out ? JSON.stringify(out) : '<empty>'}`,
105
+ ` visible=[${visible.join(',')}]`,
106
+ ` hidden(on-but-absent)=[${hidden.join(',')}]`,
107
+ ]);
108
+ }
109
+
110
+ // --- Ghost-bar self-defense (CREAM-uchemxln) --------------------------------
111
+ // No Claude Code host removal path deletes our statusLine OR the version cache:
112
+ // `/plugin uninstall` and `/plugin marketplace remove` both leave the cache tree
113
+ // AND the statusLine in settings.json. So a plugin-cache copy of this renderer
114
+ // keeps executing every session after the plugin is gone — a zombie bar the user
115
+ // has no in-product way to stop (`/cc-cream:uninstall` deregisters with the
116
+ // plugin). The shell `[ -f entrypoint ] || exit 0` guard in install.js can't
117
+ // cover this: the cache it checks for is never GC'd, so the file never goes
118
+ // missing. The reliable signal is the host registry, not the filesystem — when we
119
+ // detect we're running FROM the plugin cache, confirm cc-cream is still listed in
120
+ // installed_plugins.json; if it's gone, exit 0 silently.
121
+
122
+ function realpathOr(p) {
123
+ try {
124
+ return fs.realpathSync(p);
125
+ } catch {
126
+ return path.resolve(p);
127
+ }
128
+ }
129
+
130
+ // If `selfPath` lives under `<root>/plugins/cache/<marketplace>/<plugin>/...`,
131
+ // return { pluginsDir, pluginHome }; otherwise null (a manual/dev install, which
132
+ // is never a cache orphan). Both paths are derived from the running location, so
133
+ // the registry we consult is the one that actually governs THIS install — no
134
+ // os.homedir()/CLAUDE_CONFIG_DIR assumption.
135
+ function pluginCacheLocation(selfPath) {
136
+ const segs = realpathOr(selfPath).split(path.sep);
137
+ for (let i = 0; i + 3 < segs.length; i++) {
138
+ if (segs[i] === 'plugins' && segs[i + 1] === 'cache') {
139
+ return {
140
+ pluginsDir: segs.slice(0, i + 1).join(path.sep),
141
+ pluginHome: segs.slice(0, i + 4).join(path.sep),
142
+ };
143
+ }
144
+ }
145
+ return null;
146
+ }
147
+
148
+ function isWithin(parent, child) {
149
+ const rel = path.relative(parent, child);
150
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
151
+ }
152
+
153
+ // True when this renderer is a plugin-cache orphan: running from the cache while
154
+ // cc-cream is absent from the host's installed_plugins.json. Cost is one tiny
155
+ // read, and ONLY on the plugin-cache path — manual/dev installs return early
156
+ // before touching the disk. A missing registry (ENOENT) counts as orphaned; any
157
+ // other read/parse failure is treated as not-orphaned, so a transient glitch can
158
+ // never suppress a legitimately wired bar.
159
+ function isOrphanedPluginRun(selfPath) {
160
+ const loc = pluginCacheLocation(selfPath);
161
+ if (!loc) return false;
162
+ let parsed;
163
+ try {
164
+ parsed = JSON.parse(fs.readFileSync(path.join(loc.pluginsDir, 'installed_plugins.json'), 'utf8'));
165
+ } catch (err) {
166
+ return err?.code === 'ENOENT';
167
+ }
168
+ const plugins = parsed && typeof parsed === 'object' ? parsed.plugins : null;
169
+ if (!plugins || typeof plugins !== 'object') return true;
170
+ const home = realpathOr(loc.pluginHome);
171
+ for (const entries of Object.values(plugins)) {
172
+ if (!Array.isArray(entries)) continue;
173
+ for (const entry of entries) {
174
+ if (entry && typeof entry.installPath === 'string' && isWithin(home, realpathOr(entry.installPath))) {
175
+ return false; // an installed cc-cream entry lives in our cache subtree
176
+ }
177
+ }
178
+ }
179
+ return true;
180
+ }
181
+
54
182
  async function main() {
55
- const data = parseSession(await readStdin());
183
+ // Self-suppress a zombie bar left behind by an uninstalled plugin (before any
184
+ // stdin read — matching the intent of install.js's now-dead `[ -f ]` guard).
185
+ if (isOrphanedPluginRun(fileURLToPath(import.meta.url))) process.exit(0);
186
+
187
+ const raw = await readStdin();
188
+ const data = parseSession(raw);
56
189
  const cfg = loadConfig(readConfigFile());
57
190
  const now = nowFromEnv(process.env);
58
191
 
@@ -61,9 +194,14 @@ async function main() {
61
194
  const state = sessionId ? readState(stateFile) : {};
62
195
  const prevSessionState = getSessionState(state, sessionId);
63
196
 
64
- const out = render(data, cfg, process.env, now, prevSessionState);
197
+ const ttlAnchorMs = resolveTtlAnchor(data, prevSessionState, now);
198
+ const out = render(data, cfg, process.env, now, prevSessionState, ttlAnchorMs);
65
199
  if (out) process.stdout.write(`${out}\n`);
66
200
 
201
+ if (debugEnabled(process.env)) {
202
+ logDebug(process.env, { data, cfg, now, prevSessionState, sessionId, rawLen: raw.length, ttlAnchorMs, out });
203
+ }
204
+
67
205
  if (sessionId) {
68
206
  const patch = nextSessionPatch(data, prevSessionState, cfg, now);
69
207
  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
- 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);
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
- 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);
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
- 'Claude Code must be trusted and possibly restarted for the status line to appear.';
25
+ 'The bar appears on your next message restart only an already-open session, and the workspace must be trusted.';
21
26
 
22
- // The cache-glob auto-update command (docs/RELEASE_PLAN.md "Auto-update mechanism").
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. The `-d .../*/` glob yields directory paths with a trailing slash, so
26
- // `src/cc-cream.js` concatenates directly. The `grep` keeps ONLY semver-named
27
- // dirs (e.g. `0.1.10/`) before `sort -V | tail -1` picks the highest — without
28
- // it, a non-numeric cache dir (a git-sha install like `c83650b6360f/`) sorts
29
- // last and pins the bar to whatever version that happens to be, defeating
30
- // auto-update. With it, `/plugin update` is applied live with no re-run of setup.
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 resolved dir is captured in `$d` and GUARDED with `[ -z "$d" ] && exit 0`:
33
- // when the glob matches nothing — the state left behind if a user runs
34
- // `/plugin uninstall cc-cream` WITHOUT first running `/cc-cream:uninstall`, so a
35
- // stale statusLine outlives the deleted cache the command degrades to a silent
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 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"`;
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 switching strategy or node path re-plans), at refreshInterval 60.
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 an existing statusLine must be replaced.
101
+ // `consent` is the user's yes/no when a FOREIGN statusLine must be replaced.
94
102
  //
95
- // Two command strategies:
96
- // - manual (default): `node <entrypoint>` pointing at the copied-to-home runtime.
97
- // - plugin: the cache-glob auto-update command, using the absolute `nodePath`.
98
- // Pass `{ plugin: true, nodePath }` to select it; the plugin cache IS the
99
- // install, so no files are copied to home in that mode.
100
- export function plan(settings, { entrypoint, consent, plugin = false, nodePath } = {}) {
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 = plugin
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
- // An existing (different) statusLine must be confirmed before replacing.
120
- const hasExisting = existing && typeof existing === 'object';
121
- if (hasExisting) {
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: hasExisting };
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
- // 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
-
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
- if (!fs.existsSync(file)) return {};
169
- const raw = fs.readFileSync(file, 'utf8');
170
- if (raw.trim() === '') return {};
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
- process.exit(1);
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
- process.exit(1);
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
- return parsed;
173
+ process.exit(1);
186
174
  }
187
175
 
188
176
  function runtimeFiles(sourceFile) {
@@ -229,8 +217,13 @@ function ask(question) {
229
217
  }));
230
218
  }
231
219
 
232
- // Remove the cc-cream wiring (and, with consent, its install artifacts). Keeps
233
- // the user's config (~/.claude/cc-cream.json) unless `--purge` is passed.
220
+ // Remove the cc-cream wiring and its throwaway scratch. The copied runtime
221
+ // (~/.claude/cc-cream) and session state (~/.claude/cc-cream-state.json) are
222
+ // regenerable — reinstalling recreates them — so they're always cleaned, no
223
+ // prompt. (The old interactive artifact prompt was dead code: both the `!` bang
224
+ // runner and the slash commands run non-TTY — CREAM-lznfgrap.) `--purge`
225
+ // additionally removes the one file worth keeping by default: the user-authored
226
+ // config (~/.claude/cc-cream.json).
234
227
  async function uninstall({ purge }) {
235
228
  const file = settingsPath();
236
229
  const settings = readSettings(file);
@@ -238,46 +231,192 @@ async function uninstall({ purge }) {
238
231
  for (const m of result.messages) console.log(m);
239
232
  if (result.changed) {
240
233
  writeFileAtomic(file, `${JSON.stringify(result.settings, null, 2)}\n`);
241
- console.log(`\nUpdated ${file}.`);
234
+ console.log(`Updated ${file}.`);
242
235
  }
243
236
 
244
237
  const home = path.join(os.homedir(), '.claude');
245
- const runtimeDir = path.join(home, 'cc-cream');
246
- const stateFile = path.join(home, 'cc-cream-state.json');
247
238
  const configFile = path.join(home, 'cc-cream.json');
248
239
 
249
- const artifacts = [runtimeDir, stateFile].filter((p) => fs.existsSync(p));
250
- if (artifacts.length) {
251
- let remove = purge;
252
- if (!remove && process.stdin.isTTY) {
253
- remove = await ask(`Also delete the copied runtime and session state?\n ${artifacts.join('\n ')}`);
254
- }
255
- if (remove) {
256
- for (const p of artifacts) fs.rmSync(p, { recursive: true, force: true });
257
- console.log('Removed runtime and state files.');
258
- } else if (process.stdin.isTTY) {
259
- console.log('Left runtime and state files in place.');
240
+ // Auto-clean regenerable scratch (not user data — no prompt).
241
+ const scratch = [path.join(home, 'cc-cream'), path.join(home, 'cc-cream-state.json')]
242
+ .filter((p) => fs.existsSync(p));
243
+ for (const p of scratch) fs.rmSync(p, { recursive: true, force: true });
244
+ if (scratch.length) console.log('Removed the copied runtime and session state.');
245
+
246
+ if (fs.existsSync(configFile)) {
247
+ if (purge) {
248
+ fs.rmSync(configFile, { force: true });
249
+ console.log(`Removed your config ${configFile}.`);
260
250
  } else {
261
- // Non-interactive (e.g. run via the /cc-cream:uninstall slash command, which
262
- // has no TTY): never block on a prompt. The statusLine — the thing that
263
- // matters — is already removed; keep the artifacts (deletion is destructive)
264
- // and say how to remove them.
265
- console.log(`Left runtime and session state in place — no terminal to confirm deletion:\n ${artifacts.join('\n ')}`);
266
- console.log('Re-run in a terminal, or pass --purge, to remove them.');
251
+ console.log(`Kept your config ${configFile} (pass --purge to remove it too).`);
267
252
  }
268
253
  }
269
- if (purge && fs.existsSync(configFile)) {
270
- fs.rmSync(configFile, { force: true });
271
- console.log(`Removed config ${configFile}.`);
272
- } else if (fs.existsSync(configFile)) {
273
- console.log(`Kept your config ${configFile} (pass --purge to remove it too).`);
254
+
255
+ printUninstallReceipt();
256
+ }
257
+
258
+ // The closing receipt. No Claude Code host removal path drops our statusLine OR
259
+ // the version cache, so spell out what's gone, what the host leaves behind, and
260
+ // the npm-free escape hatch (the lingering cache always has a working install.js).
261
+ // See project memory cc-cream-plugin-lifecycle-findings.
262
+ function printUninstallReceipt() {
263
+ console.log('\nDone — the bar disappears on your next message (restart an already-open session to drop it now).');
264
+ console.log('The host leaves the rest behind; to fully remove cc-cream:');
265
+ console.log(' • Plugin: /plugin uninstall cc-cream then /plugin marketplace remove cc-cream');
266
+ console.log(' • Version cache (never auto-removed): rm -rf ~/.claude/plugins/cache/cc-cream');
267
+ console.log(' • The /cc-cream:* slash commands linger in this session until you restart Claude Code.');
268
+ console.log('Already removed the plugin? This same uninstall lives in the cache:');
269
+ console.log(' node ~/.claude/plugins/cache/cc-cream/cc-cream/<version>/src/install.js --uninstall [--purge]');
270
+ }
271
+
272
+ // `cc-cream-setup --check-config`: lint ~/.claude/cc-cream.json against the
273
+ // config schema, reporting unknown keys and out-of-domain values (which the
274
+ // renderer silently ignores). Exits non-zero when problems are found.
275
+ function checkConfigCli() {
276
+ const file = path.join(os.homedir(), '.claude', 'cc-cream.json');
277
+ if (!fs.existsSync(file)) {
278
+ console.log(`No config at ${file} — cc-cream uses its defaults. Nothing to check.`);
279
+ return;
274
280
  }
281
+ const raw = fs.readFileSync(file, 'utf8');
282
+ if (raw.trim() === '') {
283
+ console.log(`${file} is empty — cc-cream uses its defaults. Nothing to check.`);
284
+ return;
285
+ }
286
+ let parsed;
287
+ try {
288
+ parsed = JSON.parse(raw);
289
+ } catch {
290
+ console.error(`Error: ${file} is not valid JSON — the whole file is ignored.`);
291
+ process.exit(1);
292
+ }
293
+ const problems = checkConfig(parsed);
294
+ if (problems.length === 0) {
295
+ console.log(`${file}: OK — config looks good.`);
296
+ return;
297
+ }
298
+ console.error(`${file}: ${problems.length} problem(s) — each falls back to the default:`);
299
+ for (const p of problems) console.error(` - ${p}`);
300
+ process.exit(1);
301
+ }
275
302
 
276
- console.log('\nRestart Claude Code to drop the bar.');
303
+ function listDirs(dir) {
304
+ try {
305
+ return fs.readdirSync(dir, { withFileTypes: true })
306
+ .filter((e) => e.isDirectory())
307
+ .map((e) => e.name)
308
+ .sort();
309
+ } catch {
310
+ return [];
311
+ }
312
+ }
313
+
314
+ function readJsonSafe(file) {
315
+ try {
316
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
317
+ } catch {
318
+ return null;
319
+ }
320
+ }
321
+
322
+ // `cc-cream-setup --status`: a read-only report of cc-cream's entire on-disk
323
+ // footprint. Because no Claude Code host removal path drops our statusLine or GCs
324
+ // the version cache (project memory cc-cream-plugin-lifecycle-findings), users
325
+ // can't otherwise tell whether cc-cream fully went away — this command answers
326
+ // "clean slate?" in one shot, and points out what the host left behind.
327
+ function statusCli() {
328
+ const home = path.join(os.homedir(), '.claude');
329
+ const plugins = path.join(home, 'plugins');
330
+ const items = [];
331
+ const add = (label, present, detail) => items.push({ label, present, detail });
332
+
333
+ // statusLine wiring
334
+ const { state, value } = readSettingsFile(settingsPath());
335
+ if (!isSafeToWrite(state)) {
336
+ add('statusLine wiring', false, `settings.json unreadable (${state}) — not inspected`);
337
+ } else if (isCcCreamStatusLine(value.statusLine)) {
338
+ const ep = (value.statusLine.command.match(/\[ -f "([^"]+)"/) || [])[1] || '';
339
+ const ok = ep && fs.existsSync(ep);
340
+ add('statusLine wiring', true, ok
341
+ ? `belongs to cc-cream, pinned to ${ep}`
342
+ : `belongs to cc-cream, pinned to ${ep || '(unknown)'} — entrypoint MISSING (stale/ghost wiring)`);
343
+ } else if (value.statusLine) {
344
+ add('statusLine wiring', false, 'present but not cc-cream’s (left untouched)');
345
+ } else {
346
+ add('statusLine wiring', false, 'none');
347
+ }
348
+
349
+ // plugin cache versions (the host never GCs these)
350
+ const versions = listDirs(path.join(plugins, 'cache', 'cc-cream', 'cc-cream'));
351
+ add('plugin cache', versions.length > 0, versions.length
352
+ ? `${versions.length} version(s) [${versions.join(', ')}] — host never GCs these; rm to reclaim`
353
+ : 'none');
354
+
355
+ // marketplace clone
356
+ const clone = path.join(plugins, 'marketplaces', 'cc-cream');
357
+ add('marketplace clone', fs.existsSync(clone), fs.existsSync(clone) ? clone : 'none');
358
+
359
+ // registrations
360
+ const installed = readJsonSafe(path.join(plugins, 'installed_plugins.json'));
361
+ const isRegistered = !!installed?.plugins && typeof installed.plugins === 'object'
362
+ && Object.keys(installed.plugins).some((k) => k.startsWith('cc-cream@'));
363
+ add('plugin registration', isRegistered, isRegistered
364
+ ? 'listed in installed_plugins.json'
365
+ : 'not listed in installed_plugins.json');
366
+
367
+ const known = readJsonSafe(path.join(plugins, 'known_marketplaces.json'));
368
+ const knownMkt = !!known && typeof known === 'object' && Object.hasOwn(known, 'cc-cream');
369
+ add('marketplace registration', knownMkt, knownMkt
370
+ ? 'listed in known_marketplaces.json'
371
+ : 'not listed in known_marketplaces.json');
372
+
373
+ // auto-wire marker (plugin data dir, falling back to the config dir)
374
+ const markerDir = process.env.CLAUDE_PLUGIN_DATA || path.join(plugins, 'data', 'cc-cream-cc-cream');
375
+ const marker = [path.join(markerDir, 'cc-cream-autowire-done'), path.join(home, 'cc-cream-autowire-done')]
376
+ .find((p) => fs.existsSync(p));
377
+ add('auto-wire marker', !!marker, marker || 'none');
378
+
379
+ // session state
380
+ const stateFile = path.join(home, 'cc-cream-state.json');
381
+ if (fs.existsSync(stateFile)) {
382
+ const obj = readJsonSafe(stateFile);
383
+ const n = obj && typeof obj === 'object' ? Object.keys(obj).length : '?';
384
+ add('session state', true, `${n} session(s) in cc-cream-state.json`);
385
+ } else {
386
+ add('session state', false, 'none');
387
+ }
388
+
389
+ // config
390
+ const configFile = path.join(home, 'cc-cream.json');
391
+ add('config', fs.existsSync(configFile), fs.existsSync(configFile) ? configFile : 'none (using defaults)');
392
+
393
+ // manual runtime copy
394
+ const runtimeDir = path.join(home, 'cc-cream');
395
+ add('manual runtime copy', fs.existsSync(runtimeDir), fs.existsSync(runtimeDir) ? runtimeDir : 'none');
396
+
397
+ console.log('cc-cream footprint:');
398
+ for (const it of items) console.log(` [${it.present ? 'x' : ' '}] ${it.label}: ${it.detail}`);
399
+
400
+ if (items.every((i) => !i.present)) {
401
+ console.log('\nClean slate — no cc-cream footprint found.');
402
+ return;
403
+ }
404
+ console.log(`\n${items.filter((i) => i.present).length} component(s) present. To remove everything:`);
405
+ console.log(' /cc-cream:uninstall (or the cache-path install.js --uninstall) clears the statusLine + scratch;');
406
+ console.log(' then /plugin uninstall cc-cream + /plugin marketplace remove cc-cream;');
407
+ console.log(' then rm -rf ~/.claude/plugins/cache/cc-cream (the host never removes it).');
277
408
  }
278
409
 
279
410
  async function main() {
280
411
  const args = process.argv.slice(2);
412
+ if (args.includes('--status')) {
413
+ statusCli();
414
+ return;
415
+ }
416
+ if (args.includes('--check-config')) {
417
+ checkConfigCli();
418
+ return;
419
+ }
281
420
  if (args.includes('--uninstall')) {
282
421
  await uninstall({ purge: args.includes('--purge') });
283
422
  return;
@@ -290,18 +429,21 @@ async function main() {
290
429
  const file = settingsPath();
291
430
  const settings = readSettings(file);
292
431
 
293
- // planOpts holds whatever the chosen strategy needs to build its command.
432
+ // planOpts holds the entrypoint + node path the command bakes in.
294
433
  let planOpts;
295
434
  if (plugin) {
296
- // Plugin mode: the plugin cache IS the install — do NOT copy to home. The
297
- // command self-resolves the latest cached version on every render.
298
- planOpts = { plugin: true, nodePath: resolveNodePath() };
435
+ // Plugin mode: the plugin cache IS the install — do NOT copy to home. Point
436
+ // the statusLine at this install.js's sibling cc-cream.js, i.e. the current
437
+ // version's absolute path in the plugin cache. The SessionStart hook re-pins
438
+ // it after a /plugin update (the command can't self-resolve the version).
439
+ const entrypoint = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'cc-cream.js');
440
+ planOpts = { entrypoint, nodePath: resolveNodePath() };
299
441
  } else {
300
442
  // Manual / GitHub mode: copy the runtime into ~/.claude/cc-cream and point
301
- // the statusLine at that copied entrypoint.
443
+ // the statusLine at that copied (stable) entrypoint.
302
444
  const sourceFile = positional[0]
303
445
  ? path.resolve(positional[0])
304
- : path.resolve(path.dirname(new URL(import.meta.url).pathname), 'cc-cream.js');
446
+ : path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'cc-cream.js');
305
447
 
306
448
  if (!fs.existsSync(sourceFile)) {
307
449
  console.error(`Error: cc-cream.js not found at ${sourceFile}`);
@@ -313,16 +455,19 @@ async function main() {
313
455
  if (copyRuntimeFiles(sourceFile, destDir)) {
314
456
  console.log(`Copied cc-cream runtime files to ${destDir}`);
315
457
  }
316
- planOpts = { entrypoint: dest };
458
+ planOpts = { entrypoint: dest, nodePath: resolveNodePath() };
317
459
  }
318
460
 
319
461
  let result = plan(settings, planOpts);
320
- // If a replace needs consent, ask now and re-plan with the answer.
462
+ // A foreign statusLine needs consent before we replace it. This first plan()
463
+ // pass is detection only — do NOT print its messages: they include a
464
+ // speculative "Declined …" (consent was absent) that would contradict a
465
+ // subsequent --force replace. Resolve consent, re-plan, then print the single
466
+ // coherent second-pass result below (CREAM-hpjebzes).
321
467
  if (!result.changed && result.needsConsent) {
322
- for (const m of result.messages) console.log(m);
323
468
  let yes;
324
469
  if (process.stdin.isTTY) {
325
- yes = await ask('Replace it with cc-cream?');
470
+ yes = await ask('Replace your existing statusLine with cc-cream?');
326
471
  } else {
327
472
  // Non-interactive (e.g. run via the /cc-cream:setup slash command, which has
328
473
  // no TTY): never block on a prompt. Safe to overwrite our OWN wiring (an
@@ -330,7 +475,7 @@ async function main() {
330
475
  // statusLine without a terminal or an explicit --force.
331
476
  yes = force || isCcCreamStatusLine(settings.statusLine);
332
477
  console.log(yes
333
- ? 'Non-interactive: replacing the existing cc-cream statusLine.'
478
+ ? 'Non-interactive: replacing the existing statusLine with cc-cream’s.'
334
479
  : 'Non-interactive: left your existing statusLine unchanged. Re-run in a terminal, or pass --force, to replace it.');
335
480
  }
336
481
  result = plan(settings, { ...planOpts, consent: yes });
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
- // Assemble enabled+visible segments into up to three rows.
7
- export function render(data, cfg, env, now, prevSessionState = null) {
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
- const segs = renderSegments(data, cfg, ttlMin, now, prevSessionState, tz);
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
- function segTtl(data, cfg, ttlMin, now, prevSessionState) {
60
- const prevTokens = prevSessionState?.total_input_tokens;
61
- const curTokens = data?.context_window?.total_input_tokens;
62
- const tokensGrew = isNum(curTokens) && isNum(prevTokens) && curTokens > prevTokens;
63
-
64
- let anchorMs;
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(data, cfg, ttlMin, now, prevSessionState),
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),
@@ -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
+ }