@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.
Files changed (3) hide show
  1. package/dist/cli.js +48 -9
  2. package/dist/run.js +151 -9
  3. 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 --cmd <bin> arbitrary command
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
- const positional = rest.filter((a) => !a.startsWith('-'));
1135
- const flags = rest.filter((a) => a.startsWith('-'));
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
- const agentArgs = positional.slice(1);
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. `claude` / `codex` look up <NAME>_BIN
31
- * env, else fall through to the bare name on PATH. `--cmd <path>` is
32
- * the escape hatch for arbitrary processes. */
33
- function resolveAgent(agent) {
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
- return envOverride || agent;
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 \r unless submit was explicitly false. Default true
76
- // because the whole point of `sickr run` is auto-submit; opt out
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, opts.agentArgs, {
299
+ const pty = ptySpawn(agentBin, effectiveArgs, {
158
300
  name: 'xterm-256color',
159
301
  cols, rows,
160
302
  cwd: process.cwd(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sickr/cli",
3
- "version": "0.9.5",
3
+ "version": "0.9.6",
4
4
  "type": "module",
5
5
  "description": "npx @sickr/cli - replay, live look, and workflow orchestration for AI coding agents.",
6
6
  "bin": { "replay": "dist/cli.js", "sickr": "dist/cli.js" },