cc-cream 0.3.6 → 0.4.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
@@ -6,6 +6,18 @@ All notable changes to cc-cream are documented here. Format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.0] — 2026-06-04
10
+
11
+ ### Added
12
+ - **`--show` / `--hide` flags on `/cc-cream:setup` and `cc-cream-setup`.** Toggle segments from the command line without editing JSON: `--hide 5h,7d,peak`, `--show all`, `--show effort,thinking`. `--hide` overrides `--show` when both name the same segment. Changes are idempotent and written to `~/.claude/cc-cream.json`.
13
+ - **`--set key=value` flag.** Set any config field via dot-path: `--set percentage=remaining`, `--set ctx.ceiling=100000`, `--set 5h.amber=80`. Multiple `--set` flags are allowed in one call. Invalid keys or out-of-domain values exit non-zero with an error message.
14
+
15
+ ### Changed
16
+ - **All 14 segments are now enabled on first install.** Previously `write`, `effort`, `thinking`, `api_ratio`, and `session_name` defaulted to off; users had to know to turn them on. They are now on by default — use `--hide` to remove any you don't need.
17
+
18
+ ### Fixed
19
+ - **Orphaned renderer removes stale `cc-cream-state.json`.** When `/plugin uninstall cc-cream` is run before `/cc-cream:uninstall` (wrong order, cache kept), the ghost-bar self-defense suppressed the bar but left `cc-cream-state.json` on disk. The orphaned renderer now removes it before exiting.
20
+
9
21
  ## [0.3.6] — 2026-06-04
10
22
 
11
23
  ### Changed
package/README.md CHANGED
@@ -23,12 +23,14 @@ ctx:21% [43k] | cache:99% | write:2% | ttl:60 | effort:high | think:on | ∿ api
23
23
  Sonnet 4.6 | My project session
24
24
  ```
25
25
 
26
- **Row 1 — this session:** context-window fill, cache hit rate, TTL countdown, session cost. Optional: cache write rate, effort level, thinking mode, API time ratio.
26
+ **Row 1 — this session:** context-window fill, cache hit rate, TTL countdown, session cost, cache write rate, effort level, thinking mode, API time ratio.
27
27
 
28
28
  **Row 2 — rate-limit budgets:** 5h and 7d window usage with reset countdowns and a burn-rate projection. Hidden for API users.
29
29
 
30
30
  **Row 3 — identity:** model name and session name.
31
31
 
32
+ Use `--hide` to remove any segments you don't need: `/cc-cream:setup --hide write,api_ratio,thinking`.
33
+
32
34
  ## Features
33
35
 
34
36
  **Cache health.** `cache` shows your hit rate each turn and turns red when it drops sharply — the only signal you'll get that a compaction, a far-back edit, or a large tool result just invalidated your cache prefix. `ttl` counts down to when the cache goes cold, turning amber then red as the window closes. Supports both 5-minute (API) and 60-minute (subscriber) TTLs, auto-detected.
@@ -60,7 +62,7 @@ macOS and Linux. Windows is a planned fast-follow.
60
62
  ### Option 1 — Claude Code plugin (recommended)
61
63
 
62
64
  ```bash
63
- /plugin marketplace add bart-turczynski/cc-cream
65
+ /plugin marketplace add bart-turczynski/claude-plugins
64
66
  /plugin install cc-cream
65
67
  ```
66
68
 
@@ -91,7 +93,7 @@ Download or clone the repository, then run the consent installer:
91
93
 
92
94
  ```bash
93
95
  git clone https://github.com/bart-turczynski/cc-cream.git
