@sickr/replay 0.1.0 → 0.2.0

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
@@ -1,19 +1,58 @@
1
1
  #!/usr/bin/env node
2
2
  import { pathToFileURL } from 'node:url';
3
- import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync } from 'node:fs';
3
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, unlinkSync } from 'node:fs';
4
4
  import { homedir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { spawn } from 'node:child_process';
7
7
  import { appendEvent, loadRun, runsDir, latestRunId } from './recorder.js';
8
- import { mergeHooks } from './hookConfig.js';
8
+ import { mergeHooks, removeHooks } from './hookConfig.js';
9
9
  import { renderRunHtml } from './render.js';
10
- import { buildSharePayload, publish } from './share.js';
10
+ import { buildSharePayload, publish, PublishError } from './share.js';
11
11
  const REPLAY_ENDPOINT = process.env.SICKR_REPLAY_ENDPOINT ?? 'https://sickr.ai/api/replay';
12
- const COMMANDS = ['init', 'record', 'open', 'list', 'share'];
12
+ const COMMANDS = ['init', 'record', 'open', 'list', 'share', 'stop', 'clear', 'help'];
13
13
  export function parseCommand(argv) {
14
14
  const c = argv[0];
15
15
  return c && COMMANDS.includes(c) ? c : null;
16
16
  }
17
+ export const HELP = `SICKR Replay — audit & replay what your AI coding agent did.
18
+
19
+ Records your Claude Code session (prompts, edits, commands) to a local, redacted
20
+ timeline you can replay — and optionally share as a public link.
21
+
22
+ Why: a durable record of every agent action. If your agent (Claude or Codex)
23
+ loses context or can't reload a past chat, the replay log helps you — and it —
24
+ recall what was just done.
25
+
26
+ Usage: npx @sickr/replay <command> [options]
27
+
28
+ Commands:
29
+ init Install the Claude Code recording hooks in this project and
30
+ start capturing runs to ~/.sickr/runs (secrets redacted).
31
+ open [run] Render a run to a local HTML timeline and open it in your
32
+ browser. 100% local — nothing is uploaded.
33
+ share [run] Redact and publish ONE run to a public sickr.ai/r/<id> link
34
+ (shows a preview and asks first). Links expire after 24h.
35
+ --open also open the published link in your browser
36
+ --yes skip the confirmation prompt
37
+ list List recorded runs, newest first.
38
+ stop Stop recording — removes SICKR's hooks from this project.
39
+ Your recorded runs are kept; run \`init\` to start again.
40
+ clear Delete all local runs in ~/.sickr/runs (asks first).
41
+ help Show this help.
42
+
43
+ ────────────────────────────────────────────────────────────────────
44
+ This tool audits ONE agent on ONE machine. SICKR governs your whole team.
45
+
46
+ · Gates & approvals — plan sign-off, review, merge and validation checks
47
+ that work HOLDS at until they pass.
48
+ · Humans + agents on one board — agents are first-class teammates with
49
+ roles, capacity and accountability, not a side channel.
50
+ · A full, signed-off audit trail across every actor and every change.
51
+ · Runs 24/7 — produce as much work as you like; the team handles it.
52
+
53
+ Free tier available · bring your own Claude or Codex subscription.
54
+ → https://sickr.ai
55
+ `;
17
56
  export function currentRunId(cc) {
18
57
  return String(cc.session_id ?? 'session');
19
58
  }
@@ -38,6 +77,52 @@ function handleInit() {
38
77
  `Runs are recorded locally to ${runsDir()} (secrets redacted).\n` +
39
78
  `Use Claude Code as normal, then: npx @sickr/replay open\n`);
40
79
  }
