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 +35 -5
- package/bin/ccr.js +35 -13
- package/package.json +11 -4
- package/src/doctor.js +33 -23
- package/src/launch-vscode.js +288 -0
- package/src/launch-win.js +457 -0
- package/src/render/economy.js +5 -1
- package/src/render/shared.js +29 -1
- package/src/settings-inject.js +77 -0
- package/src/sidecar.js +133 -20
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
|
|
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
|
|
57
|
-
sidebar
|
|
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
|
-
-
|
|
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
|
-
/**
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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": {
|
|
8
|
-
|
|
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
|
-
/**
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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 =
|
|
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
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
63
|
+
// Native Windows hosts the sidecar in Windows Terminal — no 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 =
|
|
60
|
-
out.push(tmux ? ok(`tmux (${stripControl(tmux)})`) : warn('tmux missing — needed for `ccr [profile]` sidebar
|
|
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(
|
|
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 =
|
|
83
|
+
const ccs = hasFn('ccs');
|
|
69
84
|
if (ccs) {
|
|
70
85
|
let profiles = [];
|
|
71
|
-
try { profiles = fs.readdirSync(path.join(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
108
|
-
|
|
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 };
|