@sickr/cli 0.9.3 → 0.9.4

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 CHANGED
@@ -12,7 +12,7 @@ import { AUTH_ENDPOINT, readCredentials, writeCredentials, clearCredentials, sta
12
12
  import { ui, card, kv } from './ui.js';
13
13
  import { AGENT_API_URL, clearAgentCredentials, disconnectAgent, fetchAgentStatus, pollAgentConnect, readAgentCredentials, rotateAgentKey, startAgentConnect, writeAgentCredentials, } from './agentAuth.js';
14
14
  const REPLAY_ENDPOINT = process.env.SICKR_REPLAY_ENDPOINT ?? 'https://sickr.ai/api/replay';
15
- const COMMANDS = ['init', 'record', 'open', 'list', 'share', 'stop', 'clear', 'login', 'logout', 'whoami', 'agent', 'live', 'replay', 'workflow', 'help'];
15
+ const COMMANDS = ['init', 'record', 'open', 'list', 'share', 'stop', 'clear', 'login', 'logout', 'whoami', 'agent', 'live', 'run', 'replay', 'workflow', 'help'];
16
16
  export function parseCommand(argv) {
17
17
  const c = argv[0];
18
18
  return c && COMMANDS.includes(c) ? c : null;
@@ -112,6 +112,16 @@ Legacy replay commands remain supported:
112
112
  replay live stop stop the sidecar
113
113
  While running, steer messages from the watching browser are
114
114
  saved to ~/.sickr/inbox/<urlid>.md and printed in your terminal.
115
+ run <agent> Replay Pro (Phase 2): wrap an agent in a PTY this CLI owns.
116
+ Browser steer messages land directly in the agent's stdin —
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
121
+ \`node-pty\` ships as an optional dep + auto-installs with the
122
+ CLI on supported platforms (mac, Linux, Windows 10+).
123
+ Browsers can override per-message: {mode:'queue'|'steer',
124
+ submit:true|false}; default is auto-steer with submit.
115
125
  agent connect --agent-id <id>
116
126
  Connect this machine to a configured SICKR agent using GitHub
117
127
  browser approval. Stores the agent key in ~/.sickr/agent.json.
@@ -978,12 +988,32 @@ async function handleWorkflow(rest) {
978
988
  }
979
989
  runWorkflow(rest.length ? rest : ['status']);
980
990
  }
981
- async function readStdin() {
991
+ export async function readStreamWithIdle(input, idleMs = 250, emptyMs = 1500) {
982
992
  const chunks = [];
983
- for await (const chunk of process.stdin)
984
- chunks.push(chunk);
993
+ const iterator = input[Symbol.asyncIterator]();
994
+ let sawData = false;
995
+ while (true) {
996
+ const timeoutMs = sawData ? idleMs : emptyMs;
997
+ const timeout = new Promise((resolve) => {
998
+ setTimeout(() => resolve({ done: true, value: undefined }), timeoutMs);
999
+ });
1000
+ const next = await Promise.race([iterator.next(), timeout]);
1001
+ if (next.done)
1002
+ break;
1003
+ sawData = true;
1004
+ chunks.push(Buffer.isBuffer(next.value) ? next.value : Buffer.from(next.value));
1005
+ }
1006
+ if (typeof iterator.return === 'function') {
1007
+ try {
1008
+ void iterator.return();
1009
+ }
1010
+ catch { /* ignore stream cleanup failures */ }
1011
+ }
985
1012
  return Buffer.concat(chunks).toString('utf8');
986
1013
  }
1014
+ async function readStdin() {
1015
+ return readStreamWithIdle(process.stdin);
1016
+ }
987
1017
  async function main() {
988
1018
  const argv = process.argv.slice(2);
989
1019
  if (argv.length === 0 || argv[0] === 'help' || argv.includes('--help') || argv.includes('-h')) {
@@ -1088,6 +1118,30 @@ async function main() {
1088
1118
  await startLive(opts);
1089
1119
  return;
1090
1120
  }
1121
+ case 'run': {
1122
+ // sickr run <agent> [...agent-args] [--verbose]
1123
+ // Wraps the agent in a PTY the CLI owns. Browser steers go
1124
+ // straight to stdin. node-pty is loaded on first use.
1125
+ const positional = rest.filter((a) => !a.startsWith('-'));
1126
+ const flags = rest.filter((a) => a.startsWith('-'));
1127
+ const agent = positional[0];
1128
+ if (!agent) {
1129
+ process.stderr.write('sickr: usage — `sickr run <agent> [args...]` (agent: claude | codex | <bin name>)\n');
1130
+ process.exit(1);
1131
+ return;
1132
+ }
1133
+ const agentArgs = positional.slice(1);
1134
+ const verbose = flags.includes('--verbose') || flags.includes('-v');
1135
+ const { startRun } = await import('./run.js');
1136
+ try {
1137
+ await startRun({ agent, agentArgs, verbose });
1138
+ }
1139
+ catch (e) {
1140
+ process.stderr.write(`sickr: ${e.message}\n`);
1141
+ process.exit(5);
1142
+ }
1143
+ return;
1144
+ }
1091
1145
  case 'share': {
1092
1146
  const yes = rest.includes('--yes') || rest.includes('-y');
1093
1147
  const openAfter = rest.includes('--open');
package/dist/live.js CHANGED
@@ -356,7 +356,7 @@ async function loadWsShim() {
356
356
  return (mod.default ?? mod.WebSocket);
357
357
  }
358
358
  catch {
359
- throw new Error('Missing optional dep `ws`. Install with: `npm i -g ws` (or `npm i ws` in your project).');
359
+ throw new Error('`ws` is unavailable. It ships as an optional dependency of @sickr/cli; if it failed to install, try: `npm install -g ws`.');
360
360
  }
361
361
  }
362
362
  export function stopLive() {
package/dist/run.js ADDED
@@ -0,0 +1,332 @@
1
+ // `sickr run <agent>` — Phase 2 PTY proxy.
2
+ //
3
+ // Wraps a coding agent (Claude, Codex, anything) in a PTY the CLI owns.
4
+ // Browser steer messages received over the existing WS protocol are
5
+ // written directly into the wrapped agent's stdin as if the operator
6
+ // typed them. Real remote control, not pasteboard.
7
+ //
8
+ // Phase 1 `sickr live` (passive sidecar) is unchanged and still ships.
9
+ // Phase 2 is additive — operators pick: passive watching = `sickr live`,
10
+ // remote control = `sickr run`.
11
+ //
12
+ // Wire protocol (extension to Phase 1):
13
+ // {kind:'steer', text, mode?:'queue'|'steer', submit?:boolean}
14
+ // - mode='steer' (default when running under PTY) -> pty.write(text + (submit ? \r : ''))
15
+ // - mode='queue' (default Phase 1) -> append to ~/.sickr/inbox/<urlid>.md
16
+ // - submit=true (default for steer) -> append \r so agent acts immediately
17
+ // - submit=false -> no newline; operator can edit/confirm
18
+ //
19
+ // SICKR_AGENT_BIN_<NAME> env vars override resolution (e.g. CLAUDE_BIN).
20
+ //
21
+ // Native dep: node-pty. Dynamically imported on first use. Missing →
22
+ // friendly install hint; doesn't break `sickr` core install.
23
+ import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, readdirSync, statSync, openSync, readSync, closeSync, unlinkSync } from 'node:fs';
24
+ import { homedir } from 'node:os';
25
+ import { join } from 'node:path';
26
+ import { setTimeout as sleep } from 'node:timers/promises';
27
+ import { readCredentials } from './auth.js';
28
+ import { runsDir } from './recorder.js';
29
+ 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) {
34
+ const envOverride = process.env[`SICKR_AGENT_BIN_${agent.toUpperCase()}`] ?? process.env[`${agent.toUpperCase()}_BIN`];
35
+ return envOverride || agent;
36
+ }
37
+ /** UTC date YYYY-MM-DD — matches sickr-ui's /api/replay slot id formula. */
38
+ function utcDate(now = new Date()) { return now.toISOString().slice(0, 10); }
39
+ async function computeUrlid(creds, dayHint) {
40
+ const day = dayHint ?? utcDate();
41
+ const secret = process.env.SICKR_URLID_SECRET;
42
+ if (secret) {
43
+ const { createHash } = await import('node:crypto');
44
+ return createHash('sha256').update(`urlid|${secret}|${creds.github_user_id}|${day}`).digest('hex').slice(0, 10);
45
+ }
46
+ const r = await fetch(`${LIVE_BASE}/resolve?date=${encodeURIComponent(day)}`, {
47
+ headers: { Authorization: `Bearer ${creds.token}` },
48
+ });
49
+ if (!r.ok)
50
+ throw new Error(`resolve_failed: ${r.status}`);
51
+ const j = await r.json();
52
+ if (!j.urlid)
53
+ throw new Error('resolve_no_urlid');
54
+ return j.urlid;
55
+ }
56
+ function inboxDir() { return join(homedir(), '.sickr', 'inbox'); }
57
+ /** Append a steer message to the per-urlid inbox markdown file. Same
58
+ * shape Phase 1 wrote — operators who relied on the inbox keep working. */
59
+ function appendInbox(urlid, text, at) {
60
+ const dir = inboxDir();
61
+ mkdirSync(dir, { recursive: true });
62
+ const file = join(dir, `${urlid}.md`);
63
+ if (!existsSync(file))
64
+ writeFileSync(file, `# steer inbox — ${urlid}\n\n`);
65
+ appendFileSync(file, `\n## ${at}\n\n${text}\n`);
66
+ }
67
+ export function decideSteer(msg, defaultMode = 'pty') {
68
+ const text = String(msg.text ?? '');
69
+ if (!text)
70
+ return { target: 'inbox' }; // empty steer is a no-op; route to inbox so nothing surprising lands in the agent
71
+ // Explicit mode wins. 'queue' = inbox. 'steer' = pty. Unknown = default.
72
+ const mode = msg.mode === 'queue' ? 'inbox' : msg.mode === 'steer' ? 'pty' : defaultMode;
73
+ if (mode === 'inbox')
74
+ 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.
78
+ const submit = msg.submit !== false;
79
+ return { target: 'pty', bytes: submit ? text + '\r' : text };
80
+ }
81
+ function offsetsPath() { return join(homedir(), '.sickr', 'run-offsets.json'); }
82
+ function readOffsets() {
83
+ try {
84
+ return JSON.parse(readFileSync(offsetsPath(), 'utf8'));
85
+ }
86
+ catch {
87
+ return {};
88
+ }
89
+ }
90
+ function writeOffsets(o) {
91
+ mkdirSync(join(homedir(), '.sickr'), { recursive: true });
92
+ writeFileSync(offsetsPath(), JSON.stringify(o, null, 2));
93
+ }
94
+ /** Read newly-appended lines since `from` byte position. Same defensive
95
+ * logic as live.ts::tailFrom but stays local to avoid the cross-file
96
+ * export dance. */
97
+ function tailFrom(path, from) {
98
+ const size = statSync(path).size;
99
+ if (size <= from)
100
+ return { lines: [], newOffset: from };
101
+ const fd = openSync(path, 'r');
102
+ try {
103
+ const len = size - from;
104
+ const buf = Buffer.alloc(len);
105
+ readSync(fd, buf, 0, len, from);
106
+ const chunk = buf.toString('utf8');
107
+ const lastNl = chunk.lastIndexOf('\n');
108
+ if (lastNl < 0)
109
+ return { lines: [], newOffset: from };
110
+ return { lines: chunk.slice(0, lastNl).split('\n').filter(Boolean), newOffset: from + lastNl + 1 };
111
+ }
112
+ finally {
113
+ closeSync(fd);
114
+ }
115
+ }
116
+ /** Best-effort load of node-pty. Returns the spawn function or throws
117
+ * with a copy-pasteable install hint. */
118
+ async function loadNodePtySpawn() {
119
+ try {
120
+ const mod = await import('node-pty');
121
+ const spawn = mod.spawn ?? mod.default?.spawn;
122
+ if (!spawn)
123
+ throw new Error('node-pty has no spawn export');
124
+ return spawn;
125
+ }
126
+ catch (e) {
127
+ throw new Error('`node-pty` is unavailable. It ships as an optional dependency of\n' +
128
+ '@sickr/cli and is normally installed automatically — but native\n' +
129
+ 'compilation may have failed at install time. Try installing it\n' +
130
+ 'directly:\n' +
131
+ ' npm install -g node-pty\n' +
132
+ '(prebuilt binaries available for macOS, Linux, and Windows 10+;\n' +
133
+ 'uncommon platforms may need a C++ build toolchain.)\n' +
134
+ `Underlying error: ${e.message ?? 'unknown'}`);
135
+ }
136
+ }
137
+ export async function startRun(opts) {
138
+ const creds = readCredentials();
139
+ if (!creds) {
140
+ process.stderr.write('sickr: not signed in. Run `npx sickr login` first.\n');
141
+ process.exit(2);
142
+ }
143
+ const ptySpawn = await loadNodePtySpawn();
144
+ let urlid;
145
+ try {
146
+ urlid = await computeUrlid(creds);
147
+ }
148
+ catch (e) {
149
+ process.stderr.write(`sickr: couldn't resolve live url (${e.message}). Replay Pro required.\n`);
150
+ process.exit(4);
151
+ }
152
+ const agentBin = resolveAgent(opts.agent);
153
+ const cols = process.stdout.columns ?? 80;
154
+ const rows = process.stdout.rows ?? 24;
155
+ process.stdout.write(`sickr: wrapping ${agentBin} in PTY. Watch + steer at: https://sickr.ai/r/${urlid}\n`);
156
+ process.stdout.write(` browser steer messages land directly in your agent. ^C exits.\n\n`);
157
+ const pty = ptySpawn(agentBin, opts.agentArgs, {
158
+ name: 'xterm-256color',
159
+ cols, rows,
160
+ cwd: process.cwd(),
161
+ env: process.env,
162
+ });
163
+ // Wire stdio: parent stdin -> pty; pty -> parent stdout.
164
+ if (process.stdin.isTTY)
165
+ process.stdin.setRawMode(true);
166
+ process.stdin.resume();
167
+ process.stdin.on('data', (chunk) => pty.write(chunk.toString('utf8')));
168
+ pty.on('data', (data) => process.stdout.write(data));
169
+ // Forward terminal resize. SIGWINCH fires when the terminal window changes.
170
+ const resize = () => pty.resize(process.stdout.columns ?? cols, process.stdout.rows ?? rows);
171
+ process.stdout.on('resize', resize);
172
+ // Best-effort SIGWINCH listener on Unix; harmless no-op on Windows.
173
+ try {
174
+ process.on('SIGWINCH', resize);
175
+ }
176
+ catch { /* not supported */ }
177
+ // Cleanup on parent signals / pty exit.
178
+ let cleaned = false;
179
+ const cleanup = (exitCode) => {
180
+ if (cleaned)
181
+ return;
182
+ cleaned = true;
183
+ if (process.stdin.isTTY)
184
+ try {
185
+ process.stdin.setRawMode(false);
186
+ }
187
+ catch { /* ignore */ }
188
+ try {
189
+ ws?.close();
190
+ }
191
+ catch { /* ignore */ }
192
+ try {
193
+ pty.kill();
194
+ }
195
+ catch { /* already gone */ }
196
+ process.exit(exitCode);
197
+ };
198
+ process.on('SIGINT', () => { try {
199
+ pty.write('\x03');
200
+ }
201
+ catch { /* ignore */ } }); // Ctrl+C → forward to agent; let agent decide whether to exit
202
+ process.on('SIGTERM', () => cleanup(0));
203
+ process.on('SIGHUP', () => cleanup(0));
204
+ pty.on('exit', (exitCode) => cleanup(exitCode ?? 0));
205
+ // Open WS to live-service as a pusher. Same auth + url shape as live.ts.
206
+ const offsets = readOffsets();
207
+ let ws = null;
208
+ let backoff = 1000;
209
+ // Recursive reconnect loop. Runs forever.
210
+ const runWs = async () => {
211
+ try {
212
+ await wsSession();
213
+ backoff = 1000;
214
+ }
215
+ catch (e) {
216
+ if (opts.verbose)
217
+ process.stderr.write(`\nsickr: ws disconnect (${e.message}); reconnect in ${backoff}ms\n`);
218
+ await sleep(backoff);
219
+ backoff = Math.min(backoff * 2, 30_000);
220
+ }
221
+ if (!cleaned)
222
+ void runWs();
223
+ };
224
+ const wsSession = async () => {
225
+ const wsUrl = `${LIVE_BASE.replace(/^http/, 'ws')}/ws/${urlid}?role=pusher`;
226
+ const WS = await loadWsShim();
227
+ ws = new WS(wsUrl, { headers: { Authorization: `Bearer ${creds.token}` } });
228
+ let tailTimer = null;
229
+ await new Promise((resolve, reject) => {
230
+ let opened = false;
231
+ ws.addEventListener('open', () => {
232
+ opened = true;
233
+ tailTimer = setInterval(() => pumpNewLines(ws, offsets), 500);
234
+ });
235
+ ws.addEventListener('message', (ev) => {
236
+ const raw = decodeWsPayload(ev.data);
237
+ if (opts.verbose)
238
+ process.stderr.write(`\nsickr: ws recv ${raw.length}b\n`);
239
+ let m;
240
+ try {
241
+ m = JSON.parse(raw);
242
+ }
243
+ catch {
244
+ return;
245
+ }
246
+ if (m.kind === 'steer' && m.text) {
247
+ const decision = decideSteer(m);
248
+ if (decision.target === 'pty' && decision.bytes != null) {
249
+ try {
250
+ pty.write(decision.bytes);
251
+ }
252
+ catch { /* PTY may have exited */ }
253
+ if (opts.verbose)
254
+ process.stderr.write(`sickr: pty steer ${decision.bytes.length}b\n`);
255
+ }
256
+ else {
257
+ appendInbox(urlid, m.text, m.at ?? new Date().toISOString());
258
+ if (opts.verbose)
259
+ process.stderr.write(`sickr: inbox steer\n`);
260
+ }
261
+ if (m.id) {
262
+ try {
263
+ ws.send(JSON.stringify({ kind: 'inbox_ack', message_ids: [m.id] }));
264
+ }
265
+ catch { /* will retry */ }
266
+ }
267
+ }
268
+ });
269
+ ws.addEventListener('close', (ev) => {
270
+ if (tailTimer)
271
+ clearInterval(tailTimer);
272
+ if (!opened)
273
+ reject(new Error(`close_before_open code=${ev.code}`));
274
+ else
275
+ resolve();
276
+ });
277
+ ws.addEventListener('error', (ev) => {
278
+ if (tailTimer)
279
+ clearInterval(tailTimer);
280
+ reject(new Error('ws_error' + (('message' in ev) ? `: ${ev.message ?? ''}` : '')));
281
+ });
282
+ });
283
+ };
284
+ void runWs();
285
+ // Block forever — pty drives the lifecycle via 'exit' → cleanup → process.exit.
286
+ await new Promise(() => { });
287
+ }
288
+ async function loadWsShim() {
289
+ try {
290
+ const mod = await import('ws');
291
+ return (mod.default ?? mod.WebSocket);
292
+ }
293
+ catch {
294
+ throw new Error('`ws` is unavailable. It ships as an optional dependency of @sickr/cli; if it failed to install, try: `npm install -g ws`.');
295
+ }
296
+ }
297
+ function pumpNewLines(ws, offsets) {
298
+ const dir = runsDir();
299
+ if (!existsSync(dir))
300
+ return;
301
+ const files = readdirSync(dir).filter((f) => f.endsWith('.ndjson'));
302
+ for (const f of files) {
303
+ const runId = f.replace(/\.ndjson$/, '');
304
+ const path = join(dir, f);
305
+ const from = offsets[runId] ?? statSync(path).size;
306
+ let result;
307
+ try {
308
+ result = tailFrom(path, from);
309
+ }
310
+ catch {
311
+ continue;
312
+ }
313
+ if (result.lines.length === 0) {
314
+ if (offsets[runId] === undefined)
315
+ offsets[runId] = from;
316
+ continue;
317
+ }
318
+ for (const line of result.lines) {
319
+ for (const fragment of splitJsonObjects(line)) {
320
+ try {
321
+ const event = JSON.parse(fragment);
322
+ ws.send(JSON.stringify({ kind: 'event', event }));
323
+ }
324
+ catch { /* skip malformed */ }
325
+ }
326
+ }
327
+ offsets[runId] = result.newOffset;
328
+ }
329
+ writeOffsets(offsets);
330
+ }
331
+ // Unused locals from .live re-export to keep tree-shaking happy.
332
+ void unlinkSync;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sickr/cli",
3
- "version": "0.9.3",
3
+ "version": "0.9.4",
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" },
@@ -14,6 +14,10 @@
14
14
  },
15
15
  "engines": { "node": ">=20" },
16
16
  "license": "UNLICENSED",
17
+ "optionalDependencies": {
18
+ "node-pty": "^1.0.0",
19
+ "ws": "^8.18.0"
20
+ },
17
21
  "devDependencies": {
18
22
  "@types/node": "^20.14.0",
19
23
  "typescript": "^5.6.2",