claude-code-runrate 0.1.0 → 0.2.1

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.
@@ -0,0 +1,457 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ // src/launch-win.js — native-Windows launcher for `ccr` / `ccr <profile>`.
5
+ //
6
+ // Replaces the bash+tmux launch layer (scripts/launch.sh) on win32 by driving
7
+ // Windows Terminal (wt.exe) split panes: pane 0 runs Claude Code, pane 1 runs
8
+ // `ccr sidecar`, with CCR_STATE_DIR injected into both. See SPEC §4.2/§5.2.
9
+ //
10
+ // The pure, side-effect-free helpers (profile validation, state resolution,
11
+ // wt.exe argv construction, the injected wt.exe lookup) are unit-tested without
12
+ // spawning anything; run()/fallbackNoWt() drive the real side effects through
13
+ // the injectable Deps in withDefaults().
14
+
15
+ const path = require('node:path');
16
+ const os = require('node:os');
17
+
18
+ // Profile names land in filesystem paths and a spawned command, so allow only a
19
+ // safe identifier — identical to the allow-list in scripts/launch.sh.
20
+ const PROFILE_RE = /^[A-Za-z0-9._-]+$/;
21
+
22
+ // Characters we cannot safely embed in the per-pane `cmd /k` payload, even with
23
+ // every path wrapped in double quotes:
24
+ // " ends the quoted string;
25
+ // % triggers cmd.exe variable expansion (it fires even inside quotes);
26
+ // \r \n break the command line.
27
+ // By contrast & | < > ^ ( ) ARE literal inside the double quotes we wrap every
28
+ // value in, so they need no special handling. The trust boundary here is the
29
+ // user's own env/paths (self-injection, not RCE), so the goal is to turn a
30
+ // silently-broken — or, with %, a maliciously-expandable — command into a clear,
31
+ // actionable error rather than to defend against an attacker.
32
+ const WT_UNSAFE_RE = /["%\r\n]/;
33
+
34
+ /**
35
+ * @param {string} value
36
+ * @returns {boolean} true if `value` can be safely interpolated into the cmd /k payload
37
+ */
38
+ function isWtArgSafe(value) {
39
+ return !WT_UNSAFE_RE.test(String(value));
40
+ }
41
+
42
+ // Upstream default split: the sidecar gets ~34% of the width.
43
+ const DEFAULT_SIDEBAR_PCT = 34;
44
+
45
+ // Where the sidecar pane sits relative to Claude. 'right' is a vertical split
46
+ // (wt split-pane -V, the default — matches the width-based sizing above);
47
+ // 'bottom' is a horizontal split (-H). Set via CCR_SIDEBAR_SIDE.
48
+ const DEFAULT_SIDEBAR_SIDE = 'right';
49
+
50
+ /**
51
+ * Map a sidebar side ('right' | 'bottom') to the wt.exe split-pane flag.
52
+ * 'right' → '-V' (new pane to the right), 'bottom' → '-H' (new pane below).
53
+ * Anything unrecognized falls back to the default side.
54
+ *
55
+ * @param {string} [side]
56
+ * @returns {'-V'|'-H'}
57
+ */
58
+ function sidebarSplitFlag(side) {
59
+ const s = String(side || DEFAULT_SIDEBAR_SIDE).toLowerCase();
60
+ return s === 'bottom' ? '-H' : '-V';
61
+ }
62
+
63
+ /**
64
+ * @param {unknown} profile
65
+ * @returns {boolean} true if the profile name is a safe identifier
66
+ */
67
+ function validateProfile(profile) {
68
+ return typeof profile === 'string' && PROFILE_RE.test(profile);
69
+ }
70
+
71
+ /**
72
+ * Resolve the Claude command, tmux-equivalent session name, state dir, and (for
73
+ * profiles) the expected CCS instance dir. Mirrors scripts/launch.sh, honoring
74
+ * the CC_BIN / CCR_SESSION / CCR_STATE_DIR overrides.
75
+ *
76
+ * @param {string} [profile] CCS profile name, or undefined for plain `claude`
77
+ * @param {{ env?: NodeJS.ProcessEnv, home?: string }} [opts]
78
+ * @returns {{ ccCmd: string, session: string, stateDir: string,
79
+ * instanceDir: string|null, usesCcs: boolean }}
80
+ */
81
+ function resolveProfileState(profile, opts = {}) {
82
+ const env = opts.env || process.env;
83
+ const home = opts.home || os.homedir();
84
+
85
+ if (profile) {
86
+ return {
87
+ ccCmd: `ccs ${profile}`,
88
+ session: env.CCR_SESSION || `ccr-${profile}`,
89
+ stateDir: env.CCR_STATE_DIR || path.join(home, '.ccr', profile),
90
+ instanceDir: path.join(home, '.ccs', 'instances', profile),
91
+ usesCcs: true,
92
+ };
93
+ }
94
+ return {
95
+ ccCmd: env.CC_BIN || 'claude',
96
+ session: env.CCR_SESSION || 'ccr',
97
+ stateDir: env.CCR_STATE_DIR || path.join(home, '.ccr'),
98
+ instanceDir: null,
99
+ usesCcs: false,
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Convert a width percentage (e.g. 34) into the fraction string wt.exe's
105
+ * `split-pane -s` expects (e.g. "0.34"). Clamps to a sane 5..95 range.
106
+ *
107
+ * @param {number} [pct]
108
+ * @returns {string}
109
+ */
110
+ function sidebarFraction(pct) {
111
+ let p = Number(pct);
112
+ if (!Number.isFinite(p)) p = DEFAULT_SIDEBAR_PCT;
113
+ p = Math.min(95, Math.max(5, Math.round(p)));
114
+ // Strip any float noise: 34 -> "0.34", 50 -> "0.5".
115
+ return String(Math.round(p) / 100);
116
+ }
117
+
118
+ /**
119
+ * Build the per-pane cmd.exe payload. Env is injected with `set "VAR=val"` so a
120
+ * path with spaces (or & | < >, which are literal inside the quotes) is fine and
121
+ * no trailing space is captured. Callers MUST pre-validate `stateDir` with
122
+ * isWtArgSafe — `"` and `%` are NOT made safe by these quotes (see WT_UNSAFE_RE).
123
+ *
124
+ * @param {string} stateDir
125
+ * @param {string} body the command(s) to run after the env is set
126
+ * @returns {string}
127
+ */
128
+ function paneCommand(stateDir, body) {
129
+ return `set "CCR_STATE_DIR=${stateDir}"&& ${body}`;
130
+ }
131
+
132
+ /**
133
+ * Compute the column budget for the sidecar pane so it can clamp every line and
134
+ * never soft-wrap. `process.stdout.columns` inside the `cmd /c` conpty pane is
135
+ * unreliable (often undefined or the FULL window width, not the narrow split),
136
+ * so the launcher — whose own stdout DOES report the live terminal width —
137
+ * computes the pane width here and injects it as CCR_SIDECAR_COLS.
138
+ *
139
+ * A vertical split (-V, sidebar on the right) gets `frac` of the width, minus one
140
+ * column for the pane divider. A horizontal split (-H, sidebar on the bottom)
141
+ * keeps the full width. Returns null when the terminal width is unknown (non-TTY
142
+ * launch) — the sidecar then falls back to whatever it can detect.
143
+ *
144
+ * @param {number|undefined} termCols the launcher's own terminal width
145
+ * @param {number} fracNum the sidebar fraction (e.g. 0.34)
146
+ * @param {'-V'|'-H'} splitFlag
147
+ * @returns {number|null}
148
+ */
149
+ function sidecarCols(termCols, fracNum, splitFlag) {
150
+ const t = Number(termCols);
151
+ if (!Number.isFinite(t) || t <= 0) return null;
152
+ if (splitFlag === '-H') return Math.max(20, Math.floor(t)); // bottom split keeps full width
153
+ return Math.max(20, Math.floor(t * fracNum) - 1); // right split: fraction, less the divider
154
+ }
155
+
156
+ /**
157
+ * Build the argv passed to wt.exe (excluding the wt.exe path itself):
158
+ * -w 0 new-tab --title Claude cmd /c "<pane0>" ; split-pane -H -s <frac> cmd /c "<pane1>"
159
+ *
160
+ * `-w 0` targets the CURRENT Windows Terminal window (open a tab in the window
161
+ * you ran `ccr` from) instead of spawning a separate window. The ";" pane
162
+ * separator is its own argv token (wt re-parses it); per-pane env is injected via
163
+ * `cmd /c set ...` rather than wt global env.
164
+ *
165
+ * Teardown mirrors the tmux launcher's sweep (launch.sh: `… ; touch exited; …;
166
+ * kill-session`): both panes run under `cmd /c`, so each closes when its command
167
+ * ends. On Claude exit pane 0 drops the `exited` sentinel and deletes the temp
168
+ * settings file; the sidecar runs with `--exit-on-end` so it detects the exit
169
+ * within ~120ms and closes pane 1 after a ~200ms grace (see sidecar.run).
170
+ *
171
+ * Close ORDER drives the sweep: pane 0 then lingers (SWEEP_LINGER ≈ 1s) past the
172
+ * sidecar's ~320ms close so the RIGHT pane collapses FIRST and Claude expands
173
+ * rightward to fill it — the sidebar border sweeps left→right and the tab folds,
174
+ * instead of pane 0 vanishing first and the sidecar ballooning right→left.
175
+ *
176
+ * Throws if any interpolated value contains a character that would break (or, in
177
+ * the case of %, hijack) the cmd /c payload — see isWtArgSafe. run() catches
178
+ * this and reports a clean error instead of spawning a broken command.
179
+ *
180
+ * @param {{ ccCmd: string, settingsFile: string, stateDir: string,
181
+ * node: string, ccrJs: string, sidebarPct?: number, sidebarSide?: string,
182
+ * termCols?: number }} o
183
+ * @returns {string[]}
184
+ */
185
+ function buildWtArgs(o) {
186
+ const { ccCmd, settingsFile, stateDir, node, ccrJs } = o;
187
+ for (const [label, value] of [
188
+ ['profile/state dir', stateDir],
189
+ ['settings file path', settingsFile],
190
+ ['claude command', ccCmd],
191
+ ['node path', node],
192
+ ['ccr.js path', ccrJs],
193
+ ]) {
194
+ if (!isWtArgSafe(value)) {
195
+ throw new Error(
196
+ `cannot launch: ${label} contains an unsupported character (" or %) for the ` +
197
+ `Windows Terminal launcher: ${value}`,
198
+ );
199
+ }
200
+ }
201
+ const frac = sidebarFraction(o.sidebarPct);
202
+ const splitFlag = sidebarSplitFlag(o.sidebarSide);
203
+ const exited = path.win32.join(stateDir, 'exited');
204
+
205
+ // After Claude exits we drop the sentinel + clean the settings file, then idle
206
+ // ~1s so pane 1 (which now detects the exit within ~120ms and closes after a
207
+ // ~200ms grace) folds FIRST and the border sweeps left→right. `ping -n 2`
208
+ // loopback is a reliable ~1s — its fixed inter-ping gap can't undershoot the
209
+ // sidecar's close the way `timeout /t 1` (0–1s, second-aligned) could, and it
210
+ // has none of `timeout`'s stdin quirks. ~1s is the floor: cmd.exe's wait
211
+ // primitives have 1-second granularity, so a tighter robust delay isn't
212
+ // available without fragile tricks. Silent under `>nul`.
213
+ const SWEEP_LINGER = 'ping -n 2 127.0.0.1 >nul';
214
+ const pane0 = paneCommand(
215
+ stateDir,
216
+ `${ccCmd} --settings "${settingsFile}" & type nul > "${exited}" & del /q "${settingsFile}" & ${SWEEP_LINGER}`,
217
+ );
218
+ // Inject the computed pane width so the sidecar clamps cleanly even when its own
219
+ // process.stdout.columns is unreliable inside the cmd /c conpty pane.
220
+ const cols = sidecarCols(o.termCols, Number(frac), splitFlag);
221
+ const sidecarBody =
222
+ (cols != null ? `set "CCR_SIDECAR_COLS=${cols}"&& ` : '') +
223
+ `"${node}" "${ccrJs}" sidecar --exit-on-end`;
224
+ const pane1 = paneCommand(stateDir, sidecarBody);
225
+
226
+ return [
227
+ '-w', '0', 'new-tab', '--title', 'Claude', 'cmd', '/c', pane0,
228
+ ';',
229
+ 'split-pane', splitFlag, '-s', frac, 'cmd', '/c', pane1,
230
+ ];
231
+ }
232
+
233
+ /**
234
+ * Resolve wt.exe via `where`, or return null if absent. The lookup is injected
235
+ * so this stays unit-testable; the default shells out to `where`.
236
+ *
237
+ * @param {{ runWhere?: (name: string) => (string|null) }} [opts]
238
+ * @returns {string|null}
239
+ */
240
+ function findWindowsTerminal(opts = {}) {
241
+ const lookup = opts.runWhere || defaultWhere;
242
+ return lookup('wt') || lookup('wt.exe') || null;
243
+ }
244
+
245
+ /**
246
+ * @param {string} name
247
+ * @returns {string|null} first match path, or null
248
+ */
249
+ function defaultWhere(name) {
250
+ const { spawnSync } = require('node:child_process');
251
+ const r = spawnSync('where', [name], { encoding: 'utf8' });
252
+ if (r.status !== 0 || !r.stdout) return null;
253
+ const first = r.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0];
254
+ return first || null;
255
+ }
256
+
257
+ const inject = require('./settings-inject');
258
+ const { ensureSecureDir } = require('./state-dir');
259
+
260
+ /**
261
+ * Fill in real-environment implementations for anything the caller didn't
262
+ * inject. Every external effect (PATH lookups, fs, spawn, output) flows through
263
+ * here so run() can be unit-tested with pure stand-ins.
264
+ *
265
+ * @param {Partial<Deps>} deps
266
+ * @returns {Deps}
267
+ */
268
+ function withDefaults(deps) {
269
+ const env = deps.env || process.env;
270
+ const home = deps.home || os.homedir();
271
+ return {
272
+ env,
273
+ home,
274
+ // The launcher's own stdout reports the live terminal width — the sidecar's
275
+ // does not, inside its cmd /c pane (see sidecarCols). undefined on non-TTY.
276
+ cols: deps.cols != null ? deps.cols : process.stdout.columns,
277
+ node: deps.node || process.execPath,
278
+ ccrJs: deps.ccrJs || path.join(__dirname, '..', 'bin', 'ccr.js'),
279
+ out: deps.out || ((s) => { process.stdout.write(s); }),
280
+ err: deps.err || ((s) => { process.stderr.write(s); }),
281
+ findWt: deps.findWt || (() => findWindowsTerminal()),
282
+ which: deps.which || defaultWhere,
283
+ existsDir: deps.existsDir || defaultExistsDir,
284
+ listDir: deps.listDir || defaultListDir,
285
+ ensureDir: deps.ensureDir || ensureSecureDir,
286
+ removeExited: deps.removeExited || defaultRemoveExited,
287
+ writeSettings: deps.writeSettings || ((s) => inject.writeSettingsFile(s)),
288
+ cleanup: deps.cleanup || ((f) => inject.cleanupSettingsFile(f)),
289
+ spawnWt: deps.spawnWt || defaultSpawnWt,
290
+ };
291
+ }
292
+
293
+ /** @param {string} dir @returns {boolean} */
294
+ function defaultExistsDir(dir) {
295
+ try {
296
+ return require('node:fs').statSync(dir).isDirectory();
297
+ } catch {
298
+ return false;
299
+ }
300
+ }
301
+
302
+ /** @param {string} dir @returns {string[]} */
303
+ function defaultListDir(dir) {
304
+ try {
305
+ return require('node:fs').readdirSync(dir);
306
+ } catch {
307
+ return [];
308
+ }
309
+ }
310
+
311
+ /** @param {string} stateDir @returns {void} */
312
+ function defaultRemoveExited(stateDir) {
313
+ try {
314
+ require('node:fs').rmSync(path.join(stateDir, 'exited'), { force: true });
315
+ } catch {
316
+ // best effort
317
+ }
318
+ }
319
+
320
+ /** @param {string} wt @param {string[]} args @returns {{status:number|null, error?:Error}} */
321
+ function defaultSpawnWt(wt, args) {
322
+ return require('node:child_process').spawnSync(wt, args, { stdio: 'ignore' });
323
+ }
324
+
325
+ /**
326
+ * The graceful no-Windows-Terminal fallback: keep the working native CLI usable
327
+ * instead of dead-ending. Returns 1 (no crash, no stack trace). SPEC §6 / §8.7.
328
+ *
329
+ * @param {{ err: (s: string) => void }} d
330
+ * @returns {number}
331
+ */
332
+ function fallbackNoWt(d) {
333
+ d.err(
334
+ 'ccr: Windows Terminal (wt.exe) not found — it hosts the live sidecar.\n' +
335
+ ' Install it: winget install Microsoft.WindowsTerminal\n' +
336
+ ' Until then these native commands work without it:\n' +
337
+ ' ccr economy one-off economy panel\n' +
338
+ " ccr statusline wire into Claude Code's statusLine\n" +
339
+ ' ccr doctor check your setup\n');
340
+ return 1;
341
+ }
342
+
343
+ /**
344
+ * `ccr [profile]` on native Windows: split a Windows Terminal window into
345
+ * Claude Code + ccr sidecar. Implements SPEC §4.2 steps 1–7. Returns an exit
346
+ * code. All side effects are injectable for testing (see withDefaults).
347
+ *
348
+ * @param {string} [profile]
349
+ * @param {Partial<Deps>} [deps]
350
+ * @returns {number}
351
+ */
352
+ function run(profile, deps = {}) {
353
+ const d = withDefaults(deps);
354
+
355
+ // 1. Validate the profile (it lands in paths and a spawned command).
356
+ if (profile !== undefined && !validateProfile(profile)) {
357
+ d.err(`ccr: invalid profile name '${profile}' (allowed: letters, digits, . _ -)\n`);
358
+ return 1;
359
+ }
360
+
361
+ // 2. Require Windows Terminal, else fall back gracefully.
362
+ const wt = d.findWt();
363
+ if (!wt) return fallbackNoWt(d);
364
+
365
+ // 3. Resolve profile state + required binaries.
366
+ const st = resolveProfileState(profile, { env: d.env, home: d.home });
367
+ if (st.usesCcs) {
368
+ if (!d.which('ccs')) {
369
+ d.err("ccr: 'ccs' not found on PATH — pass a profile only if CCS is installed.\n");
370
+ return 1;
371
+ }
372
+ if (st.instanceDir && !d.existsDir(st.instanceDir)) {
373
+ d.err(`ccr: CCS profile '${profile}' not found (${st.instanceDir}).\n`);
374
+ const avail = d.listDir(path.join(d.home, '.ccs', 'instances')).join(' ');
375
+ d.err(` available: ${avail}\n`);
376
+ return 1;
377
+ }
378
+ } else {
379
+ const bin = st.ccCmd.split(' ')[0];
380
+ if (!d.which(bin)) {
381
+ d.err(`ccr: '${bin}' not found on PATH.\n`);
382
+ return 1;
383
+ }
384
+ }
385
+
386
+ // 4. Prepare the per-profile state dir; clear a stale sentinel.
387
+ try { d.ensureDir(st.stateDir); } catch { /* best effort */ }
388
+ d.removeExited(st.stateDir);
389
+
390
+ // 5. Inject statusLine via a temp settings FILE (avoids CLI JSON quoting).
391
+ const command = inject.buildStatusLineCommandInline({ node: d.node, ccrJs: d.ccrJs });
392
+ const settingsFile = d.writeSettings(inject.buildSettings(command));
393
+
394
+ // 6. Build + spawn the wt.exe command (CCR_STATE_DIR injected per-pane).
395
+ const pct = parseInt(String(d.env.CCR_SIDEBAR_PCT), 10);
396
+ let args;
397
+ try {
398
+ args = buildWtArgs({
399
+ ccCmd: st.ccCmd,
400
+ settingsFile,
401
+ stateDir: st.stateDir,
402
+ node: d.node,
403
+ ccrJs: d.ccrJs,
404
+ sidebarPct: Number.isFinite(pct) ? pct : DEFAULT_SIDEBAR_PCT,
405
+ sidebarSide: d.env.CCR_SIDEBAR_SIDE || DEFAULT_SIDEBAR_SIDE,
406
+ termCols: d.cols,
407
+ });
408
+ } catch (e) {
409
+ d.err(`ccr: ${e instanceof Error ? e.message : String(e)}\n`);
410
+ d.cleanup(settingsFile); // window never opened → nothing else will clean up
411
+ return 1;
412
+ }
413
+ const r = d.spawnWt(wt, args);
414
+ if (r.error) {
415
+ d.err(`ccr: failed to launch Windows Terminal: ${r.error.message}\n`);
416
+ d.cleanup(settingsFile); // window never opened → pane 0 can't clean up
417
+ return 1;
418
+ }
419
+ // 7. On success the settings file is cleaned up by pane 0 when Claude exits.
420
+ return typeof r.status === 'number' ? r.status : 0;
421
+ }
422
+
423
+ /**
424
+ * @typedef {object} Deps
425
+ * @property {NodeJS.ProcessEnv} env
426
+ * @property {string} home
427
+ * @property {number|undefined} cols
428
+ * @property {string} node
429
+ * @property {string} ccrJs
430
+ * @property {(s: string) => void} out
431
+ * @property {(s: string) => void} err
432
+ * @property {() => (string|null)} findWt
433
+ * @property {(name: string) => (string|null)} which
434
+ * @property {(dir: string) => boolean} existsDir
435
+ * @property {(dir: string) => string[]} listDir
436
+ * @property {(dir: string) => void} ensureDir
437
+ * @property {(dir: string) => void} removeExited
438
+ * @property {(settings: object) => string} writeSettings
439
+ * @property {(file: string) => void} cleanup
440
+ * @property {(wt: string, args: string[]) => {status: number|null, error?: Error}} spawnWt
441
+ */
442
+
443
+ module.exports = {
444
+ PROFILE_RE,
445
+ DEFAULT_SIDEBAR_PCT,
446
+ DEFAULT_SIDEBAR_SIDE,
447
+ validateProfile,
448
+ isWtArgSafe,
449
+ resolveProfileState,
450
+ sidebarFraction,
451
+ sidebarSplitFlag,
452
+ sidecarCols,
453
+ buildWtArgs,
454
+ findWindowsTerminal,
455
+ run,
456
+ fallbackNoWt,
457
+ };
@@ -15,7 +15,11 @@ const { dim, bold, green, red, yellow, cyan, flash, pctColor, bar, tok, fmtMins,
15
15
  const bandColor = { imminent: red, warn: yellow, ok: cyan };
16
16
 
17
17
  function wallRow(/** @type {any} */ row, /** @type {any} */ L, /** @type {boolean} */ tick, /** @type {number} */ labelW) {
18
- const used = Math.round(row.est.usedPct);
18
+ // Truncate, don't round: Claude's own surfaces (`/usage`, claude.ai usage)
19
+ // floor the fractional `used_percentage` (e.g. 41.6 → "41%"). Math.round here
20
+ // read ~1pt high on values past the half-point. Display only — the burn/ROI
21
+ // math below still uses the raw fractional `row.est.usedPct`.
22
+ const used = Math.floor(row.est.usedPct);
19
23
  const ml = row.est.minutesLeft;
20
24
  const b = band(ml);
21
25
  // Per-row colour dot: green when the window resets before you'd hit it,
@@ -21,6 +21,34 @@ function bar(/** @type {number} */ p, w = 10) {
21
21
  return '▓'.repeat(f) + '░'.repeat(w - f);
22
22
  }
23
23
 
24
+ /**
25
+ * Clamp one line to `cols` visible columns: SGR escapes (`\x1b[…m`) pass through
26
+ * with zero width, printable chars count as 1. Appends a reset if it had to cut,
27
+ * so a severed colour run doesn't bleed into the cleared tail. Prevents the soft
28
+ * wrap that corrupts the sidecar's cursor-home redraw in a narrow pane. A
29
+ * non-positive `cols` (e.g. a non-TTY where columns is undefined) is a no-op.
30
+ * @param {string} line
31
+ * @param {number} [cols]
32
+ * @returns {string}
33
+ */
34
+ function clampVisible(line, cols) {
35
+ if (!(typeof cols === 'number' && cols > 0)) return line;
36
+ const sgr = /\x1b\[[0-9;]*m/y;
37
+ let out = '';
38
+ let width = 0;
39
+ let i = 0;
40
+ while (i < line.length) {
41
+ sgr.lastIndex = i;
42
+ const m = sgr.exec(line);
43
+ if (m) { out += m[0]; i = sgr.lastIndex; continue; }
44
+ if (width >= cols) return out + '\x1b[0m';
45
+ out += line[i];
46
+ width += 1;
47
+ i += 1;
48
+ }
49
+ return out;
50
+ }
51
+
24
52
  function tok(/** @type {number|null} */ n) {
25
53
  if (n == null) return '?';
26
54
  if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
@@ -46,4 +74,4 @@ function fmtReset(/** @type {number|null} */ min) {
46
74
  return `${m}m`;
47
75
  }
48
76
 
49
- module.exports = { e, dim, bold, green, red, yellow, cyan, flash, pctColor, bar, tok, fmtMins, fmtReset };
77
+ module.exports = { e, dim, bold, green, red, yellow, cyan, flash, pctColor, bar, clampVisible, tok, fmtMins, fmtReset };
@@ -0,0 +1,77 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ // src/settings-inject.js — per-launch statusLine injection (SPEC §4.2 step 5, §5.3).
5
+ //
6
+ // Claude Code must call `ccr statusline` on every status tick WITHOUT mutating
7
+ // any file under ~/.claude. We achieve that exactly like upstream launch.sh:
8
+ // write a throwaway settings object to a temp file and pass it to
9
+ // `claude --settings <file>`. A FILE (not inline --settings '{...}') sidesteps
10
+ // the Windows command-line JSON-quoting minefield.
11
+ //
12
+ // The statusLine `command` value is the inline form: node + bin/ccr.js resolved
13
+ // by absolute path. Because it lives inside the JSON settings file (never on a
14
+ // shell line), no shell-quoting is involved and no separate shim file is needed.
15
+
16
+ const fs = require('node:fs');
17
+ const os = require('node:os');
18
+ const path = require('node:path');
19
+ const crypto = require('node:crypto');
20
+
21
+ /**
22
+ * Inline statusLine command: resolve node + ccr.js by path. Quoting is safe
23
+ * because this value lives inside the JSON settings FILE, not on a shell line.
24
+ *
25
+ * @param {{ node: string, ccrJs: string }} o
26
+ * @returns {string}
27
+ */
28
+ function buildStatusLineCommandInline(o) {
29
+ return `"${o.node}" "${o.ccrJs}" statusline`;
30
+ }
31
+
32
+ /**
33
+ * The settings object Claude Code consumes via --settings.
34
+ *
35
+ * @param {string} command the statusLine command value
36
+ * @returns {{ statusLine: { type: 'command', command: string } }}
37
+ */
38
+ function buildSettings(command) {
39
+ return { statusLine: { type: 'command', command } };
40
+ }
41
+
42
+ /**
43
+ * Write the settings object to a uniquely-named temp file and return its path.
44
+ * The file lives under the temp dir only — never under ~/.claude.
45
+ *
46
+ * @param {object} settings
47
+ * @param {{ tmpDir?: string, rand?: string }} [opts]
48
+ * @returns {string} absolute path to the written settings file
49
+ */
50
+ function writeSettingsFile(settings, opts = {}) {
51
+ const dir = opts.tmpDir || os.tmpdir();
52
+ const rand = opts.rand || crypto.randomBytes(4).toString('hex');
53
+ const file = path.join(dir, `ccr-settings-${rand}.json`);
54
+ fs.writeFileSync(file, JSON.stringify(settings), { encoding: 'utf8' });
55
+ return file;
56
+ }
57
+
58
+ /**
59
+ * Best-effort removal of the temp settings file. Never throws.
60
+ *
61
+ * @param {string} file
62
+ * @returns {void}
63
+ */
64
+ function cleanupSettingsFile(file) {
65
+ try {
66
+ fs.rmSync(file, { force: true });
67
+ } catch {
68
+ // best-effort: a leftover temp file is harmless.
69
+ }
70
+ }
71
+
72
+ module.exports = {
73
+ buildStatusLineCommandInline,
74
+ buildSettings,
75
+ writeSettingsFile,
76
+ cleanupSettingsFile,
77
+ };