@sickr/cli 0.9.5 → 0.9.6
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/dist/cli.js +48 -9
- package/dist/run.js +151 -9
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -115,9 +115,19 @@ Legacy replay commands remain supported:
|
|
|
115
115
|
run <agent> Replay Pro (Phase 2): wrap an agent in a PTY this CLI owns.
|
|
116
116
|
Browser steer messages land directly in the agent's stdin —
|
|
117
117
|
real remote control, no copy-paste from inbox.
|
|
118
|
-
sickr run claude spawn Claude Code under sickr
|
|
119
|
-
sickr run codex same for Codex
|
|
120
|
-
sickr run
|
|
118
|
+
sickr run claude spawn Claude Code under sickr (auto-perms)
|
|
119
|
+
sickr run codex same for Codex (auto-perms)
|
|
120
|
+
sickr run <bin> arbitrary command
|
|
121
|
+
Modes:
|
|
122
|
+
--mode auto (default) inject the agent's "no prompt /
|
|
123
|
+
full permissions" flag so it
|
|
124
|
+
doesn't stall on tool confirms
|
|
125
|
+
the operator can't see (claude:
|
|
126
|
+
--dangerously-skip-permissions;
|
|
127
|
+
codex: --dangerously-bypass-
|
|
128
|
+
approvals-and-sandbox)
|
|
129
|
+
--mode interactive pass agent flags through verbatim;
|
|
130
|
+
agent will prompt for tool use
|
|
121
131
|
\`node-pty\` ships as an optional dep + auto-installs with the
|
|
122
132
|
CLI on supported platforms (mac, Linux, Windows 10+).
|
|
123
133
|
Browsers can override per-message: {mode:'queue'|'steer',
|
|
@@ -1128,22 +1138,51 @@ async function main() {
|
|
|
1128
1138
|
return;
|
|
1129
1139
|
}
|
|
1130
1140
|
case 'run': {
|
|
1131
|
-
// sickr run <agent> [...agent-args] [--verbose]
|
|
1141
|
+
// sickr run <agent> [--mode auto|interactive] [...agent-args] [--verbose]
|
|
1132
1142
|
// Wraps the agent in a PTY the CLI owns. Browser steers go
|
|
1133
1143
|
// straight to stdin. node-pty is loaded on first use.
|
|
1134
|
-
|
|
1135
|
-
const
|
|
1144
|
+
// --mode is stripped before forwarding; the rest passes through verbatim.
|
|
1145
|
+
const filtered = [];
|
|
1146
|
+
let mode = 'auto';
|
|
1147
|
+
for (let i = 0; i < rest.length; i++) {
|
|
1148
|
+
if (rest[i] === '--mode' && rest[i + 1]) {
|
|
1149
|
+
const v = rest[i + 1];
|
|
1150
|
+
if (v !== 'auto' && v !== 'interactive') {
|
|
1151
|
+
process.stderr.write(`sickr: --mode must be 'auto' or 'interactive' (got '${v}')\n`);
|
|
1152
|
+
process.exit(1);
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
mode = v;
|
|
1156
|
+
i++;
|
|
1157
|
+
continue;
|
|
1158
|
+
}
|
|
1159
|
+
if (rest[i].startsWith('--mode=')) {
|
|
1160
|
+
const v = rest[i].slice('--mode='.length);
|
|
1161
|
+
if (v !== 'auto' && v !== 'interactive') {
|
|
1162
|
+
process.stderr.write(`sickr: --mode must be 'auto' or 'interactive' (got '${v}')\n`);
|
|
1163
|
+
process.exit(1);
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
mode = v;
|
|
1167
|
+
continue;
|
|
1168
|
+
}
|
|
1169
|
+
filtered.push(rest[i]);
|
|
1170
|
+
}
|
|
1171
|
+
const positional = filtered.filter((a) => !a.startsWith('-'));
|
|
1172
|
+
const flags = filtered.filter((a) => a.startsWith('-'));
|
|
1136
1173
|
const agent = positional[0];
|
|
1137
1174
|
if (!agent) {
|
|
1138
|
-
process.stderr.write('sickr: usage — `sickr run <agent> [args...]` (agent: claude | codex | <bin name>)\n');
|
|
1175
|
+
process.stderr.write('sickr: usage — `sickr run <agent> [--mode auto|interactive] [args...]` (agent: claude | codex | <bin name>)\n');
|
|
1139
1176
|
process.exit(1);
|
|
1140
1177
|
return;
|
|
1141
1178
|
}
|
|
1142
|
-
|
|
1179
|
+
// Pass through every non-sickr flag plus positional tail to the agent.
|
|
1180
|
+
const passthroughFlags = flags.filter((f) => f !== '--verbose' && f !== '-v');
|
|
1181
|
+
const agentArgs = [...passthroughFlags, ...positional.slice(1)];
|
|
1143
1182
|
const verbose = flags.includes('--verbose') || flags.includes('-v');
|
|
1144
1183
|
const { startRun } = await import('./run.js');
|
|
1145
1184
|
try {
|
|
1146
|
-
await startRun({ agent, agentArgs, verbose });
|
|
1185
|
+
await startRun({ agent, agentArgs, verbose, mode });
|
|
1147
1186
|
}
|
|
1148
1187
|
catch (e) {
|
|
1149
1188
|
process.stderr.write(`sickr: ${e.message}\n`);
|
package/dist/run.js
CHANGED
|
@@ -23,16 +23,62 @@
|
|
|
23
23
|
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, readdirSync, statSync, openSync, readSync, closeSync, unlinkSync } from 'node:fs';
|
|
24
24
|
import { homedir } from 'node:os';
|
|
25
25
|
import { join } from 'node:path';
|
|
26
|
+
import { execFileSync } from 'node:child_process';
|
|
26
27
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
27
28
|
import { readCredentials } from './auth.js';
|
|
28
29
|
import { runsDir } from './recorder.js';
|
|
30
|
+
import { mergeHooks, removeHooks } from './hookConfig.js';
|
|
29
31
|
import { LIVE_BASE, decodeWsPayload, splitJsonObjects } from './live.js';
|
|
30
|
-
/** Resolve the agent binary path.
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
|
|
32
|
+
/** Resolve the agent binary path. Precedence:
|
|
33
|
+
* 1. SICKR_AGENT_BIN_<NAME> env (e.g. SICKR_AGENT_BIN_CLAUDE)
|
|
34
|
+
* 2. <NAME>_BIN env (e.g. CLAUDE_BIN)
|
|
35
|
+
* 3. OS shell lookup (`where` on Windows, `which` on Unix) so PATHEXT
|
|
36
|
+
* resolution finds `claude.cmd`, `claude.exe`, etc.
|
|
37
|
+
* 4. Bare name (node-pty handles it; rarely works on Windows).
|
|
38
|
+
*
|
|
39
|
+
* Windows-specific: node-pty's CreateProcess does NOT respect PATHEXT,
|
|
40
|
+
* so a bare `claude` will fail with 'Cannot create process, error code: 2'
|
|
41
|
+
* even when `claude.cmd` is on PATH. The shell lookup at step 3 closes
|
|
42
|
+
* the gap. Surfaced 2026-06-01 on the first Windows E2E test. */
|
|
43
|
+
export function resolveAgent(agent) {
|
|
44
|
+
if (/[\/\\]/.test(agent))
|
|
45
|
+
return agent; // absolute / relative path used as-is
|
|
34
46
|
const envOverride = process.env[`SICKR_AGENT_BIN_${agent.toUpperCase()}`] ?? process.env[`${agent.toUpperCase()}_BIN`];
|
|
35
|
-
|
|
47
|
+
if (envOverride)
|
|
48
|
+
return envOverride;
|
|
49
|
+
// Shell lookup. Cheap (~20ms) and only fires once per `sickr run`.
|
|
50
|
+
// Wrapped in try/catch so a missing `where`/`which` doesn't crash —
|
|
51
|
+
// we fall through to the bare name, which gives the original error
|
|
52
|
+
// and lets the user set SICKR_AGENT_BIN_<NAME> as escape hatch.
|
|
53
|
+
try {
|
|
54
|
+
const isWin = process.platform === 'win32';
|
|
55
|
+
const cmd = isWin ? 'where' : 'which';
|
|
56
|
+
const out = execFileSync(cmd, [agent], { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
57
|
+
const candidates = out.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
58
|
+
if (candidates.length === 0)
|
|
59
|
+
return agent;
|
|
60
|
+
// On Windows, `where` returns multiple candidates when both the npm
|
|
61
|
+
// shell-script wrapper (no extension) and the .cmd/.bat shim exist.
|
|
62
|
+
// CreateProcess can't run the unextensioned wrapper, so prefer
|
|
63
|
+
// executable extensions in this order: .exe, .cmd, .bat, .com,
|
|
64
|
+
// then anything else. Bug surfaced 2026-06-01 — `where claude` led
|
|
65
|
+
// with `C:\...\nvm\v22\claude` (no ext) before `claude.cmd`.
|
|
66
|
+
if (isWin) {
|
|
67
|
+
const EXEC_RANK = ['.exe', '.cmd', '.bat', '.com'];
|
|
68
|
+
const sorted = candidates.slice().sort((a, b) => {
|
|
69
|
+
const ar = EXEC_RANK.findIndex((ext) => a.toLowerCase().endsWith(ext));
|
|
70
|
+
const br = EXEC_RANK.findIndex((ext) => b.toLowerCase().endsWith(ext));
|
|
71
|
+
// Items with a recognised exec ext sort first; ties keep original order.
|
|
72
|
+
const ai = ar === -1 ? EXEC_RANK.length : ar;
|
|
73
|
+
const bi = br === -1 ? EXEC_RANK.length : br;
|
|
74
|
+
return ai - bi;
|
|
75
|
+
});
|
|
76
|
+
return sorted[0];
|
|
77
|
+
}
|
|
78
|
+
return candidates[0];
|
|
79
|
+
}
|
|
80
|
+
catch { /* not found; fall through */ }
|
|
81
|
+
return agent;
|
|
36
82
|
}
|
|
37
83
|
/** UTC date YYYY-MM-DD — matches sickr-ui's /api/replay slot id formula. */
|
|
38
84
|
function utcDate(now = new Date()) { return now.toISOString().slice(0, 10); }
|
|
@@ -72,9 +118,17 @@ export function decideSteer(msg, defaultMode = 'pty') {
|
|
|
72
118
|
const mode = msg.mode === 'queue' ? 'inbox' : msg.mode === 'steer' ? 'pty' : defaultMode;
|
|
73
119
|
if (mode === 'inbox')
|
|
74
120
|
return { target: 'inbox' };
|
|
75
|
-
// For PTY: append
|
|
76
|
-
// because the whole point of `sickr run` is
|
|
77
|
-
// requires saying so.
|
|
121
|
+
// For PTY: append a line terminator unless submit was explicitly
|
|
122
|
+
// false. Default true because the whole point of `sickr run` is
|
|
123
|
+
// auto-submit; opt out requires saying so.
|
|
124
|
+
//
|
|
125
|
+
// We send \r (CR only). That's what the Enter key produces in most
|
|
126
|
+
// TUIs running under a PTY in raw mode (the terminal driver doesn't
|
|
127
|
+
// translate CR -> LF for raw input). Surfaced 2026-06-01: bare \r
|
|
128
|
+
// worked for echo / cat tests but Claude Code's React Ink input
|
|
129
|
+
// handler appeared to ignore single \r on Windows ConPTY. Sending
|
|
130
|
+
// \r\n is widely compatible: bare-\r TUIs treat the trailing \n as
|
|
131
|
+
// an empty no-op line; line-buffered programs see a single line.
|
|
78
132
|
const submit = msg.submit !== false;
|
|
79
133
|
return { target: 'pty', bytes: submit ? text + '\r' : text };
|
|
80
134
|
}
|
|
@@ -113,6 +167,54 @@ function tailFrom(path, from) {
|
|
|
113
167
|
closeSync(fd);
|
|
114
168
|
}
|
|
115
169
|
}
|
|
170
|
+
/** Silently ensure recording hooks are installed for the wrapped agent
|
|
171
|
+
* in the CURRENT working directory. Without this, Claude / Codex won't
|
|
172
|
+
* emit events to ~/.sickr/runs/, so the WS push has nothing to forward
|
|
173
|
+
* to the DO and the browser /r/<urlid> view stays empty.
|
|
174
|
+
*
|
|
175
|
+
* Surfaced 2026-06-01: first E2E spawned Claude under PTY but refresh
|
|
176
|
+
* on the browser showed only an unrelated event from a prior session.
|
|
177
|
+
* Operator hit the "have to remember `sickr init claude` first" trap.
|
|
178
|
+
*
|
|
179
|
+
* This function is idempotent — if the file already has the right
|
|
180
|
+
* hooks the merge is a no-op. We don't print anything on success
|
|
181
|
+
* (the spawn is about to take over the terminal). Returns true if
|
|
182
|
+
* hooks were ensured / installed; false if the agent name isn't
|
|
183
|
+
* one we know how to init for. */
|
|
184
|
+
export function ensureRecordingHooks(agent) {
|
|
185
|
+
const provider = agent === 'claude' || agent === 'codex' ? agent : null;
|
|
186
|
+
if (!provider)
|
|
187
|
+
return false; // Custom binaries — caller decides whether to install hooks
|
|
188
|
+
const settingsPath = provider === 'codex'
|
|
189
|
+
? join(process.cwd(), '.codex', 'hooks.json')
|
|
190
|
+
: join(process.cwd(), '.claude', 'settings.json');
|
|
191
|
+
let existing = {};
|
|
192
|
+
if (existsSync(settingsPath)) {
|
|
193
|
+
try {
|
|
194
|
+
existing = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
existing = {};
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// Cheap detection — if the file already mentions our record command,
|
|
201
|
+
// skip the write. Avoids touching a file every run when the user
|
|
202
|
+
// already init'd here.
|
|
203
|
+
const serialized = JSON.stringify(existing);
|
|
204
|
+
if (serialized.includes('@sickr/cli record') || serialized.includes('@sickr/replay record')) {
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
// Merge using the same logic as cli.ts::handleInit but silent.
|
|
208
|
+
// Dynamic import keeps the file from pulling hookConfig at module
|
|
209
|
+
// load (tiny boot perf + lets tests run without the module).
|
|
210
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
211
|
+
const command = `npx @sickr/cli record${provider === 'codex' ? ' --codex' : ''}`;
|
|
212
|
+
const merged = mergeHooks(removeHooks(existing), command);
|
|
213
|
+
mkdirSync(join(settingsPath, '..'), { recursive: true });
|
|
214
|
+
writeFileSync(settingsPath, JSON.stringify(merged, null, 2) + '\n');
|
|
215
|
+
mkdirSync(runsDir(), { recursive: true });
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
116
218
|
/** Best-effort load of node-pty. Returns the spawn function or throws
|
|
117
219
|
* with a copy-pasteable install hint. */
|
|
118
220
|
async function loadNodePtySpawn() {
|
|
@@ -134,6 +236,30 @@ async function loadNodePtySpawn() {
|
|
|
134
236
|
`Underlying error: ${e.message ?? 'unknown'}`);
|
|
135
237
|
}
|
|
136
238
|
}
|
|
239
|
+
/** Per-agent flag that disables prompting / sandboxing so the wrapped
|
|
240
|
+
* agent doesn't stall waiting for unattended confirmation. Pure function
|
|
241
|
+
* — exported for unit tests. Honors operator overrides: if the operator
|
|
242
|
+
* already passed the relevant flag (or any opposite), we don't double-add.
|
|
243
|
+
*
|
|
244
|
+
* History (2026-06-01): `sickr run claude` defaulted to interactive and
|
|
245
|
+
* surfaced as a "hung" terminal — Claude was waiting on a tool-use
|
|
246
|
+
* permission prompt the operator couldn't see in the PTY mirror. Moving
|
|
247
|
+
* the default to "auto / full permissions" closes the gap; `--mode
|
|
248
|
+
* interactive` opts back in for sensitive sessions. */
|
|
249
|
+
export function autoModeArgsFor(agent, existingArgs) {
|
|
250
|
+
const a = agent.toLowerCase();
|
|
251
|
+
const has = (needle) => existingArgs.some((x) => x === needle || x.startsWith(needle + '='));
|
|
252
|
+
if (a === 'claude' || a.endsWith('/claude') || a.endsWith('\\claude') || a.endsWith('claude.cmd') || a.endsWith('claude.exe')) {
|
|
253
|
+
return has('--dangerously-skip-permissions') ? [] : ['--dangerously-skip-permissions'];
|
|
254
|
+
}
|
|
255
|
+
if (a === 'codex' || a.endsWith('/codex') || a.endsWith('\\codex') || a.endsWith('codex.cmd') || a.endsWith('codex.exe')) {
|
|
256
|
+
if (has('--dangerously-bypass-approvals-and-sandbox') || has('--full-auto') || has('--ask-for-approval'))
|
|
257
|
+
return [];
|
|
258
|
+
return ['--dangerously-bypass-approvals-and-sandbox'];
|
|
259
|
+
}
|
|
260
|
+
// Unknown agent — no opinion. Operator should pass their own flags.
|
|
261
|
+
return [];
|
|
262
|
+
}
|
|
137
263
|
export async function startRun(opts) {
|
|
138
264
|
const creds = readCredentials();
|
|
139
265
|
if (!creds) {
|
|
@@ -149,12 +275,28 @@ export async function startRun(opts) {
|
|
|
149
275
|
process.stderr.write(`sickr: couldn't resolve live url (${e.message}). Replay Pro required.\n`);
|
|
150
276
|
process.exit(4);
|
|
151
277
|
}
|
|
278
|
+
// Ensure recording hooks are installed in the cwd for known agents.
|
|
279
|
+
// Without this, Claude / Codex run normally but emit no events — the
|
|
280
|
+
// browser /r/<urlid> stays empty until events flow.
|
|
281
|
+
ensureRecordingHooks(opts.agent);
|
|
152
282
|
const agentBin = resolveAgent(opts.agent);
|
|
153
283
|
const cols = process.stdout.columns ?? 80;
|
|
154
284
|
const rows = process.stdout.rows ?? 24;
|
|
285
|
+
// mode=auto (default) injects the agent's "no prompt / full perms" flag
|
|
286
|
+
// so the wrapped CLI doesn't stall on tool-use confirmations the
|
|
287
|
+
// operator can't see. --mode interactive disables the injection.
|
|
288
|
+
const mode = opts.mode ?? 'auto';
|
|
289
|
+
const injected = mode === 'auto' ? autoModeArgsFor(opts.agent, opts.agentArgs) : [];
|
|
290
|
+
const effectiveArgs = [...injected, ...opts.agentArgs];
|
|
155
291
|
process.stdout.write(`sickr: wrapping ${agentBin} in PTY. Watch + steer at: https://sickr.ai/r/${urlid}\n`);
|
|
292
|
+
if (mode === 'auto' && injected.length > 0) {
|
|
293
|
+
process.stdout.write(` mode=auto — injected ${injected.join(' ')} (use --mode interactive to disable)\n`);
|
|
294
|
+
}
|
|
295
|
+
else if (mode === 'interactive') {
|
|
296
|
+
process.stdout.write(` mode=interactive — agent will prompt for tool use; you confirm in the PTY\n`);
|
|
297
|
+
}
|
|
156
298
|
process.stdout.write(` browser steer messages land directly in your agent. ^C exits.\n\n`);
|
|
157
|
-
const pty = ptySpawn(agentBin,
|
|
299
|
+
const pty = ptySpawn(agentBin, effectiveArgs, {
|
|
158
300
|
name: 'xterm-256color',
|
|
159
301
|
cols, rows,
|
|
160
302
|
cwd: process.cwd(),
|
package/package.json
CHANGED