94
- node cc-cream/src/install.js
96
+ node cc-cream/plugin/src/install.js
95
97
  ```
96
98
 
97
99
  The installer detects an existing `statusLine` and asks before replacing it, preserves any `padding` you have set, and is idempotent.
@@ -117,9 +119,50 @@ Add `--purge` to either to also remove your `~/.claude/cc-cream.json` config. Se
117
119
 
118
120
  ## Configuration
119
121
 
120
- Every display decision is driven by `~/.claude/cc-cream.json`. You don't have to edit it by hand — just ask Claude to toggle segments, flip how percentages are counted, or adjust thresholds. The defaults are based on what the data and community experience suggest are reasonable starting points.
122
+ ### Toggle segments
123
+
124
+ **Plugin users** — pass flags directly to `/cc-cream:setup`:
125
+ ```
126
+ /cc-cream:setup --hide 5h,7d,peak
127
+ /cc-cream:setup --show all --hide peak
128
+ /cc-cream:setup --show effort,thinking
129
+ ```
130
+
131
+ **npm / manual users** — same flags via `cc-cream-setup`:
132
+ ```bash
133
+ cc-cream-setup --hide 5h,7d,peak
134
+ cc-cream-setup --show all
135
+ ```
136
+
137
+ `--show all` re-enables everything. `--hide` overrides `--show` when both name the same segment. Valid segment names: `ctx` `cache` `write` `ttl` `effort` `thinking` `api_ratio` `cost` `5h` `7d` `burn` `peak` `model` `session_name`.
138
+
139
+ ### Set config values
140
+
141
+ `--set key=value` sets any config field. Multiple `--set` flags are allowed in one call.
142
+
143
+ ```
144
+ # flip percentage direction
145
+ /cc-cream:setup --set percentage=remaining
146
+
147
+ # set a fixed-token ceiling for ctx warnings (useful on large-context models)
148
+ /cc-cream:setup --set ctx.basis=ceiling --set ctx.ceiling=100000
149
+
150
+ # tighten color thresholds
151
+ /cc-cream:setup --set ctx.amber=20 --set ctx.orange=30 --set ctx.red=40
152
+
153
+ # adjust rate-limit warning bands
154
+ /cc-cream:setup --set 5h.amber=80 --set 5h.red=95
155
+ ```
156
+
157
+ npm / manual users replace `/cc-cream:setup` with `cc-cream-setup`.
158
+
159
+ Top-level keys: `percentage` (`consumed`|`remaining`), `numbers` (`compact`|`exact`), `ttl` (`auto`|`60`|`5`). Per-segment dot-paths: `segment.field` — e.g. `ctx.ceiling`, `ctx.basis`, `5h.amber`. Full field reference in [CONFIGURATION.md](CONFIGURATION.md).
160
+
161
+ The flags write to `~/.claude/cc-cream.json`; the bar reflects the change on the next render.
162
+
163
+ ### Fine-tuning
121
164
 
122
- If you do edit by hand, run the doctor afterward to catch typos:
165
+ Every display decision is also configurable by hand in `~/.claude/cc-cream.json` — thresholds, row assignments, color bands, TTL mode, and more. Run the doctor after editing to catch typos:
123
166
  ```bash
124
167
  cc-cream-setup --check-config
125
168
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-cream",
3
- "version": "0.3.6",
3
+ "version": "0.4.0",
4
4
  "description": "See cache health, context fill, token burn, rate limits, and peak hours in Claude Code CLI. The status line for tokenminning - cache rules everything around me - dolla, dolla bill, y'all.",
5
5
  "directories": {
6
6
  "doc": "docs"
@@ -9,18 +9,18 @@
9
9
  "lint": "biome lint plugin/src/ plugin/hooks/",
10
10
  "knip": "knip",
11
11
  "validate": "command -v claude >/dev/null 2>&1 && claude plugin validate plugin || echo 'cc-cream: claude CLI not found — skipping plugin validation'",
12
- "pretest": "npm run lint && npm run knip && npm run validate",
12
+ "pretest": "pnpm run lint && pnpm run knip && pnpm run validate",
13
13
  "test": "cucumber-js",
14
14
  "test:manual": "cucumber-js --profile manual",
15
15
  "test:cli": "cucumber-js --profile cli",
16
- "coverage": "c8 cucumber-js",
16
+ "coverage": "c8 --check-coverage --lines 90 cucumber-js",
17
17
  "watch": "cucumber-js --watch",
18
18
  "hooks": "simple-git-hooks",
19
19
  "release": "node scripts/release.mjs",
20
- "prepublishOnly": "npm test"
20
+ "prepublishOnly": "pnpm test"
21
21
  },
22
22
  "simple-git-hooks": {
23
- "pre-push": "npm run coverage"
23
+ "pre-push": "pnpm run coverage"
24
24
  },
25
25
  "bin": {
26
26
  "cc-cream": "plugin/src/cc-cream.js",
@@ -47,8 +47,9 @@
47
47
  "author": "Bart Turczynski <support@spoonkeyworks.com>",
48
48
  "license": "MIT",
49
49
  "type": "module",
50
+ "packageManager": "pnpm@11.3.0",
50
51
  "engines": {
51
- "node": ">=18"
52
+ "node": ">=22"
52
53
  },
53
54
  "repository": {
54
55
  "type": "git",
@@ -64,8 +65,5 @@
64
65
  "c8": "^11.0.0",
65
66
  "knip": "^6.14.2",
66
67
  "simple-git-hooks": "^2.13.1"
67
- },
68
- "overrides": {
69
- "yargs": "^18.0.0"
70
68
  }
71
69
  }
@@ -4,11 +4,11 @@
4
4
  // <=3-row bar. Hard rule: degrade, never crash.
5
5
 
6
6
  import fs from 'node:fs';
7
- import os from 'node:os';
8
- import path from 'node:path';
9
7
  import process from 'node:process';
10
8
  import { fileURLToPath } from 'node:url';
11
9
  import { loadConfig, readConfigFile } from './config.js';
