cc-cream 0.3.4 → 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 +29 -0
- package/README.md +50 -7
- package/package.json +12 -14
- package/{src → plugin/src}/cc-cream.js +10 -77
- package/{src → plugin/src}/config.js +44 -3
- package/{src → plugin/src}/defaults.js +11 -9
- package/{src → plugin/src}/install.js +233 -27
- package/plugin/src/orphan.js +61 -0
- package/plugin/src/paths.js +16 -0
- package/{src → plugin/src}/segments.js +7 -3
- package/{src → plugin/src}/utils.js +31 -7
- /package/{src → plugin/src}/render.js +0 -0
- /package/{src → plugin/src}/settings.js +0 -0
- /package/{src → plugin/src}/state.js +0 -0
- /package/{src → plugin/src}/ttl.js +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,35 @@ 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
|
+
|
|
21
|
+
## [0.3.6] — 2026-06-04
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
- **Plugin is now distributed via a lean catalogue repo (`bart-turczynski/claude-plugins`) instead of the full dev repo.** Previously the marketplace pointed at `bart-turczynski/cc-cream`, so every user who registered the marketplace got a full clone of the development repository — `features/`, `fixtures/`, `package-lock.json` (114 KB), `CHANGELOG.md` (31 KB), and the full git history — landing in `~/.claude/plugins/marketplaces/`. The marketplace source is now `bart-turczynski/claude-plugins`, a purpose-built catalogue containing only the plugin payload and `marketplace.json`. The marketplaces clone shrinks from ~1 MB to ~260 KB (irreducible floor: `.git/` + `.claude-plugin/`). Existing installs should remove and re-add the marketplace: `claude plugin marketplace remove bart-turczynski && claude plugin marketplace add bart-turczynski/claude-plugins`.
|
|
25
|
+
|
|
26
|
+
### Infrastructure
|
|
27
|
+
- Added `.github/workflows/sync-catalogue.yml`: on each GitHub Release, syncs `plugin/` contents to `cc-cream/` in the `bart-turczynski/claude-plugins` catalogue repo so the distribution copy stays current without manual maintenance.
|
|
28
|
+
|
|
29
|
+
## [0.3.5] — 2026-06-04
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
- **`/cc-cream:setup --force` now actually replaces an existing statusLine.** The setup slash command shelled out to `install.js --plugin` but never forwarded `$ARGUMENTS`, so `--force` (and any other flag) was silently dropped — `--force` appeared to do nothing when another tool already owned the statusLine. `commands/setup.md` now passes `$ARGUMENTS` through, matching `uninstall.md`. `install.js` already honored `--force`; only the command wiring was broken.
|
|
33
|
+
- **Plugin install no longer balloons the cache to ~114 MB.** Claude Code's plugin installer runs `npm install` whenever it finds a `package.json` in the cached plugin tree, which pulled the entire devDependency tree (Biome, Cucumber, knip, c8, …) into `~/.claude/plugins/cache/cc-cream/`. The plugin payload now lives in a `plugin/` subdirectory and the marketplace `source` points to `"./plugin"`, so only `plugin/`'s contents (`src/`, `commands/`, `hooks/`, `.claude-plugin/plugin.json`) are copied to the cache — with no `package.json` there, the installer has nothing to install. `package.json`, dev configs, and `marketplace.json` stay at the repo root; npm `bin`/`files` now point at `plugin/src/`. No behavior change to the rendered bar.
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
- **The `peak` segment now shows when the window closes and when the next one opens (CREAM-scwwzbxh).** Inside the faster-drain window it reads `peak until HH:MM` — the close time in your **local** timezone, not PT — so you can see how long the elevated drain lasts. In the hour before the window opens it counts down `peak in Nm`. The new `peak.lead` config key sets how many minutes ahead the countdown appears (default `60`). Previously the segment showed only the bare word `peak` while in-window and nothing otherwise.
|
|
37
|
+
|
|
9
38
|
## [0.3.4] — 2026-05-31
|
|
10
39
|
|
|
11
40
|
### Changed
|
package/README.md
CHANGED
|
@@ -19,23 +19,25 @@ With all segments enabled:
|
|
|
19
19
|
|
|
20
20
|
```
|
|
21
21
|
ctx:21% [43k] | cache:99% | write:2% | ttl:60 | effort:high | think:on | ∿ api:74% | ~$0.23
|
|
22
|
-
5h:13% ↺2h57m | ~3h12m | 7d:6% ↺Sat 21:00 | peak
|
|
22
|
+
5h:13% ↺2h57m | ~3h12m | 7d:6% ↺Sat 21:00 | peak until 11:00
|
|
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
|
|
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.
|
|
35
37
|
|
|
36
38
|
**Rate-limit budgets.** `5h` and `7d` show how much of your rolling usage is gone and when each window resets. `burn` adds a live projection based on your current pace — useful before committing to a long agent run.
|
|
37
39
|
|
|
38
|
-
**Peak hours.** Anthropic's rate-limit drain accelerates Mon–Fri during Pacific business hours. The `peak` segment
|
|
40
|
+
**Peak hours.** Anthropic's rate-limit drain accelerates Mon–Fri during Pacific business hours. The `peak` segment tells you when the current window closes (`peak until 11:00`, in your local time) while you're in it, and counts down to the next one (`peak in 47m`) in the hour before it opens — so you can pace yourself or wait it out. No other Claude Code status tool surfaces this.
|
|
39
41
|
|
|
40
42
|
**Context window.** `ctx` shows occupancy and input-token magnitude. On large-context models where "50% of window" still means 500k tokens, you can set a fixed-token ceiling instead — warnings fire at the same absolute count regardless of window size.
|
|
41
43
|
|
|
@@ -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/
|
|
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
|
-
|
|
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
|
-
|
|
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,33 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cc-cream",
|
|
3
|
-
"version": "0.
|
|
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"
|
|
7
7
|
},
|
|
8
8
|
"scripts": {
|
|
9
|
-
"lint": "biome lint src/ hooks/",
|
|
9
|
+
"lint": "biome lint plugin/src/ plugin/hooks/",
|
|
10
10
|
"knip": "knip",
|
|
11
|
-
"validate": "command -v claude >/dev/null 2>&1 && claude plugin validate
|
|
12
|
-
"pretest": "
|
|
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": "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": "
|
|
20
|
+
"prepublishOnly": "pnpm test"
|
|
21
21
|
},
|
|
22
22
|
"simple-git-hooks": {
|
|
23
|
-
"pre-push": "
|
|
23
|
+
"pre-push": "pnpm run coverage"
|
|
24
24
|
},
|
|
25
25
|
"bin": {
|
|
26
|
-
"cc-cream": "src/cc-cream.js",
|
|
27
|
-
"cc-cream-setup": "src/install.js"
|
|
26
|
+
"cc-cream": "plugin/src/cc-cream.js",
|
|
27
|
+
"cc-cream-setup": "plugin/src/install.js"
|
|
28
28
|
},
|
|
29
29
|
"files": [
|
|
30
|
-
"src/",
|
|
30
|
+
"plugin/src/",
|
|
31
31
|
"LICENSE",
|
|
32
32
|
"README.md",
|
|
33
33
|
"CHANGELOG.md"
|
|
@@ -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": ">=
|
|
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 ||
|
|
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
|
-
|
|
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 =
|
|
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
|
|
@@ -48,6 +47,7 @@ const SEGMENT_FIELDS = {
|
|
|
48
47
|
display: ctxDisplayOr,
|
|
49
48
|
start: hourOr,
|
|
50
49
|
end: hourOr,
|
|
50
|
+
lead: posOr,
|
|
51
51
|
};
|
|
52
52
|
|
|
53
53
|
function mergeConfig(parsed) {
|
|
@@ -140,9 +140,50 @@ export function checkConfig(parsed) {
|
|
|
140
140
|
return problems;
|
|
141
141
|
}
|
|
142
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
|
+
|
|
143
184
|
export function readConfigFile() {
|
|
144
185
|
try {
|
|
145
|
-
return fs.readFileSync(
|
|
186
|
+
return fs.readFileSync(PATHS.configFile(), 'utf8');
|
|
146
187
|
} catch {
|
|
147
188
|
return null;
|
|
148
189
|
}
|
|
@@ -19,16 +19,18 @@ export const DEFAULTS = {
|
|
|
19
19
|
cost: { on: true, row: 1, order: 5 },
|
|
20
20
|
'5h': { on: true, row: 2, order: 1, amber: 75, red: 90 },
|
|
21
21
|
'7d': { on: true, row: 2, order: 2, amber: 75, red: 90 },
|
|
22
|
-
// peak: amber
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
peak
|
|
22
|
+
// peak: amber peak-window indicator (PRDv2 §2). start/end are Pacific-time
|
|
23
|
+
// hours (0–23, exclusive end); weekday (Mon–Fri) and the America/Los_Angeles
|
|
24
|
+
// timezone are hardcoded policy facts, not config. Inside the window it reads
|
|
25
|
+
// "peak until HH:MM" (local close time); the `lead` minutes before it opens it
|
|
26
|
+
// counts down "peak in Nm".
|
|
27
|
+
peak: { on: true, row: 2, order: 3, start: 5, end: 11, lead: 60 },
|
|
26
28
|
burn: { on: true, row: 2, order: 1.5 },
|
|
27
|
-
effort: { on:
|
|
28
|
-
thinking: { on:
|
|
29
|
-
api_ratio: { on:
|
|
30
|
-
session_name: { on:
|
|
31
|
-
write: { on:
|
|
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 },
|
|
32
34
|
},
|
|
33
35
|
};
|
|
34
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
|
-
|
|
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
|
|
72
|
-
//
|
|
73
|
-
//
|
|
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
|
-
//
|
|
151
|
-
//
|
|
152
|
-
function
|
|
153
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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 =
|
|
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
|
|
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 = [
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
+
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { band, countdown, flipPct, fmtNum, isNum,
|
|
1
|
+
import { band, countdown, flipPct, fmtNum, isNum, localHM, numOr, pad2, peakStatus } from './utils.js';
|
|
2
2
|
import { hasWindow } from './ttl.js';
|
|
3
3
|
|
|
4
4
|
function magnitudeTokens(cw) {
|
|
@@ -125,8 +125,12 @@ function segCacheWrite(data) {
|
|
|
125
125
|
function segPeak(data, cfg, now, tz) {
|
|
126
126
|
// peak rides the account-budget row, so it shows only when that row has windows.
|
|
127
127
|
if (!hasWindow(data?.rate_limits)) return null;
|
|
128
|
-
|
|
129
|
-
|
|
128
|
+
const st = peakStatus(now, cfg, tz);
|
|
129
|
+
if (!st) return null;
|
|
130
|
+
const text = st.state === 'approaching'
|
|
131
|
+
? `peak in ${st.startsInMin}m` // counting down to the window opening
|
|
132
|
+
: `peak until ${localHM(st.endsAtMs)}`; // inside it: local clock time it closes
|
|
133
|
+
return { text, color: 'amber' };
|
|
130
134
|
}
|
|
131
135
|
|
|
132
136
|
function segBurn(fiveHour, prev, now) {
|
|
@@ -75,24 +75,48 @@ export const flipPct = (consumedShown, cfg) => (
|
|
|
75
75
|
cfg.percentage === 'remaining' ? 100 - consumedShown : consumedShown
|
|
76
76
|
);
|
|
77
77
|
|
|
78
|
-
//
|
|
79
|
-
// (Mon–Fri) within [start, end) Pacific-time hours.
|
|
80
|
-
|
|
78
|
+
// Resolve the "peak" window (PRDv2 §2) relative to `now`. Anthropic drains the
|
|
79
|
+
// 5h budget faster on weekdays (Mon–Fri) within [start, end) Pacific-time hours.
|
|
80
|
+
// Returns one of:
|
|
81
|
+
// { state: 'in', endsAtMs } — inside [start, end); endsAtMs is the
|
|
82
|
+
// window close as an epoch (format local)
|
|
83
|
+
// { state: 'approaching', startsInMin } — inside [start-lead, start)
|
|
84
|
+
// null — off-window, weekend, or Intl failure
|
|
85
|
+
// `lead` (minutes, default 60) is how early the approaching countdown appears.
|
|
86
|
+
export function peakStatus(now, cfg, tz = 'America/Los_Angeles') {
|
|
81
87
|
const s = cfg?.segments?.peak ?? {};
|
|
82
88
|
const start = isNum(s.start) ? s.start : 5;
|
|
83
89
|
const end = isNum(s.end) ? s.end : 11;
|
|
90
|
+
const lead = isNum(s.lead) && s.lead > 0 ? s.lead : 60;
|
|
84
91
|
try {
|
|
85
92
|
const parts = new Intl.DateTimeFormat('en-US', {
|
|
86
|
-
timeZone: tz, hour: 'numeric', hour12: false, weekday: 'short',
|
|
93
|
+
timeZone: tz, hour: 'numeric', minute: 'numeric', hour12: false, weekday: 'short',
|
|
87
94
|
}).formatToParts(new Date(now));
|
|
88
95
|
const p = Object.fromEntries(parts.map((x) => [x.type, x.value]));
|
|
89
|
-
|
|
90
|
-
|
|
96
|
+
if (['Sat', 'Sun'].includes(p.weekday)) return null;
|
|
97
|
+
const ptMin = (Number(p.hour) % 24) * 60 + Number(p.minute); // some ICU builds emit "24" for midnight
|
|
98
|
+
const startMin = start * 60;
|
|
99
|
+
const endMin = end * 60;
|
|
100
|
+
if (ptMin >= startMin && ptMin < endMin) {
|
|
101
|
+
return { state: 'in', endsAtMs: now + (endMin - ptMin) * 60000 };
|
|
102
|
+
}
|
|
103
|
+
if (ptMin >= startMin - lead && ptMin < startMin) {
|
|
104
|
+
return { state: 'approaching', startsInMin: startMin - ptMin };
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
91
107
|
} catch {
|
|
92
|
-
return
|
|
108
|
+
return null;
|
|
93
109
|
}
|
|
94
110
|
}
|
|
95
111
|
|
|
112
|
+
// Back-compat predicate: true only inside the window itself (not while approaching).
|
|
113
|
+
export const isPeak = (now, cfg, tz = 'America/Los_Angeles') => peakStatus(now, cfg, tz)?.state === 'in';
|
|
114
|
+
|
|
115
|
+
// Wall-clock HH:MM in the host's local timezone (TZ-driven, like countdown's day branch).
|
|
116
|
+
export const localHM = (ms) => new Date(ms).toLocaleTimeString(undefined, {
|
|
117
|
+
hour: '2-digit', minute: '2-digit', hour12: false,
|
|
118
|
+
});
|
|
119
|
+
|
|
96
120
|
// resets_at - now, on the §4.4 format ladder: >=1d -> "Fri 23:45", >=1h -> HhMMm, else MMm.
|
|
97
121
|
export function countdown(resetsAt, now) {
|
|
98
122
|
const t = toEpochMs(resetsAt);
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|