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.
package/README.md CHANGED
@@ -51,15 +51,42 @@ npm i -g claude-code-runrate
51
51
  | `ccr economy --json` | Stable machine-readable model (scripting) | ✅ | ✅ | ✅ |
52
52
  | `ccr resume` | Recent sessions ranked by cost to resume | ✅ | ✅ | ✅ |
53
53
  | `ccr statusline` | One-line summary wired into CC's `statusLine` | ✅ | ✅ | ✅ |
54
- | `ccr sidecar` | Live tmux dashboard + tool/skills feed | ✅ | ✅ | (tmux; use WSL) |
54
+ | `ccr sidecar` | Live dashboard + tool/skills feed | ✅ tmux | ✅ tmux | Windows Terminal / VS Code |
55
55
 
56
- The CLI and statusline are pure Node — they run on native Windows. The rich live
57
- sidebar needs `tmux`, so on Windows use WSL.
56
+ The CLI and statusline are pure Node — they run on native Windows. The live
57
+ sidebar runs on **Windows Terminal** (`ccr` splits it automatically) or inside
58
+ **VS Code's integrated terminal** on any OS (see [VS Code](#vs-code-split-terminal)).
59
+ On Linux/macOS the default host is `tmux`. No WSL required.
58
60
 
59
61
  ## Requirements
60
62
 
61
63
  - **Node ≥ 18.3** — that's it for the core. **Zero runtime dependencies.**
62
- - `tmux` — only for the optional `ccr sidecar`.
64
+ - For the optional live `ccr sidecar`: `tmux` on Linux/macOS, or **Windows
65
+ Terminal** / **VS Code's integrated terminal** on Windows (no WSL).
66
+
67
+ ## VS Code (split terminal)
68
+
69
+ `ccr` detects VS Code's integrated terminal (`TERM_PROGRAM=vscode`) and wires the
70
+ live sidebar into a **split pane** — no separate window, no WSL. A shell can't
71
+ trigger the split itself, so `ccr` does everything around it:
72
+
73
+ 1. Run `ccr` (or `ccr <profile>`). Claude starts in the **current** pane and a
74
+ bright banner shows the steps. The sidecar command is **copied to your
75
+ clipboard** automatically (via an OSC 52 escape — works over SSH/remote too).
76
+ 2. **Split the terminal** — `Ctrl+Shift+5` (Windows/Linux) or `Cmd+\` (macOS).
77
+ 3. **Paste** into the new pane and press Enter — the live sidebar runs there.
78
+
79
+ Lost the banner once Claude takes the screen? Run `ccr sidecar --hint` to reprint
80
+ the steps and re-copy the command.
81
+
82
+ On **Windows** this is the default inside VS Code (Windows Terminal otherwise
83
+ opens a separate window, so the in-editor split is nicer). On **Linux/macOS**,
84
+ `ccr` defaults to `tmux` (which works inside the VS Code terminal too); set
85
+ `CCR_VSCODE=1` to use the split-terminal flow there instead.
86
+
87
+ > Automating the split keystroke itself would need a VS Code extension (the `code`
88
+ > CLI has no "run command" verb) — out of scope for the zero-dependency core. The
89
+ > clipboard + `--hint` reduce it to split-and-paste.
63
90
 
64
91
  ## Wiring the statusline into Claude Code
65
92
 
@@ -77,7 +104,10 @@ latency.)
77
104
 
78
105
  This project is built **BDD-first**: the Gherkin in [`features/`](features/) is
79
106
  the source of truth, executed by a hand-rolled zero-dependency harness on top of
80
- Node's built-in test runner.
107
+ Node's built-in test runner — a ~250-line Gherkin parser + runner that supports
108
+ the practical core of the grammar and rejects everything else loudly rather than
109
+ mis-parsing it. See [`docs/GHERKIN.md`](docs/GHERKIN.md) for the grammar, the
110
+ deliberate limits, and the API.
81
111
 
82
112
  ```bash
83
113
  npm test # node --test — harness self-tests + feature scenarios
package/bin/ccr.js CHANGED
@@ -40,7 +40,8 @@ Examples:
40
40
 
41
41
  Options:
42
42
  -h, --help Show this help
43
- -v, --version Show version`;
43
+ -v, --version Show version
44
+ --mary Enable the mary interface`;
44
45
 