10
+ import { isOrphanedPluginRun } from './orphan.js';
11
+ import { PATHS } from './paths.js';
12
12
  import { buildSegments, render } from './render.js';
13
13
  import {
14
14
  getSessionState,
@@ -82,7 +82,7 @@ const debugEnabled = (env) => {
82
82
  };
83
83
 
84
84
  function writeDebug(env, lines) {
85
- const file = env.CC_CREAM_DEBUG_LOG || path.join(os.homedir(), '.claude', 'cc-cream-debug.log');
85
+ const file = env.CC_CREAM_DEBUG_LOG || PATHS.debugLog();
86
86
  try {
87
87
  fs.appendFileSync(file, `${lines.join('\n')}\n`);
88
88
  } catch {
@@ -107,82 +107,15 @@ function logDebug(env, { data, cfg, now, prevSessionState, sessionId, rawLen, tt
107
107
  ]);
108
108
  }
109
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
-
182
110
  async function main() {
183
111
  // Self-suppress a zombie bar left behind by an uninstalled plugin (before any
184
112
  // stdin read — matching the intent of install.js's now-dead `[ -f ]` guard).
185
- if (isOrphanedPluginRun(fileURLToPath(import.meta.url))) process.exit(0);
113
+ // Also clean up the stale session state so it doesn't linger after a
114
+ // wrong-order `/plugin uninstall` (cache kept, /cc-cream:uninstall skipped).
115
+ if (isOrphanedPluginRun(fileURLToPath(import.meta.url))) {
116
+ try { fs.rmSync(PATHS.stateFile(), { force: true }); } catch { /* ignore */ }
117
+ process.exit(0);
118
+ }
186
119
 
187
120
  const raw = await readStdin();
188
121
  const data = parseSession(raw);
@@ -190,7 +123,7 @@ async function main() {
190
123
  const now = nowFromEnv(process.env);
191
124
 
192
125
  const sessionId = typeof data.session_id === 'string' && data.session_id ? data.session_id : null;
193
- const stateFile = path.join(os.homedir(), '.claude', 'cc-cream-state.json');
126
+ const stateFile = PATHS.stateFile();
194
127
  const state = sessionId ? readState(stateFile) : {};
195
128
  const prevSessionState = getSessionState(state, sessionId);
196
129
 
@@ -1,7 +1,6 @@
1
1
  import fs from 'node:fs';
2
- import os from 'node:os';
3
- import path from 'node:path';
4
2
  import { DEFAULTS } from './defaults.js';
3
+ import { PATHS } from './paths.js';
5
4
  import { clone, isNum, numOr } from './utils.js';
6
5
 
7
6
  // Each normalizer is `(value, fallback) => value | fallback`: it returns the
@@ -141,9 +140,50 @@ export function checkConfig(parsed) {
141
140
  return problems;
142
141
  }
143
142
 
143
+ // Coerce a CLI string to the JS value a normalizer expects.
144
+ function coerceValue(str) {
145
+ if (str === 'true') return true;
146
+ if (str === 'false') return false;
147
+ const n = Number(str);
148
+ return !Number.isNaN(n) && str.trim() !== '' ? n : str;
149
+ }
150
+
151
+ // Validate and normalize a single dot-path assignment from the CLI.
152
+ // dotPath: "percentage" (top-level) or "ctx.ceiling" (segment.field).
153
+ // rawValue: the string the user passed; coerced before normalization.
154
+ // Returns { ok: true, value } or { ok: false, error }.
155
+ export function normalizeConfigField(dotPath, rawValue) {
156
+ const INVALID = Symbol('invalid');
157
+ const coerced = coerceValue(rawValue);
158
+ const parts = dotPath.split('.');
159
+
160
+ if (parts.length === 1) {
161
+ const [key] = parts;
162
+ const norm = TOP_LEVEL[key];
163
+ if (!norm) return { ok: false, error: `unknown config key: "${key}"` };
164
+ const v = norm(coerced, INVALID);
165
+ if (v === INVALID) return { ok: false, error: `invalid value for "${key}": ${JSON.stringify(rawValue)}` };
166
+ return { ok: true, value: v };
167
+ }
168
+
169
+ if (parts.length === 2) {
170
+ const [segId, field] = parts;
171
+ if (!DEFAULTS.segments[segId]) return { ok: false, error: `unknown segment: "${segId}"` };
172
+ const segDef = DEFAULTS.segments[segId];
173
+ if (!(field in segDef)) return { ok: false, error: `unknown field "${field}" on segment "${segId}"` };
174
+ const norm = SEGMENT_FIELDS[field];
175
+ if (!norm) return { ok: false, error: `unsettable field: "${field}"` };
176
+ const v = norm(coerced, INVALID);
177
+ if (v === INVALID) return { ok: false, error: `invalid value for "${segId}.${field}": ${JSON.stringify(rawValue)}` };
178
+ return { ok: true, value: v };
179
+ }
180
+
181
+ return { ok: false, error: `invalid key path "${dotPath}" — use "key" for top-level or "segment.field" for per-segment` };
182
+ }
183
+
144
184
  export function readConfigFile() {
145
185
  try {
146
- return fs.readFileSync(path.join(os.homedir(), '.claude', 'cc-cream.json'), 'utf8');
186
+ return fs.readFileSync(PATHS.configFile(), 'utf8');
147
187
  } catch {
148
188
  return null;
149
189
  }
@@ -26,11 +26,11 @@ export const DEFAULTS = {
26
26
  // counts down "peak in Nm".
27
27
  peak: { on: true, row: 2, order: 3, start: 5, end: 11, lead: 60 },
28
28
  burn: { on: true, row: 2, order: 1.5 },
29
- effort: { on: false, row: 1, order: 6 },
30
- thinking: { on: false, row: 1, order: 7 },
31
- api_ratio: { on: false, row: 1, order: 8 },
32
- session_name: { on: false, row: 3, order: 1 },
33
- write: { on: false, row: 1, order: 3.5 },
29
+ effort: { on: true, row: 1, order: 6 },
30
+ thinking: { on: true, row: 1, order: 7 },
31
+ api_ratio: { on: true, row: 1, order: 8 },
32
+ session_name: { on: true, row: 3, order: 1 },
33
+ write: { on: true, row: 1, order: 3.5 },
34
34
  },
35
35
  };
36
36
 
@@ -10,12 +10,13 @@
10
10
 
11
11
  import { execSync } from 'node:child_process';
12
12
  import fs from 'node:fs';
13
- import os from 'node:os';
14
13
  import path from 'node:path';
15
14
  import process from 'node:process';
16
15
  import readline from 'node:readline';
17
16
  import { fileURLToPath } from 'node:url';
18
- import { checkConfig } from './config.js';
17
+ import { checkConfig, normalizeConfigField } from './config.js';
18
+ import { DEFAULTS } from './defaults.js';
19
+ import { PATHS } from './paths.js';
19
20
  import { isSafeToWrite, readSettings as readSettingsFile, writeFileAtomic } from './settings.js';
20
21
  import { isEntrypoint } from './utils.js';
21
22
 
@@ -50,8 +51,22 @@ const TRUST_NOTE =
50
51
  // statusLine outlives the deleted cache. Without it, node would crash with
51
52
  // MODULE_NOT_FOUND on every render. "Degrade, never crash" (CLAUDE.md). `exec`
52
53
  // replaces the shell so stdin/stdout pass straight through to the renderer.
54
+
55
+ // Escape a path for safe embedding inside a "double-quoted" POSIX shell word.
56
+ // The paths here aren't third-party-controlled — they're the user's own install
57
+ // location (os.homedir() + the resolved node binary) — but a home dir or node
58
+ // path containing `"`, `$`, a backtick, or `\` would otherwise break the command
59
+ // or let the shell expand/execute part of it. These four are the only characters
60
+ // special inside double quotes; backslash-escaping them neutralizes the lot while
61
+ // leaving every ordinary path byte-for-byte unchanged (so no command churn).
62
+ function shDquote(s) {
63
+ return String(s).replace(/(["$`\\])/g, '\\$1');
64
+ }
65
+
53
66
  export function statusLineCommand(nodePath, entrypoint) {
54
- return `[ -f "${entrypoint}" ] || exit 0; exec "${nodePath}" "${entrypoint}"`;
67
+ const ep = shDquote(entrypoint);
68
+ const node = shDquote(nodePath);
69
+ return `[ -f "${ep}" ] || exit 0; exec "${node}" "${ep}"`;
55
70
  }
56
71
 
57
72
  // `desired` is considered already installed if it matches the planned command
@@ -68,16 +83,23 @@ function isInstalled(existing, command) {
68
83
  }
69
84
 
70
85
  // True if an existing statusLine belongs to cc-cream under ANY install strategy
71
- // (dev repo, copied home runtime, or the plugin cache-glob) — every command
72
- // references the cc-cream entrypoint. Used by uninstall so we never touch a
73
- // statusLine the user wired for something else.
86
+ // (dev repo, copied home runtime, or the plugin cache) — every strategy points
87
+ // the command at an entrypoint whose basename is `cc-cream.js`. We match that
88
+ // filename rather than the bare substring `cc-cream`: the loose match would also
89
+ // claim a FOREIGN statusLine that merely mentions the string (a comment, an
90
+ // unrelated arg, a directory called cc-cream), and the whole consent flow exists
91
+ // precisely so we never touch a line the user wired for something else. Matching
92
+ // the entrypoint filename stays strategy-agnostic (it's invariant across dev /
93
+ // home-copy / plugin-cache) without over-fitting to the `[ -f … ] || exit 0`
94
+ // wrapper, so it still recognizes older command shapes. Used by uninstall and by
95
+ // the consent gate in plan().
74
96
  export function isCcCreamStatusLine(existing) {
75
97
  return (
76
98
  !!existing &&
77
99
  typeof existing === 'object' &&
78
100
  existing.type === 'command' &&
79
101
  typeof existing.command === 'string' &&
80
- existing.command.includes('cc-cream')
102
+ existing.command.includes('cc-cream.js')
81
103
  );
82
104
  }
83
105
 
@@ -146,17 +168,134 @@ export function plan(settings, { entrypoint, consent, nodePath } = {}) {
146
168
  return { settings: { ...s, statusLine: desired }, changed: true, messages, needsConsent: hasForeign };
147
169
  }
148
170
 
149
- // ---------------------------------------------------------------------------
150
- // CLI wrapper.
151
- // ---------------------------------------------------------------------------
152
- function settingsPath() {
153
- return path.join(os.homedir(), '.claude', 'settings.json');
171
+ // Decide what to do for --show / --hide segment config. Pure: no I/O.
172
+ // `show` and `hide` are arrays of segment IDs (or ['all']); hide overrides show.
173
+ // Returns { config, changed, messages, problems }.
174
+ export function planConfigure(currentRaw, { show = [], hide = [] } = {}) {
175
+ const ALL = Object.keys(DEFAULTS.segments);
176
+ const messages = [];
177
+ const problems = [];
178
+
179
+ const showIds = show.includes('all') ? ALL : show;
180
+ const hideIds = hide.includes('all') ? ALL : hide;
181
+
182
+ for (const id of new Set([...showIds, ...hideIds])) {
183
+ if (!DEFAULTS.segments[id]) problems.push(`unknown segment: "${id}"`);
184
+ }
185
+ if (problems.length) {
186
+ for (const p of problems) messages.push(p);
187
+ return { config: null, changed: false, messages, problems };
188
+ }
189
+
190
+ let parsed = {};
191
+ if (currentRaw != null) {
192
+ try { parsed = JSON.parse(currentRaw); } catch { /* start fresh */ }
193
+ }
194
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) parsed = {};
195
+
196
+ const existingSegs =
197
+ parsed.segments && typeof parsed.segments === 'object' && !Array.isArray(parsed.segments)
198
+ ? parsed.segments
199
+ : {};
200
+ const segs = { ...existingSegs };
201
+
202
+ // hide overrides show: last writer wins in target map
203
+ const target = {};
204
+ for (const id of showIds) target[id] = true;
205
+ for (const id of hideIds) target[id] = false;
206
+
207
+ let changed = false;
208
+ for (const [id, on] of Object.entries(target)) {
209
+ const existing = segs[id];
210
+ const currentOn =
211
+ existing && typeof existing === 'object' && 'on' in existing
212
+ ? existing.on
213
+ : DEFAULTS.segments[id].on;
214
+ if (currentOn !== on) {
215
+ segs[id] = { ...(existing || {}), on };
216
+ changed = true;
217
+ messages.push(`${on ? 'Showing' : 'Hiding'} segment "${id}".`);
218
+ }
219
+ }
220
+
221
+ if (!changed) {
222
+ messages.push('Already configured — no changes needed.');
223
+ return { config: parsed, changed: false, messages, problems: [] };
224
+ }
225
+ return { config: { ...parsed, segments: segs }, changed: true, messages, problems: [] };
154
226
  }
155
227
 
156
- function destinationPath() {
157
- return path.join(os.homedir(), '.claude', 'cc-cream', 'cc-cream.js');
228
+ // Apply one or more "key=value" assignments to ~/.claude/cc-cream.json. Pure: no I/O.
229
+ // assignments: array of strings like ["percentage=remaining", "ctx.ceiling=100000"].
230
+ // Supports top-level keys and "segment.field" dot-paths.
231
+ // Returns { config, changed, messages, problems }.
232
+ export function planSet(currentRaw, assignments) {
233
+ const messages = [];
234
+ const problems = [];
235
+
236
+ const parsed_pairs = [];
237
+ for (const a of assignments) {
238
+ const eq = a.indexOf('=');
239
+ if (eq === -1) {
240
+ problems.push(`invalid assignment "${a}" — expected key=value`);
241
+ continue;
242
+ }
243
+ const dotPath = a.slice(0, eq).trim();
244
+ const rawValue = a.slice(eq + 1).trim();
245
+ const result = normalizeConfigField(dotPath, rawValue);
246
+ if (!result.ok) {
247
+ problems.push(result.error);
248
+ } else {
249
+ parsed_pairs.push({ dotPath, value: result.value });
250
+ }
251
+ }
252
+
253
+ if (problems.length) {
254
+ for (const p of problems) messages.push(p);
255
+ return { config: null, changed: false, messages, problems };
256
+ }
257
+
258
+ let cfg = {};
259
+ if (currentRaw != null) {
260
+ try { cfg = JSON.parse(currentRaw); } catch { /* start fresh */ }
261
+ }
262
+ if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) cfg = {};
263
+
264
+ let changed = false;
265
+ for (const { dotPath, value } of parsed_pairs) {
266
+ const parts = dotPath.split('.');
267
+ if (parts.length === 1) {
268
+ const [key] = parts;
269
+ if (cfg[key] !== value) {
270
+ cfg = { ...cfg, [key]: value };
271
+ changed = true;
272
+ messages.push(`Set ${key} = ${JSON.stringify(value)}.`);
273
+ }
274
+ } else {
275
+ const [segId, field] = parts;
276
+ const segs = cfg.segments && typeof cfg.segments === 'object' && !Array.isArray(cfg.segments)
277
+ ? { ...cfg.segments }
278
+ : {};
279
+ const seg = segs[segId] && typeof segs[segId] === 'object' ? { ...segs[segId] } : {};
280
+ if (seg[field] !== value) {
281
+ seg[field] = value;
282
+ segs[segId] = seg;
283
+ cfg = { ...cfg, segments: segs };
284
+ changed = true;
285
+ messages.push(`Set ${segId}.${field} = ${JSON.stringify(value)}.`);
286
+ }
287
+ }
288
+ }
289
+
290
+ if (!changed) {
291
+ messages.push('Already configured — no changes needed.');
292
+ }
293
+ return { config: cfg, changed, messages, problems: [] };
158
294
  }
159
295
 
296
+ // ---------------------------------------------------------------------------
297
+ // CLI wrapper.
298
+ // ---------------------------------------------------------------------------
160
299
  // Read settings.json safely for the CLI. A MISSING or empty file -> {} (fresh
161
300
  // start, nothing to lose); a valid object is returned as-is. Any other state
162
301
  // (corrupt JSON, a non-object, or an unreadable file) is REFUSED: we exit rather
@@ -231,7 +370,7 @@ function ask(question) {
231
370
  // additionally removes the one file worth keeping by default: the user-authored
232
371
  // config (~/.claude/cc-cream.json).
233
372
  async function uninstall({ purge }) {
234
- const file = settingsPath();
373
+ const file = PATHS.settingsFile();
235
374
  const settings = readSettings(file);
236
375
  const result = planUninstall(settings);
237
376
  for (const m of result.messages) console.log(m);
@@ -240,11 +379,10 @@ async function uninstall({ purge }) {
240
379
  console.log(`Updated ${file}.`);
241
380
  }
242
381
 
243
- const home = path.join(os.homedir(), '.claude');
244
- const configFile = path.join(home, 'cc-cream.json');
382
+ const configFile = PATHS.configFile();
245
383
 
246
384
  // Auto-clean regenerable scratch (not user data — no prompt).
247
- const scratch = [path.join(home, 'cc-cream'), path.join(home, 'cc-cream-state.json')]
385
+ const scratch = [PATHS.runtimeDir(), PATHS.stateFile()]
248
386
  .filter((p) => fs.existsSync(p));
249
387
  for (const p of scratch) fs.rmSync(p, { recursive: true, force: true });
250
388
  if (scratch.length) console.log('Removed the copied runtime and session state.');
@@ -264,7 +402,7 @@ async function uninstall({ purge }) {
264
402
  // Shorten an absolute path under $HOME to a `~/…` form for display. The shell
265
403
  // still expands `~`, so the result stays copy-pasteable.
266
404
  function tildeify(p) {
267
- const home = os.homedir();
405
+ const home = PATHS.homeDir();
268
406
  return p === home || p.startsWith(`${home}/`) ? `~${p.slice(home.length)}` : p;
269
407
  }
270
408
 
@@ -293,7 +431,7 @@ function printUninstallReceipt() {
293
431
  // config schema, reporting unknown keys and out-of-domain values (which the
294
432
  // renderer silently ignores). Exits non-zero when problems are found.
295
433
  function checkConfigCli() {
296
- const file = path.join(os.homedir(), '.claude', 'cc-cream.json');
434
+ const file = PATHS.configFile();
297
435
  if (!fs.existsSync(file)) {
298
436
  console.log(`No config at ${file} — cc-cream uses its defaults. Nothing to check.`);
299
437
  return;
@@ -345,13 +483,13 @@ function readJsonSafe(file) {
345
483
  // can't otherwise tell whether cc-cream fully went away — this command answers
346
484
  // "clean slate?" in one shot, and points out what the host left behind.
347
485
  function statusCli() {
348
- const home = path.join(os.homedir(), '.claude');
486
+ const home = PATHS.claudeDir();
349
487
  const plugins = path.join(home, 'plugins');
350
488
  const items = [];
351
489
  const add = (label, present, detail) => items.push({ label, present, detail });
352
490
 
353
491
  // statusLine wiring
354
- const { state, value } = readSettingsFile(settingsPath());
492
+ const { state, value } = readSettingsFile(PATHS.settingsFile());
355
493
  if (!isSafeToWrite(state)) {
356
494
  add('statusLine wiring', false, `settings.json unreadable (${state}) — not inspected`);
357
495
  } else if (isCcCreamStatusLine(value.statusLine)) {
@@ -397,7 +535,7 @@ function statusCli() {
397
535
  add('auto-wire marker', !!marker, marker || 'none');
398
536
 
399
537
  // session state
400
- const stateFile = path.join(home, 'cc-cream-state.json');
538
+ const stateFile = PATHS.stateFile();
401
539
  if (fs.existsSync(stateFile)) {
402
540
  const obj = readJsonSafe(stateFile);
403
541
  const n = obj && typeof obj === 'object' ? Object.keys(obj).length : '?';
@@ -407,11 +545,11 @@ function statusCli() {
407
545
  }
408
546
 
409
547
  // config
410
- const configFile = path.join(home, 'cc-cream.json');
548
+ const configFile = PATHS.configFile();
411
549
  add('config', fs.existsSync(configFile), fs.existsSync(configFile) ? configFile : 'none (using defaults)');
412
550
 
413
551
  // manual runtime copy
414
- const runtimeDir = path.join(home, 'cc-cream');
552
+ const runtimeDir = PATHS.runtimeDir();
415
553
  add('manual runtime copy', fs.existsSync(runtimeDir), fs.existsSync(runtimeDir) ? runtimeDir : 'none');
416
554
 
417
555
  console.log('cc-cream footprint:');
@@ -427,6 +565,62 @@ function statusCli() {
427
565
  console.log(' then rm -rf ~/.claude/plugins/cache/cc-cream (the host never removes it).');
428
566
  }
429
567
 
568
+ function allArgVals(args, flag) {
569
+ const results = [];
570
+ for (let i = 0; i < args.length; i++) {
571
+ if (args[i] === flag && i + 1 < args.length) {
572
+ results.push(args[i + 1]);
573
+ i++;
574
+ }
575
+ }
576
+ return results;
577
+ }
578
+
579
+ function argVal(args, flag) {
580
+ const i = args.indexOf(flag);
581
+ return i !== -1 && i + 1 < args.length ? args[i + 1] : null;
582
+ }
583
+
584
+ function splitIds(val) {
585
+ return val ? val.split(',').map((s) => s.trim()).filter(Boolean) : [];
586
+ }
587
+
588
+ async function setCli(assignments) {
589
+ const file = PATHS.configFile();
590
+ let raw = null;
591
+ try { raw = fs.readFileSync(file, 'utf8'); } catch { /* not found is fine */ }
592
+
593
+ const result = planSet(raw, assignments);
594
+ for (const m of result.messages) console.log(m);
595
+ if (result.problems.length) {
596
+ process.exit(1);
597
+ return;
598
+ }
599
+ if (result.changed) {
600
+ fs.mkdirSync(path.dirname(file), { recursive: true });
601
+ writeFileAtomic(file, `${JSON.stringify(result.config, null, 2)}\n`);
602
+ console.log(`\nWrote ${file}.`);
603
+ }
604
+ }
605
+
606
+ async function configureCli({ show, hide }) {
607
+ const file = PATHS.configFile();
608
+ let raw = null;
609
+ try { raw = fs.readFileSync(file, 'utf8'); } catch { /* not found is fine */ }
610
+
611
+ const result = planConfigure(raw, { show, hide });
612
+ for (const m of result.messages) console.log(m);
613
+ if (result.problems.length) {
614
+ process.exit(1);
615
+ return;
616
+ }
617
+ if (result.changed) {
618
+ fs.mkdirSync(path.dirname(file), { recursive: true });
619
+ writeFileAtomic(file, `${JSON.stringify(result.config, null, 2)}\n`);
620
+ console.log(`\nWrote ${file}.`);
621
+ }
622
+ }
623
+
430
624
  async function main() {
431
625
  const args = process.argv.slice(2);
432
626
  if (args.includes('--status')) {
@@ -441,12 +635,24 @@ async function main() {
441
635
  await uninstall({ purge: args.includes('--purge') });
442
636
  return;
443
637
  }
638
+ const showArg = argVal(args, '--show');
639
+ const hideArg = argVal(args, '--hide');
640
+ if (showArg !== null || hideArg !== null) {
641
+ await configureCli({ show: splitIds(showArg), hide: splitIds(hideArg) });
642
+ return;
643
+ }
644
+ const setArgs = allArgVals(args, '--set');
645
+ if (setArgs.length > 0) {
646
+ const assignments = setArgs.flatMap((v) => v.split(',').map((s) => s.trim()).filter(Boolean));
647
+ await setCli(assignments);
648
+ return;
649
+ }
444
650
  const plugin = args.includes('--plugin');
445
651
  const force = args.includes('--force') || args.includes('--yes');
446
652
  // First non-flag arg is an optional local source path (manual mode only).
447
653
  const positional = args.filter((a) => !a.startsWith('--'));
448
654
 
449
- const file = settingsPath();
655
+ const file = PATHS.settingsFile();
450
656
  const settings = readSettings(file);
451
657
 
452
658
  // planOpts holds the entrypoint + node path the command bakes in.
@@ -470,7 +676,7 @@ async function main() {
470
676
  process.exit(1);
471
677
  }
472
678
 
473
- const dest = destinationPath();
679
+ const dest = PATHS.runtimeEntry();
474
680
  const destDir = path.dirname(dest);
475
681
  if (copyRuntimeFiles(sourceFile, destDir)) {
476
682
  console.log(`Copied cc-cream runtime files to ${destDir}`);
@@ -0,0 +1,61 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ function realpathOr(p) {
5
+ try {
6
+ return fs.realpathSync(p);
7
+ } catch {
8
+ return path.resolve(p);
9
+ }
10
+ }
11
+
12
+ // If `selfPath` lives under `<root>/plugins/cache/<marketplace>/<plugin>/...`,
13
+ // return { pluginsDir, pluginHome }; otherwise null (manual/dev install, never
14
+ // a cache orphan). Both paths derive from the running location so the registry
15
+ // we consult is the one governing THIS install — no os.homedir() assumption.
16
+ function pluginCacheLocation(selfPath) {
17
+ const segs = realpathOr(selfPath).split(path.sep);
18
+ for (let i = 0; i + 3 < segs.length; i++) {
19
+ if (segs[i] === 'plugins' && segs[i + 1] === 'cache') {
20
+ return {
21
+ pluginsDir: segs.slice(0, i + 1).join(path.sep),
22
+ pluginHome: segs.slice(0, i + 4).join(path.sep),
23
+ };
24
+ }
25
+ }
26
+ return null;
27
+ }
28
+
29
+ function isWithin(parent, child) {
30
+ const rel = path.relative(parent, child);
31
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
32
+ }
33
+
34
+ // True when this renderer is a plugin-cache orphan: running from the cache while
35
+ // cc-cream is absent from the host's installed_plugins.json. Cost is one tiny
36
+ // read, and ONLY on the plugin-cache path — manual/dev installs return early
37
+ // before touching the disk. A missing registry (ENOENT) counts as orphaned; any
38
+ // other read/parse failure is treated as not-orphaned, so a transient glitch can
39
+ // never suppress a legitimately wired bar.
40
+ export function isOrphanedPluginRun(selfPath) {
41
+ const loc = pluginCacheLocation(selfPath);
42
+ if (!loc) return false;
43
+ let parsed;
44
+ try {
45
+ parsed = JSON.parse(fs.readFileSync(path.join(loc.pluginsDir, 'installed_plugins.json'), 'utf8'));
46
+ } catch (err) {
47
+ return err?.code === 'ENOENT';
48
+ }
49
+ const plugins = parsed && typeof parsed === 'object' ? parsed.plugins : null;
50
+ if (!plugins || typeof plugins !== 'object') return true;
51
+ const home = realpathOr(loc.pluginHome);
52
+ for (const entries of Object.values(plugins)) {
53
+ if (!Array.isArray(entries)) continue;
54
+ for (const entry of entries) {
55
+ if (entry && typeof entry.installPath === 'string' && isWithin(home, realpathOr(entry.installPath))) {
56
+ return false;
57
+ }
58
+ }
59
+ }
60
+ return true;
61
+ }
@@ -0,0 +1,16 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+
4
+ const homeDir = () => os.homedir();
5
+ const claudeDir = () => path.join(homeDir(), '.claude');
6
+
7
+ export const PATHS = {
8
+ homeDir,
9
+ claudeDir,
10
+ configFile: () => path.join(claudeDir(), 'cc-cream.json'),
11
+ stateFile: () => path.join(claudeDir(), 'cc-cream-state.json'),
12
+ debugLog: () => path.join(claudeDir(), 'cc-cream-debug.log'),
13
+ settingsFile: () => path.join(claudeDir(), 'settings.json'),
14
+ runtimeDir: () => path.join(claudeDir(), 'cc-cream'),
15
+ runtimeEntry: () => path.join(claudeDir(), 'cc-cream', 'cc-cream.js'),
16
+ };