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 +32 -0
- package/README.md +56 -14
- package/package.json +2 -1
- package/src/cc-cream.js +142 -4
- package/src/config.js +91 -16
- package/src/install.js +247 -102
- package/src/render.js +12 -3
- package/src/segments.js +8 -22
- package/src/settings.js +64 -0
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
|
|
139
|
+
/plugin uninstall cc-cream # 2. drops the plugin
|
|
140
140
|
```
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
>
|
|
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
|
-
>
|
|
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
|
|
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.
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
'
|
|
25
|
+
'The bar appears on your next message — restart only an already-open session, and the workspace must be trusted.';
|
|
21
26
|
|
|
22
|
-
// The
|
|
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.
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
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
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
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
|
|
40
|
-
return `
|
|
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
|
|
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
|
|
101
|
+
// `consent` is the user's yes/no when a FOREIGN statusLine must be replaced.
|
|
94
102
|
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
// -
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
export function plan(settings, { entrypoint, consent,
|
|
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 =
|
|
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
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
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:
|
|
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
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
233
|
-
//
|
|
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(
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
297
|
-
//
|
|
298
|
-
|
|
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(
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
-
//
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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(
|
|
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),
|
package/src/settings.js
ADDED
|
@@ -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
|
+
}
|