45
46
  /**
46
47
  * @param {string[]} argv
@@ -56,6 +57,10 @@ function main(argv) {
56
57
  help: { type: 'boolean', short: 'h' },
57
58
  version: { type: 'boolean', short: 'v' },
58
59
  json: { type: 'boolean' },
60
+ 'state-dir': { type: 'string' },
61
+ hint: { type: 'boolean' },
62
+ 'exit-on-end': { type: 'boolean' },
63
+ mary: { type: 'boolean' },
59
64
  },
60
65
  });
61
66
  } catch (err) {
@@ -66,6 +71,9 @@ function main(argv) {
66
71
  const { values, positionals } = parsed;
67
72
  if (values.version) { process.stdout.write(`${pkg.version}\n`); return 0; }
68
73
  if (values.help) { process.stdout.write(`${HELP}\n`); return 0; }
74
+ // The mary interface is an env toggle under the hood; surface it as a flag so
75
+ // in-process panels (economy/statusline/resume) pick it up for this run.
76
+ if (values.mary) process.env.CCR_ENABLE_MARY_INTERFACE = '1';
69
77
 
70
78
  const cmd = positionals[0];
71
79
  if (!cmd) return cmdLaunch(undefined); // bare `ccr` → launch
@@ -73,7 +81,7 @@ function main(argv) {
73
81
  case 'economy': return cmdEconomy(!!values.json);
74
82
  case 'resume': return cmdResume(positionals[1]);
75
83
  case 'statusline': return cmdStatusline();
76
- case 'sidecar': return cmdSidecar();
84
+ case 'sidecar': return cmdSidecar(values['state-dir'], !!values.hint, !!values['exit-on-end']);
77
85
  case 'doctor': return require('../src/doctor').run();
78
86
  case 'launch': return cmdLaunch(positionals[1]);
79
87
  default: return cmdLaunch(cmd); // anything else → treat as a CCS profile
@@ -151,26 +159,40 @@ function cmdResume(arg) {
151
159
  return 0;
152
160
  }
153
161
 
154
- /** `ccr sidecar` — live economy panel; keeps the process alive (no exit code). */
155
- function cmdSidecar() {
156
- require('../src/sidecar').run();
162
+ /**
163
+ * `ccr sidecar` — live economy panel; keeps the process alive (no exit code).
164
+ * `--state-dir <dir>` targets a specific session (used by the VS Code split-pane
165
+ * one-liner, which is shell-agnostic). `--hint` reprints the VS Code split
166
+ * instructions + re-copies the one-liner instead of running the panel.
167
+ * `--exit-on-end` closes the panel shortly after the session ends (the Windows
168
+ * Terminal launcher passes it so its `cmd /c` pane sweeps closed like tmux).
169
+ * @param {string | undefined} stateDir
170
+ * @param {boolean} [showHint]
171
+ * @param {boolean} [exitOnEnd]
172
+ * @returns {number | undefined}
173
+ */
174
+ function cmdSidecar(stateDir, showHint, exitOnEnd) {
175
+ if (stateDir) process.env.CCR_STATE_DIR = stateDir;
176
+ if (showHint) return require('../src/launch-vscode').hint(process.env.CCR_STATE_DIR || STATE_DIR);
177
+ require('../src/sidecar').run({ exitOnEnd: !!exitOnEnd });
157
178
  return undefined;
158
179
  }
159
180
 
160
181
  /**
161
- * `ccr [profile]` — launch the tmux session (claude/ccs + sidebar) via launch.sh.
182
+ * `ccr [profile]` — launch the live sidecar. Inside VS Code's integrated terminal
183
+ * we split it in place (Windows always; other OSes via CCR_VSCODE=1, since tmux
184
+ * already works there); on native Windows we drive Windows Terminal; otherwise
185
+ * the tmux launcher (scripts/launch.sh).
162
186
  * @param {string | undefined} profile
163
187
  * @returns {number}
164
188
  */
165
189
  function cmdLaunch(profile) {
190
+ const inVscode = process.env.TERM_PROGRAM === 'vscode';
191
+ if (inVscode && (process.platform === 'win32' || process.env.CCR_VSCODE === '1')) {
192
+ return require('../src/launch-vscode').run(profile);
193
+ }
166
194
  if (process.platform === 'win32') {
167
- process.stderr.write(
168
- 'ccr: the live sidebar needs tmux + bash, which native Windows lacks.\n' +
169
- ' Use WSL for the sidebar, or run these directly (they work natively):\n' +
170
- ' ccr economy one-off economy panel\n' +
171
- " ccr statusline wire into Claude Code's statusLine\n" +
172
- ' ccr doctor check your setup\n');
173
- return 1;
195
+ return require('../src/launch-win').run(profile);
174
196
  }
175
197
  const { spawnSync } = require('node:child_process');
176
198
  const launcher = path.join(__dirname, '..', 'scripts', 'launch.sh');
package/package.json CHANGED
@@ -1,11 +1,16 @@
1
1
  {
2
2
  "name": "claude-code-runrate",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Claude Code run-rate — subscription burn-rate & economy for your Claude Code sessions.",
5
5
  "license": "MIT",
6
6
  "author": "Bing Ho <reps-attic-riot@duck.com>",
7
- "repository": { "type": "git", "url": "git+https://github.com/bingh0/ccr.git" },
8
- "bugs": { "url": "https://github.com/bingh0/ccr/issues" },
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/bingh0/ccr.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/bingh0/ccr/issues"
13
+ },
9
14
  "homepage": "https://github.com/bingh0/ccr#readme",
10
15
  "type": "commonjs",
11
16
  "bin": {
@@ -24,7 +29,8 @@
24
29
  ],
25
30
  "scripts": {
26
31
  "test": "node --test",
27
- "typecheck": "tsc --noEmit -p jsconfig.json"
32
+ "typecheck": "tsc --noEmit -p jsconfig.json",
33
+ "lint": "oxlint"
28
34
  },
29
35
  "keywords": [
30
36
  "claude-code",
@@ -36,6 +42,7 @@
36
42
  "dependencies": {},
37
43
  "devDependencies": {
38
44
  "@types/node": "^24.0.0",
45
+ "oxlint": "^1.71.0",
39
46
  "typescript": "^5.7.0"
40
47
  }
41
48
  }
package/src/doctor.js CHANGED
@@ -34,10 +34,19 @@ function isExec(/** @type {string} */ f) {
34
34
  try { return (fs.statSync(f).mode & 0o111) !== 0; } catch { return false; }
35
35
  }
36
36
 
37
- /** @returns {number} exit code (0 = healthy) */
38
- function run() {
39
- const REPO = path.join(__dirname, '..');
40
- const isWin = process.platform === 'win32';
37
+ /**
38
+ * @param {{ platform?: string, has?: (cmd: string) => (string|null),
39
+ * homedir?: string, repo?: string, write?: (s: string) => void }} [opts]
40
+ * side effects are injectable for testing; defaults hit the real environment
41
+ * @returns {number} exit code (0 = healthy)
42
+ */
43
+ function run(opts = {}) {
44
+ const platform = opts.platform || process.platform;
45
+ const hasFn = opts.has || has;
46
+ const homedir = opts.homedir || os.homedir();
47
+ const REPO = opts.repo || path.join(__dirname, '..');
48
+ const write = opts.write || ((s) => { process.stdout.write(s); });
49
+ const isWin = platform === 'win32';
41
50
  const out = [bold('ccr doctor'), ''];
42
51
  let problems = 0;
43
52
 
@@ -46,29 +55,35 @@ function run() {
46
55
  out.push(nodeOk ? ok(`node ${process.version}`) : bad(`node ${process.version} — need >= 18.3`));
47
56
  if (!nodeOk) problems++;
48
57
 
49
- const ccr = has('ccr');
58
+ const ccr = hasFn('ccr');
50
59
  out.push(ccr ? ok(`ccr on PATH (${stripControl(ccr)})`) : warn('ccr not on PATH — run `npm link` in the repo'));
51
60
  if (!ccr) problems++;
52
61
 
53
62
  if (isWin) {
54
- // The live sidebar (tmux + bash) is WSL-only on native Windows by design,
55
- // not a problem. Say so plainly so a Windows user isn't told to "fix" it.
56
- out.push(dim('· live sidebar (`ccr`) needs tmux + bash — for that, use WSL2.'));
57
- out.push(dim(' The CLI (economy / statusline / resume / doctor) runs natively here.'));
63
+ // Native Windows hosts the sidecar in Windows Terminalno tmux/bash/WSL.
64
+ const wt = hasFn('wt');
65
+ if (wt) {
66
+ out.push(ok(`Windows Terminal (sidecar host) (${stripControl(wt)})`));
67
+ } else {
68
+ out.push(warn('Windows Terminal not found — the sidecar needs it (winget install Microsoft.WindowsTerminal); the CLI still works'));
69
+ problems++;
70
+ }
71
+ // statusLine is injected inline (node + bin/ccr.js by path) via the per-launch
72
+ // temp settings file, so there's no shipped shim asset to check on Windows.
58
73
  } else {
59
- const tmux = has('tmux');
60
- out.push(tmux ? ok(`tmux (${stripControl(tmux)})`) : warn('tmux missing — needed for `ccr [profile]` sidebar (use WSL on Windows)'));
74
+ const tmux = hasFn('tmux');
75
+ out.push(tmux ? ok(`tmux (${stripControl(tmux)})`) : warn('tmux missing — needed for the `ccr [profile]` sidebar'));
61
76
  if (!tmux) problems++;
62
- out.push(has('bash') ? ok('bash') : warn('bash missing — needed for the launcher'));
77
+ out.push(hasFn('bash') ? ok('bash') : warn('bash missing — needed for the launcher'));
63
78
 
64
79
  const sl = path.join(REPO, 'sidecar', 'ccr-statusline');
65
80
  out.push(isExec(sl) ? ok('sidecar/ccr-statusline is executable') : warn('sidecar/ccr-statusline not executable (the launcher self-heals this)'));
66
81
  }
67
82
 
68
- const ccs = has('ccs');
83
+ const ccs = hasFn('ccs');
69
84
  if (ccs) {
70
85
  let profiles = [];
71
- try { profiles = fs.readdirSync(path.join(os.homedir(), '.ccs', 'instances')).filter((p) => !p.startsWith('.')); } catch { /* none */ }
86
+ try { profiles = fs.readdirSync(path.join(homedir, '.ccs', 'instances')).filter((p) => !p.startsWith('.')); } catch { /* none */ }
72
87
  // Profile + path come from the filesystem; sanitize before display.
73
88
  out.push(ok(`ccs (${stripControl(ccs)}) · profiles: ${profiles.map(stripControl).join(', ') || '(none)'}`));
74
89
  } else {
@@ -77,7 +92,7 @@ function run() {
77
92
 
78
93
  // newest captured snapshot across ~/.ccr and its per-profile subdirs (state
79
94
  // lives under the user's home now, never world-shared /tmp).
80
- const ccrDir = path.join(os.homedir(), '.ccr');
95
+ const ccrDir = path.join(homedir, '.ccr');
81
96
  const dirs = [ccrDir];
82
97
  try {
83
98
  for (const d of fs.readdirSync(ccrDir)) {
@@ -98,17 +113,12 @@ function run() {
98
113
  out.push(ok(`status captured ${ageMin}m ago (${stripControl(newest.d)})`));
99
114
  out.push(dim(` buckets: ${keys.map(stripControl).join(', ') || '(none — API session?)'}`));
100
115
  } else {
101
- out.push(warn(isWin
102
- ? 'no status captured yet — wire `ccr statusline` into Claude Code (settings.json) to start capturing'
103
- : 'no status captured yet — launch with `ccr` (or `ccr <profile>`) to start capturing'));
116
+ out.push(warn('no status captured yet — launch with `ccr` (or `ccr <profile>`) to start capturing'));
104
117
  }
105
118
 
106
119
  out.push('');
107
- const allGood = isWin
108
- ? 'all good — `ccr economy` for the panel, `ccr statusline` to wire into CC'
109
- : 'all good — `ccr` to launch, `ccr economy` for the panel';
110
- out.push(problems ? warn(`${problems} thing(s) to address above`) : ok(allGood));
111
- process.stdout.write(out.join('\n') + '\n');
120
+ out.push(problems ? warn(`${problems} thing(s) to address above`) : ok('all good — `ccr` to launch, `ccr economy` for the panel'));
121
+ write(out.join('\n') + '\n');
112
122
  return problems ? 1 : 0;
113
123
  }
114
124
 
@@ -0,0 +1,288 @@
1
+ // @ts-check
2
+ 'use strict';
3
+ // src/launch-vscode.js — split-terminal launcher for VS Code's integrated
4
+ // terminal (any OS). Unlike the wt.exe path (a separate window) or tmux (a real
5
+ // multiplexer), a shell process CANNOT split the VS Code terminal itself — the
6
+ // `code` CLI exposes no "run command" verb. So we automate everything AROUND the
7
+ // split: do all the state/statusLine wiring, run Claude in the CURRENT pane, and
8
+ // hand the user a prominent, clipboard-copied one-liner for the new pane.
9
+ //
10
+ // run(profile) → banner + clipboard + Claude in this pane; sidecar one-liner
11
+ // for the split pane (Ctrl+Shift+5 / Cmd+\).
12
+ // hint(dir) → reprint that banner + re-copy the one-liner (no Claude).
13
+ //
14
+ // The zero-dependency / no-config-mutation contracts are preserved exactly as in
15
+ // launch-win: statusLine is injected via a per-launch temp settings file.
16
+
17
+ const path = require('node:path');
18
+ const os = require('node:os');
19
+ const launchWin = require('./launch-win');
20
+ const inject = require('./settings-inject');
21
+ const { ensureSecureDir } = require('./state-dir');
22
+
23
+ /**
24
+ * VS Code's "Split Terminal" default keybinding, per platform.
25
+ * @param {string} [platform]
26
+ * @returns {string}
27
+ */
28
+ function splitKeybinding(platform) {
29
+ return (platform || process.platform) === 'darwin' ? 'Cmd+\\' : 'Ctrl+Shift+5';
30
+ }
31
+
32
+ /**
33
+ * The command the user runs in the split pane. Carries the resolved state dir as
34
+ * an explicit arg (shell-agnostic: no per-shell `set`/`$env:`/`export` needed).
35
+ * Prefers the `ccr` binary when on PATH; falls back to node + ccr.js by path.
36
+ * @param {{ stateDir: string, ccrBin?: string|null, node: string, ccrJs: string, hint?: boolean }} o
37
+ * @returns {string}
38
+ */
39
+ function sidecarPasteCommand(o) {
40
+ const head = o.ccrBin ? 'ccr' : `"${o.node}" "${o.ccrJs}"`;
41
+ const tail = o.hint ? ' --hint' : ` --state-dir "${o.stateDir}"`;
42
+ return `${head} sidecar${tail}`;
43
+ }
44
+
45
+ /**
46
+ * OSC 52 clipboard-set escape — zero-dep, works over SSH/remote/dev-containers,
47
+ * and honored by VS Code's terminal. Setting the clipboard needs no native tool.
48
+ * @param {string} text
49
+ * @returns {string}
50
+ */
51
+ function osc52(text) {
52
+ return `\x1b]52;c;${Buffer.from(text, 'utf8').toString('base64')}\x07`;
53
+ }
54
+
55
+ /**
56
+ * The prominent, scrollback-findable banner. A bright reverse-video header plus a
57
+ * blinking ▶/◀ marker is the "animated cue" — terminals honoring blink animate
58
+ * it (live AND when scrolled to); elsewhere it degrades to a bold static banner.
59
+ * @param {{ sidecarCmd: string, splitKey: string, hintCmd: string, color: boolean }} o
60
+ * @returns {string}
61
+ */
62
+ function buildBanner(o) {
63
+ const c = o.color ? (/** @type {string} */ code, /** @type {string} */ s) => `\x1b[${code}m${s}\x1b[0m` : (/** @type {string} */ _code, /** @type {string} */ s) => s;
64
+ const blink = o.color ? (/** @type {string} */ s) => `\x1b[5m${s}\x1b[25m` : (/** @type {string} */ s) => s;
65
+ return [
66
+ '',
67
+ c('1;30;103', ` ${blink('▶')} ccr · live sidecar — split your VS Code terminal ${blink('◀')} `),
68
+ '',
69
+ ` ${c('1;96', '1.')} Split this terminal: ${c('1;97', o.splitKey)}`,
70
+ ` ${c('1;96', '2.')} In the new pane, run ${c('2', '(already on your clipboard — just paste)')}:`,
71
+ '',
72
+ ` ${c('1;92', o.sidecarCmd)}`,
73
+ '',
74
+ c('2', ` Claude is starting in THIS pane. Lost these steps? Run: ${o.hintCmd}`),
75
+ '',
76
+ ].join('\n') + '\n';
77
+ }
78
+
79
+ /**
80
+ * Best-effort copy: OSC 52 first (covers VS Code + remote), then a native tool
81
+ * for terminals with OSC 52 disabled. Never throws; silent if nothing is found.
82
+ * @param {string} text
83
+ * @param {{ platform: string, out: (s: string) => void, spawnCopy: (cmd: string, args: string[], input: string) => { status: number|null, error?: Error } }} d
84
+ */
85
+ function copyToClipboard(text, d) {
86
+ try { d.out(osc52(text)); } catch { /* terminal may not support OSC 52 */ }
87
+ const p = d.platform;
88
+ const attempts = p === 'win32' ? [['clip', []]]
89
+ : p === 'darwin' ? [['pbcopy', []]]
90
+ : [['wl-copy', []], ['xclip', ['-selection', 'clipboard']]];
91
+ for (const [cmd, args] of attempts) {
92
+ try {
93
+ const r = d.spawnCopy(String(cmd), /** @type {string[]} */ (args), text);
94
+ if (r && !r.error && (r.status === 0 || r.status == null)) return;
95
+ } catch { /* try the next tool */ }
96
+ }
97
+ }
98
+
99
+ /**
100
+ * `ccr [profile]` inside a VS Code integrated terminal: wire the split-view
101
+ * sidecar, then run Claude in the current pane. Returns Claude's exit code.
102
+ * @param {string} [profile]
103
+ * @param {Partial<Deps>} [deps]
104
+ * @returns {number}
105
+ */
106
+ function run(profile, deps = {}) {
107
+ const d = withDefaults(deps);
108
+
109
+ if (profile !== undefined && !launchWin.validateProfile(profile)) {
110
+ d.err(`ccr: invalid profile name '${profile}' (allowed: letters, digits, . _ -)\n`);
111
+ return 1;
112
+ }
113
+
114
+ const st = launchWin.resolveProfileState(profile, { env: d.env, home: d.home });
115
+ if (st.usesCcs) {
116
+ if (!d.which('ccs')) {
117
+ d.err("ccr: 'ccs' not found on PATH — pass a profile only if CCS is installed.\n");
118
+ return 1;
119
+ }
120
+ if (st.instanceDir && !d.existsDir(st.instanceDir)) {
121
+ d.err(`ccr: CCS profile '${profile}' not found (${st.instanceDir}).\n`);
122
+ d.err(` available: ${d.listDir(path.join(d.home, '.ccs', 'instances')).join(' ')}\n`);
123
+ return 1;
124
+ }
125
+ } else {
126
+ const bin = st.ccCmd.split(' ')[0];
127
+ if (!d.which(bin)) { d.err(`ccr: '${bin}' not found on PATH.\n`); return 1; }
128
+ }
129
+
130
+ try { d.ensureDir(st.stateDir); } catch { /* best effort */ }
131
+ d.removeExited(st.stateDir);
132
+
133
+ // statusLine via a per-launch temp settings file (no ~/.claude mutation).
134
+ const command = inject.buildStatusLineCommandInline({ node: d.node, ccrJs: d.ccrJs });
135
+ const settingsFile = d.writeSettings(inject.buildSettings(command));
136
+
137
+ // Show the split instructions + copy the sidecar one-liner BEFORE Claude takes
138
+ // over the pane (the clipboard + hint make it recoverable once it scrolls off).
139
+ const ccrBin = d.which('ccr');
140
+ const sidecarCmd = sidecarPasteCommand({ stateDir: st.stateDir, ccrBin, node: d.node, ccrJs: d.ccrJs });
141
+ const hintCmd = sidecarPasteCommand({ stateDir: st.stateDir, ccrBin, node: d.node, ccrJs: d.ccrJs, hint: true });
142
+ d.out(buildBanner({ sidecarCmd, splitKey: splitKeybinding(d.platform), hintCmd, color: d.color }));
143
+ copyToClipboard(sidecarCmd, d);
144
+
145
+ // Run Claude in the current pane (blocks until exit). The temp settings file is
146
+ // always removed; the "session ended" sentinel is only dropped if Claude
147
+ // actually ran — a failed spawn must NOT flip the sidecar to "ended".
148
+ const parts = st.ccCmd.split(' ');
149
+ const r = d.spawnClaude(parts[0], [...parts.slice(1), '--settings', settingsFile]);
150
+ d.cleanup(settingsFile);
151
+ if (r && r.error) { d.err(`ccr: failed to launch Claude: ${r.error.message}\n`); return 1; }
152
+ d.dropExited(st.stateDir);
153
+ return r && typeof r.status === 'number' ? r.status : 0;
154
+ }
155
+
156
+ /**
157
+ * Reprint the split instructions and re-copy the sidecar one-liner for an
158
+ * already-resolved state dir. Backs `ccr sidecar --hint`. Never launches Claude.
159
+ * @param {string} stateDir
160
+ * @param {Partial<Deps>} [deps]
161
+ * @returns {number}
162
+ */
163
+ function hint(stateDir, deps = {}) {
164
+ const d = withDefaults(deps);
165
+ const ccrBin = d.which('ccr');
166
+ const sidecarCmd = sidecarPasteCommand({ stateDir, ccrBin, node: d.node, ccrJs: d.ccrJs });
167
+ const hintCmd = sidecarPasteCommand({ stateDir, ccrBin, node: d.node, ccrJs: d.ccrJs, hint: true });
168
+ d.out(buildBanner({ sidecarCmd, splitKey: splitKeybinding(d.platform), hintCmd, color: d.color }));
169
+ copyToClipboard(sidecarCmd, d);
170
+ return 0;
171
+ }
172
+
173
+ /**
174
+ * Build the spawn invocation for launching Claude in the CURRENT pane.
175
+ *
176
+ * On POSIX this is a direct exec of `bin`. On Windows the resolved binary is
177
+ * typically `claude.cmd` / `ccs.cmd`, which Node's spawnSync refuses to run by
178
+ * bare name without a shell (it would ENOENT) — so we resolve the real path via
179
+ * `which` and run it through cmd.exe with our own quoting, which keeps a temp
180
+ * path containing spaces working. We reject the two characters cmd quoting
181
+ * cannot neutralize — `"` (ends the quote) and `%` (cmd expansion), plus CR/LF —
182
+ * with a clear error rather than spawn a broken or hijackable line. This mirrors
183
+ * the WT_UNSAFE policy in launch-win.js; trust boundary is the user's own env.
184
+ *
185
+ * Returns either a spawn descriptor or, on a rejected character, an `{ error }`
186
+ * that run() surfaces exactly like a spawn failure.
187
+ *
188
+ * @param {string} bin
189
+ * @param {string[]} args
190
+ * @param {{ platform: string, which: (name: string) => (string|null) }} o
191
+ * @returns {{ command: string, args: string[]|null, shell: boolean } | { error: Error }}
192
+ */
193
+ function buildClaudeSpawn(bin, args, o) {
194
+ if (o.platform !== 'win32') {
195
+ return { command: bin, args, shell: false };
196
+ }
197
+ const resolved = o.which(bin) || bin;
198
+ const all = [resolved, ...args.map(String)];
199
+ const bad = all.find((p) => /["%\r\n]/.test(p));
200
+ if (bad !== undefined) {
201
+ return { error: new Error(
202
+ `argument contains an unsupported character (", %, or newline) for the Windows shell: ${JSON.stringify(bad)}`,
203
+ ) };
204
+ }
205
+ // cmd.exe strips the outer quote pair Node adds around the /c payload, leaving
206
+ // each value individually quoted — spaces are safe, `"`/`%` are pre-rejected.
207
+ return { command: all.map((p) => `"${p}"`).join(' '), args: null, shell: true };
208
+ }
209
+
210
+ /**
211
+ * Real-environment Claude launcher: resolve+route through cmd.exe on Windows
212
+ * (see buildClaudeSpawn), direct exec elsewhere. Stays injectable so tests drive
213
+ * run() without spawning — but the Windows resolution itself is unit-tested via
214
+ * buildClaudeSpawn so this seam can't silently regress to a bare-name spawn.
215
+ *
216
+ * @param {string} bin
217
+ * @param {string[]} args
218
+ * @returns {{ status: number|null, error?: Error }}
219
+ */
220
+ function defaultSpawnClaude(bin, args) {
221
+ const built = buildClaudeSpawn(bin, args, { platform: process.platform, which: defaultWhich });
222
+ if ('error' in built) return { status: null, error: built.error };
223
+ const { spawnSync } = require('node:child_process');
224
+ return built.shell
225
+ ? spawnSync(built.command, { stdio: 'inherit', shell: true })
226
+ : spawnSync(built.command, built.args || [], { stdio: 'inherit' });
227
+ }
228
+
229
+ /** @param {string} name @returns {string|null} */
230
+ function defaultWhich(name) {
231
+ const { spawnSync } = require('node:child_process');
232
+ const finder = process.platform === 'win32' ? 'where' : 'which';
233
+ const r = spawnSync(finder, [name], { encoding: 'utf8' });
234
+ if (r.status !== 0 || !r.stdout) return null;
235
+ return r.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0] || null;
236
+ }
237
+
238
+ /**
239
+ * @param {Partial<Deps>} deps
240
+ * @returns {Deps}
241
+ */
242
+ function withDefaults(deps) {
243
+ const env = deps.env || process.env;
244
+ return {
245
+ env,
246
+ home: deps.home || os.homedir(),
247
+ node: deps.node || process.execPath,
248
+ ccrJs: deps.ccrJs || path.join(__dirname, '..', 'bin', 'ccr.js'),
249
+ platform: deps.platform || process.platform,
250
+ color: deps.color != null ? deps.color : (!!process.stdout.isTTY && !env.NO_COLOR),
251
+ out: deps.out || ((s) => { process.stdout.write(s); }),
252
+ err: deps.err || ((s) => { process.stderr.write(s); }),
253
+ which: deps.which || defaultWhich,
254
+ existsDir: deps.existsDir || ((dir) => { try { return require('node:fs').statSync(dir).isDirectory(); } catch { return false; } }),
255
+ listDir: deps.listDir || ((dir) => { try { return require('node:fs').readdirSync(dir); } catch { return []; } }),
256
+ ensureDir: deps.ensureDir || ensureSecureDir,
257
+ removeExited: deps.removeExited || ((dir) => { try { require('node:fs').rmSync(path.join(dir, 'exited'), { force: true }); } catch { /* best effort */ } }),
258
+ dropExited: deps.dropExited || ((dir) => { try { require('node:fs').writeFileSync(path.join(dir, 'exited'), ''); } catch { /* best effort */ } }),
259
+ writeSettings: deps.writeSettings || ((s) => inject.writeSettingsFile(s)),
260
+ cleanup: deps.cleanup || ((f) => inject.cleanupSettingsFile(f)),
261
+ spawnClaude: deps.spawnClaude || defaultSpawnClaude,
262
+ spawnCopy: deps.spawnCopy || ((cmd, args, input) => require('node:child_process').spawnSync(cmd, args, { input, stdio: ['pipe', 'ignore', 'ignore'] })),
263
+ };
264
+ }
265
+
266
+ /**
267
+ * @typedef {object} Deps
268
+ * @property {NodeJS.ProcessEnv} env
269
+ * @property {string} home
270
+ * @property {string} node
271
+ * @property {string} ccrJs
272
+ * @property {string} platform
273
+ * @property {boolean} color
274
+ * @property {(s: string) => void} out
275
+ * @property {(s: string) => void} err
276
+ * @property {(name: string) => (string|null)} which
277
+ * @property {(dir: string) => boolean} existsDir
278
+ * @property {(dir: string) => string[]} listDir
279
+ * @property {(dir: string) => void} ensureDir
280
+ * @property {(dir: string) => void} removeExited
281
+ * @property {(dir: string) => void} dropExited
282
+ * @property {(settings: object) => string} writeSettings
283
+ * @property {(file: string) => void} cleanup
284
+ * @property {(bin: string, args: string[]) => {status: number|null, error?: Error}} spawnClaude
285
+ * @property {(cmd: string, args: string[], input: string) => {status: number|null, error?: Error}} spawnCopy
286
+ */
287
+
288
+ module.exports = { splitKeybinding, sidecarPasteCommand, osc52, buildBanner, copyToClipboard, buildClaudeSpawn, run, hint };