80
+ /** Stop recording: remove SICKR's hooks from this project's settings, keep runs. */
81
+ export function handleStop() {
82
+ const settingsPath = join(process.cwd(), '.claude', 'settings.json');
83
+ if (!existsSync(settingsPath)) {
84
+ process.stdout.write('sickr: no .claude/settings.json here — not recording in this project.\n');
85
+ return;
86
+ }
87
+ let settings;
88
+ try {
89
+ settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
90
+ }
91
+ catch {
92
+ process.stderr.write(`sickr: could not parse ${settingsPath}; left it unchanged.\n`);
93
+ return;
94
+ }
95
+ writeFileSync(settingsPath, JSON.stringify(removeHooks(settings), null, 2) + '\n');
96
+ process.stdout.write('sickr: recording stopped — removed SICKR hooks from .claude/settings.json.\n' +
97
+ 'Your recorded runs are kept. Run `npx @sickr/replay init` to start again.\n');
98
+ }
99
+ /** Delete all local runs. Destructive — confirms unless `yes` is set. */
100
+ export async function handleClear(yes) {
101
+ const dir = runsDir();
102
+ const files = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.ndjson')) : [];
103
+ if (files.length === 0) {
104
+ process.stdout.write('sickr: no local runs to clear.\n');
105
+ return;
106
+ }
107
+ if (!yes) {
108
+ if (!process.stdin.isTTY) {
109
+ process.stderr.write(`sickr: ${files.length} run(s) in ${dir}. Re-run with --yes to delete them.\n`);
110
+ process.exit(1);
111
+ return;
112
+ }
113
+ process.stdout.write(`Delete ${files.length} recorded run(s) from ${dir}? This cannot be undone. [y/N] `);
114
+ const answer = await new Promise((resolve) => {
115
+ process.stdin.once('data', (d) => resolve(d.toString().trim().toLowerCase()));
116
+ });
117
+ if (answer !== 'y' && answer !== 'yes') {
118
+ process.stdout.write('sickr: cancelled.\n');
119
+ return;
120
+ }
121
+ }
122
+ for (const f of files)
123
+ unlinkSync(join(dir, f));
124
+ process.stdout.write(`sickr: cleared ${files.length} run(s).\n`);
125
+ }
41
126
  function openInBrowser(file) {
42
127
  const cmd = process.platform === 'win32' ? 'cmd' : process.platform === 'darwin' ? 'open' : 'xdg-open';
43
128
  const args = process.platform === 'win32' ? ['/c', 'start', '', file] : [file];
@@ -70,7 +155,7 @@ function handleList() {
70
155
  .sort((a, b) => statSync(join(dir, b)).mtimeMs - statSync(join(dir, a)).mtimeMs)
71
156
  .forEach((f) => process.stdout.write(`${f.replace(/\.ndjson$/, '')}\t${statSync(join(dir, f)).mtime.toISOString()}\n`));
72
157
  }
73
- async function handleShare(runId, yes) {
158
+ async function handleShare(runId, yes, open) {
74
159
  const id = runId ?? latestRunId();
75
160
  if (!id) {
76
161
  process.stderr.write('sickr: no runs to share. Use Claude Code first.\n');
@@ -79,9 +164,8 @@ async function handleShare(runId, yes) {
79
164
  }
80
165
  const run = loadRun(id);
81
166
  const payload = buildSharePayload(run);
82
- process.stdout.write(`sickr: about to publish run "${id}" (${payload.run.events.length} events, secrets already redacted) to ${REPLAY_ENDPOINT}\n`);
83
- for (const e of payload.run.events)
84
- process.stdout.write(` · ${e.label}: ${e.detail || ''}\n`);
167
+ process.stdout.write(`sickr: about to publish run "${id}" (${payload.run.events.length} events, secrets already redacted) to ${REPLAY_ENDPOINT}\n` +
168
+ `sickr: tip run \`npx @sickr/replay open ${id}\` to review the full timeline locally before sharing.\n`);
85
169
  if (!yes) {
86
170
  if (!process.stdin.isTTY) {
87
171
  process.stderr.write('sickr: re-run with --yes to publish non-interactively.\n');
@@ -97,8 +181,34 @@ async function handleShare(runId, yes) {
97
181
  return;
98
182
  }
99
183
  }
100
- const { url } = await publish(payload, REPLAY_ENDPOINT);
184
+ let url;
185
+ try {
186
+ ({ url } = await publish(payload, REPLAY_ENDPOINT));
187
+ }
188
+ catch (err) {
189
+ if (err instanceof PublishError && err.status === 429) {
190
+ process.stdout.write('sickr: rate-limited — you can publish about once every 10s. Waiting to retry once...\n');
191
+ await new Promise((r) => setTimeout(r, 11_000));
192
+ try {
193
+ ({ url } = await publish(payload, REPLAY_ENDPOINT));
194
+ }
195
+ catch (retryErr) {
196
+ if (retryErr instanceof PublishError && retryErr.status === 429) {
197
+ process.stderr.write('sickr: still rate-limited. Give it a minute and run `share` again.\n');
198
+ process.exit(1);
199
+ return;
200
+ }
201
+ throw retryErr;
202
+ }
203
+ }
204
+ else {
205
+ throw err;
206
+ }
207
+ }
101
208
  process.stdout.write(`sickr: published → ${url}\n`);
209
+ process.stdout.write('sickr: this link expires in 24h.\n');
210
+ if (open)
211
+ openInBrowser(url);
102
212
  }
103
213
  async function readStdin() {
104
214
  const chunks = [];
@@ -108,7 +218,12 @@ async function readStdin() {
108
218
  }
109
219
  async function main() {
110
220
  const argv = process.argv.slice(2);
221
+ if (argv.length === 0 || argv[0] === 'help' || argv.includes('--help') || argv.includes('-h')) {
222
+ process.stdout.write(HELP);
223
+ return;
224
+ }
111
225
  const cmd = parseCommand(argv);
226
+ const rest = argv.slice(1);
112
227
  switch (cmd) {
113
228
  case 'record':
114
229
  handleRecord(await readStdin());
@@ -122,14 +237,18 @@ async function main() {
122
237
  case 'list':
123
238
  handleList();
124
239
  return;
125
- case 'share': {
126
- const rest = argv.slice(1);
127
- await handleShare(rest.find((a) => !a.startsWith('-')), rest.includes('--yes'));
240
+ case 'stop':
241
+ handleStop();
242
+ return;
243
+ case 'clear':
244
+ await handleClear(rest.includes('--yes') || rest.includes('-y'));
245
+ return;
246
+ case 'share':
247
+ await handleShare(rest.find((a) => !a.startsWith('-')), rest.includes('--yes') || rest.includes('-y'), rest.includes('--open'));
128
248
  return;
129
- }
130
249
  default:
131
- process.stderr.write('usage: npx @sickr/replay <init|record|open|list|share>\n');
132
- process.exit(argv[0] ? 1 : 0);
250
+ process.stderr.write('sickr: unknown command. Run `npx @sickr/replay help`.\n');
251
+ process.exit(1);
133
252
  }
134
253
  }
135
254
  const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
@@ -17,3 +17,26 @@ export function mergeHooks(settings, binPath) {
17
17
  }
18
18
  return next;
19
19
  }
20
+ /**
21
+ * Remove the SICKR recording hooks from a Claude Code settings object — the
22
+ * inverse of mergeHooks. Unrelated hooks are preserved; an event left with no
23
+ * hooks is dropped so settings.json stays clean.
24
+ */
25
+ export function removeHooks(settings) {
26
+ const next = { ...(settings ?? {}) };
27
+ if (!next.hooks)
28
+ return next;
29
+ const hooks = { ...next.hooks };
30
+ for (const ev of EVENTS) {
31
+ const groups = Array.isArray(hooks[ev]) ? hooks[ev] : undefined;
32
+ if (!groups)
33
+ continue;
34
+ const kept = groups.filter((g) => !JSON.stringify(g).includes(TAG));
35
+ if (kept.length > 0)
36
+ hooks[ev] = kept;
37
+ else
38
+ delete hooks[ev];
39
+ }
40
+ next.hooks = hooks;
41
+ return next;
42
+ }
package/dist/render.js CHANGED
@@ -2,6 +2,35 @@ const PLASMA = '#34e0ff';
2
2
  function esc(s) {
3
3
  return s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
4
4
  }
5
+ // Inline Sickr wordmark (mirrors src/components/SickrLogo.tsx) — static ids.
6
+ function wordmark() {
7
+ return `<svg viewBox="0 0 1105 395" role="img" aria-label="Sickr" class="logo" shape-rendering="geometricPrecision">
8
+ <defs>
9
+ <linearGradient id="lg-silver" x1="0" y1="0" x2="1" y2="1">
10
+ <stop offset="0%" stop-color="#ffffff"/><stop offset="36%" stop-color="#f6f8fb"/>
11
+ <stop offset="58%" stop-color="#c7cedb"/><stop offset="78%" stop-color="#ffffff"/><stop offset="100%" stop-color="#dde3ee"/>
12
+ </linearGradient>
13
+ <filter id="lg-fx" x="-10%" y="-12%" width="120%" height="124%">
14
+ <feGaussianBlur stdDeviation="5" result="b"/>
15
+ <feColorMatrix in="b" type="matrix" values="0 0 0 0 0.20 0 0 0 0 0.88 0 0 0 0 1 0 0 0 0.5 0" result="g"/>
16
+ <feMerge><feMergeNode in="g"/><feMergeNode in="SourceGraphic"/></feMerge>
17
+ </filter>
18
+ <mask id="lg-s" maskUnits="userSpaceOnUse"><rect width="1105" height="395" fill="white"/><polygon points="101,258 270,145 202,204 161,213" fill="black"/></mask>
19
+ <mask id="lg-i" maskUnits="userSpaceOnUse"><rect width="1105" height="395" fill="white"/><polygon points="361,260 396,229 396,252 361,283" fill="black"/></mask>
20
+ <mask id="lg-c" maskUnits="userSpaceOnUse"><rect width="1105" height="395" fill="white"/><polygon points="458,272 493,244 504,262 469,290" fill="black"/></mask>
21
+ <mask id="lg-r" maskUnits="userSpaceOnUse"><rect width="1105" height="395" fill="white"/><polygon points="888,266 920,238 920,261 888,289" fill="black"/></mask>
22
+ </defs>
23
+ <g fill="url(#lg-silver)" filter="url(#lg-fx)">
24
+ <path mask="url(#lg-s)" d="M 70 324 L 110 296 L 221 296 C 243 296 258 282 258 260 C 258 238 247 223 224 207 L 244 188 C 272 204 286 227 286 258 C 286 297 260 324 228 324 Z M 136 213 C 109 199 96 179 96 153 C 96 109 122 77 156 77 L 296 77 L 262 106 L 158 106 C 138 106 124 125 124 153 C 124 171 134 184 157 195 Z"/>
25
+ <polygon points="268,146 200,203 109,256 162,211"/>
26
+ <circle cx="378" cy="116" r="19"/>
27
+ <path mask="url(#lg-i)" d="M 366 182 L 395 155 L 395 299 L 366 324 Z"/>
28
+ <path mask="url(#lg-c)" d="M 623 153 L 587 182 L 530 182 C 504 182 486 204 486 234 C 486 264 504 285 529 285 L 625 285 L 591 314 L 520 314 C 482 314 457 282 457 237 C 457 191 482 153 518 153 Z"/>
29
+ <path d="M 684 98 L 714 70 L 714 200 L 684 227 Z M 684 254 L 796 151 L 837 151 L 714 265 L 714 294 L 684 320 Z M 764 238 L 841 313 L 802 313 L 744 256 Z"/>
30
+ <path mask="url(#lg-r)" d="M 889 321 L 889 193 C 889 168 906 153 927 153 L 1034 153 L 998 182 L 934 182 C 924 182 918 189 918 199 L 918 295 Z"/>
31
+ </g>
32
+ </svg>`;
33
+ }
5
34
  function dot(kind) {
6
35
  return kind === 'tool' ? PLASMA : kind === 'prompt' ? '#8b95a7' : '#5f6b80';
7
36
  }
@@ -20,11 +49,15 @@ export function renderRunHtml(run) {
20
49
  return `<!doctype html>
21
50
  <html lang="en"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
22
51
  <title>SICKR Replay — ${esc(run.id)}</title>
52
+ <link rel="preconnect" href="https://fonts.googleapis.com"/>
53
+ <link href="https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@600;700&family=Sora:wght@300;400;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"/>
23
54
  <style>
24
55
  body{margin:0;background:#06080d;color:#e7ecf3;font-family:Sora,system-ui,Arial,sans-serif;padding:40px 20px}
25
56
  .wrap{max-width:820px;margin:0 auto}
26
- .brand{font-family:"Chakra Petch",sans-serif;color:${PLASMA};letter-spacing:.04em;font-weight:700}
27
- .meta{font-family:"JetBrains Mono",ui-monospace,monospace;font-size:12px;color:#5f6b80;margin:6px 0 28px}
57
+ .bar{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:24px}
58
+ .logo{height:30px;width:auto;display:block}
59
+ .bar a{font-family:"JetBrains Mono",monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:${PLASMA};text-decoration:none}
60
+ .meta{font-family:"JetBrains Mono",ui-monospace,monospace;font-size:12px;color:#5f6b80;margin:0 0 28px}
28
61
  ol{list-style:none;margin:0;padding:0;position:relative}
29
62
  ol::before{content:"";position:absolute;left:6px;top:6px;bottom:6px;width:1px;background:linear-gradient(${PLASMA},transparent)}
30
63
  li{position:relative;padding:0 0 18px 28px}
@@ -33,13 +66,21 @@ export function renderRunHtml(run) {
33
66
  .kind{font-family:"JetBrains Mono",monospace;font-size:10px;text-transform:uppercase;letter-spacing:.12em;color:${PLASMA};margin-left:6px}
34
67
  .detail{font-family:"JetBrains Mono",monospace;font-size:12.5px;color:#cdd5e1;margin-top:4px;white-space:pre-wrap;word-break:break-word}
35
68
  .time{font-family:"JetBrains Mono",monospace;font-size:11px;color:#5f6b80;margin-top:3px}
36
- .cta{margin-top:36px;border-top:1px solid #1b2435;padding-top:20px;font-size:14px;color:#8b95a7}
37
- .cta a{color:${PLASMA};text-decoration:none}
69
+ .cta{margin-top:36px;border:1px solid #1b2435;border-radius:12px;background:rgba(8,12,20,.55);padding:22px}
70
+ .cta h2{font-family:"Chakra Petch",sans-serif;font-weight:700;font-size:18px;color:#fff;margin:0 0 8px}
71
+ .cta p{font-size:14px;line-height:1.6;color:#9aa6b6;margin:0 0 16px}
72
+ .btn{display:inline-flex;align-items:center;gap:8px;background:${PLASMA};color:#06080d;font-family:"JetBrains Mono",monospace;font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.08em;padding:11px 16px;border-radius:6px;text-decoration:none;box-shadow:0 0 24px rgba(52,224,255,.3)}
73
+ .share-hint{margin-top:14px;font-family:"JetBrains Mono",monospace;font-size:11px;color:#5f6b80}
38
74
  </style></head>
39
75
  <body><div class="wrap">
40
- <div class="brand">sickr</div>
41
- <div class="meta">replay · ${esc(run.id)} · ${esc(run.cwd || '')} · ${run.events.length} events</div>
76
+ <div class="bar">${wordmark()}<a href="https://sickr.ai">sickr.ai →</a></div>
77
+ <div class="meta">local replay · ${esc(run.id)} · ${esc(run.cwd || '')} · ${run.events.length} events</div>
42
78
  <ol>${events}</ol>
43
- <div class="cta">This is one local agent run. Govern your whole team — gates, approvals, multi-agent, full audit → <a href="https://sickr.ai">sickr.ai</a></div>
79
+ <div class="cta">
80
+ <h2>This is one agent, on your machine.</h2>
81
+ <p>SICKR governs your whole team — gates, approvals, multi-agent hand-offs and a full, signed-off audit trail across humans and agents. Free tier · bring your own Claude or Codex.</p>
82
+ <a class="btn" href="https://sickr.ai">Explore SICKR →</a>
83
+ <div class="share-hint">Tip: <code>npx @sickr/replay share</code> publishes this run to a public link.</div>
84
+ </div>
44
85
  </div></body></html>`;
45
86
  }
package/dist/share.js CHANGED
@@ -2,6 +2,14 @@
2
2
  export function buildSharePayload(run) {
3
3
  return { run: { cwd: run.cwd, startedAt: run.startedAt, events: run.events } };
4
4
  }
5
+ export class PublishError extends Error {
6
+ status;
7
+ constructor(status) {
8
+ super(`publish failed: ${status}`);
9
+ this.status = status;
10
+ this.name = 'PublishError';
11
+ }
12
+ }
5
13
  export async function publish(payload, endpoint) {
6
14
  const res = await fetch(endpoint, {
7
15
  method: 'POST',
@@ -9,6 +17,6 @@ export async function publish(payload, endpoint) {
9
17
  body: JSON.stringify(payload),
10
18
  });
11
19
  if (!res.ok)
12
- throw new Error(`publish failed: ${res.status}`);
20
+ throw new PublishError(res.status);
13
21
  return (await res.json());
14
22
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sickr/replay",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "npx @sickr/replay — local Claude Code audit + one-click share. The free wedge into SICKR.",
6
6
  "bin": { "replay": "dist/cli.js" },