@sickr/replay 0.3.0 → 0.4.3

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
@@ -2,7 +2,7 @@
2
2
  import { pathToFileURL } from 'node:url';
3
3
  import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, unlinkSync } from 'node:fs';
4
4
  import { homedir } from 'node:os';
5
- import { join } from 'node:path';
5
+ import { join, dirname } from 'node:path';
6
6
  import { spawn } from 'node:child_process';
7
7
  import { appendEvent, loadRun, runsDir, latestRunId } from './recorder.js';
8
8
  import { mergeHooks, removeHooks } from './hookConfig.js';
@@ -16,8 +16,8 @@ export function parseCommand(argv) {
16
16
  }
17
17
  export const HELP = `SICKR Replay — audit & replay what your AI coding agent did.
18
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.
19
+ Records your Claude Code or Codex session (prompts, edits, commands) to a local,
20
+ redacted timeline you can replay — and optionally share as a public link.
21
21
 
22
22
  Why: a durable record of every agent action — a dashcam for your coding agent.
23
23
  If your agent (Claude or Codex) loses context or can't reload a past chat, the
@@ -26,10 +26,16 @@ replay log helps you — and it — recall exactly what was just done.
26
26
  Usage: npx @sickr/replay <command> [options]
27
27
 
28
28
  Commands:
29
- init Install the Claude Code recording hooks in this project and
30
- start capturing runs to ~/.sickr/runs (secrets redacted).
29
+ init Install the recording hooks in this project and start capturing
30
+ runs to ~/.sickr/runs (secrets redacted).
31
+ --codex install for Codex (.codex/hooks.json) instead of
32
+ Claude Code (.claude/settings.json)
33
+ --as "<name>" label your prompts with <name> on replays
34
+ (default "Human"; or set SICKR_HANDLE)
31
35
  open [run] Render a run to a local HTML timeline and open it in your
32
- browser. 100% local — nothing is uploaded.
36
+ browser. 100% local — nothing is uploaded. Defaults to the
37
+ newest run; pass a run id, or --codex / --claude to open the
38
+ newest run for that agent.
33
39
  share [run] Redact and publish ONE run to a public sickr.ai/r/<id> link
34
40
  (shows a preview and asks first). Links expire after 24h.
35
41
  --open also open the published link in your browser
@@ -58,44 +64,83 @@ audit, accountability, productivity and confidence.
58
64
  export function currentRunId(cc) {
59
65
  return String(cc.session_id ?? 'session');
60
66
  }
61
- /** Ingest one Claude Code hook payload. Must never throw — a hook error would break the agent. */
62
- export function handleRecord(input) {
67
+ const PROVIDERS = {
68
+ claude: { name: 'Claude Code', label: 'Claude', settingsPath: () => join(process.cwd(), '.claude', 'settings.json') },
69
+ codex: { name: 'Codex', label: 'Codex', settingsPath: () => join(process.cwd(), '.codex', 'hooks.json') },
70
+ };
71
+ function configPath() {
72
+ return join(homedir(), '.sickr', 'config.json');
73
+ }
74
+ /** Display name for the human: SICKR_HANDLE env, else ~/.sickr/config.json handle, else "Human". */
75
+ function resolveHandle() {
76
+ if (process.env.SICKR_HANDLE)
77
+ return process.env.SICKR_HANDLE;
78
+ try {
79
+ const c = JSON.parse(readFileSync(configPath(), 'utf8'));
80
+ if (c.handle)
81
+ return c.handle;
82
+ }
83
+ catch { /* no config */ }
84
+ return 'Human';
85
+ }
86
+ /** Ingest one hook payload (Claude Code or Codex). Must never throw. */
87
+ export function handleRecord(input, provider = 'claude') {
63
88
  try {
64
89
  const cc = JSON.parse(input);
65
- appendEvent(currentRunId(cc), cc);
90
+ appendEvent(currentRunId(cc), cc, { human: resolveHandle(), agent: PROVIDERS[provider].label });
66
91
  }
67
92
  catch {
68
93
  /* swallow: recording is best-effort and must not disrupt the session */
69
94
  }
70
95
  }
71
- function handleInit() {
72
- const settingsPath = join(process.cwd(), '.claude', 'settings.json');
96
+ export function handleInit(provider, handle) {
97
+ const p = PROVIDERS[provider];
98
+ const settingsPath = p.settingsPath();
73
99
  const settings = existsSync(settingsPath) ? JSON.parse(readFileSync(settingsPath, 'utf8')) : {};
74
- const merged = mergeHooks(settings, 'npx @sickr/replay');
75
- mkdirSync(join(process.cwd(), '.claude'), { recursive: true });
100
+ const command = `npx @sickr/replay record${provider === 'codex' ? ' --codex' : ''}`;
101
+ // Remove any prior SICKR hook first, then install the current command — so
102
+ // re-running init (or a CLI upgrade that changes the command) self-heals
103
+ // instead of leaving a stale hook. Scoped to this provider's file.
104
+ const merged = mergeHooks(removeHooks(settings), command);
105
+ mkdirSync(dirname(settingsPath), { recursive: true });
76
106
  writeFileSync(settingsPath, JSON.stringify(merged, null, 2) + '\n');
77
107
  mkdirSync(runsDir(), { recursive: true });
78
- process.stdout.write(`sickr: installed Claude Code hooks in ${settingsPath}\n` +
108
+ if (handle) {
109
+ let existing = {};
110
+ try {
111
+ existing = JSON.parse(readFileSync(configPath(), 'utf8'));
112
+ }
113
+ catch { /* none */ }
114
+ writeFileSync(configPath(), JSON.stringify({ ...existing, handle }, null, 2) + '\n');
115
+ }
116
+ process.stdout.write(`sickr: installed ${p.name} recording hooks in ${settingsPath}\n` +
79
117
  `Runs are recorded locally to ${runsDir()} (secrets redacted).\n` +
80
- `Use Claude Code as normal, then: npx @sickr/replay open\n`);
118
+ (handle ? `Your prompts will be labelled "${handle}".\n` : 'Tip: set SICKR_HANDLE or run `init --as "<name>"` to label your prompts.\n') +
119
+ `Use ${p.name} as normal, then: npx @sickr/replay open\n`);
81
120
  }
82
- /** Stop recording: remove SICKR's hooks from this project's settings, keep runs. */
121
+ /** Stop recording: remove SICKR's hooks from this project (both providers), keep runs. */
83
122
  export function handleStop() {
84
- const settingsPath = join(process.cwd(), '.claude', 'settings.json');
85
- if (!existsSync(settingsPath)) {
86
- process.stdout.write('sickr: no .claude/settings.json here — not recording in this project.\n');
87
- return;
88
- }
89
- let settings;
90
- try {
91
- settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
123
+ const targets = [PROVIDERS.claude.settingsPath(), PROVIDERS.codex.settingsPath()];
124
+ const cleaned = [];
125
+ for (const settingsPath of targets) {
126
+ if (!existsSync(settingsPath))
127
+ continue;
128
+ let settings;
129
+ try {
130
+ settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
131
+ }
132
+ catch {
133
+ process.stderr.write(`sickr: could not parse ${settingsPath}; left it unchanged.\n`);
134
+ continue;
135
+ }
136
+ writeFileSync(settingsPath, JSON.stringify(removeHooks(settings), null, 2) + '\n');
137
+ cleaned.push(settingsPath);
92
138
  }
93
- catch {
94
- process.stderr.write(`sickr: could not parse ${settingsPath}; left it unchanged.\n`);
139
+ if (cleaned.length === 0) {
140
+ process.stdout.write('sickr: no SICKR hooks found here — not recording in this project.\n');
95
141
  return;
96
142
  }
97
- writeFileSync(settingsPath, JSON.stringify(removeHooks(settings), null, 2) + '\n');
98
- process.stdout.write('sickr: recording stopped — removed SICKR hooks from .claude/settings.json.\n' +
143
+ process.stdout.write(`sickr: recording stopped — removed SICKR hooks from ${cleaned.join(', ')}.\n` +
99
144
  'Your recorded runs are kept. Run `npx @sickr/replay init` to start again.\n');
100
145
  }
101
146
  /** Delete all local runs. Destructive — confirms unless `yes` is set. */
@@ -131,17 +176,49 @@ function openInBrowser(file) {
131
176
  }
132
177
  catch { /* ignore */ }
133
178
  }
134
- function handleOpen(runId) {
135
- const id = runId ?? latestRunId();
179
+ /** A short, human-readable summary of a run: agent + first prompt + event count. */
180
+ function runSummary(id) {
181
+ const run = loadRun(id);
182
+ const agent = run.events.find((e) => e.kind === 'response')?.label || '—';
183
+ const prompt = (run.events.find((e) => e.kind === 'prompt')?.detail || '').replace(/\s+/g, ' ').trim();
184
+ return { agent, prompt, events: run.events.length };
185
+ }
186
+ /** Newest run whose agent (response label) matches `agent`, or null. */
187
+ export function latestRunIdFor(agent) {
188
+ const dir = runsDir();
189
+ if (!existsSync(dir))
190
+ return null;
191
+ const files = readdirSync(dir)
192
+ .filter((f) => f.endsWith('.ndjson'))
193
+ .sort((a, b) => statSync(join(dir, b)).mtimeMs - statSync(join(dir, a)).mtimeMs);
194
+ for (const f of files) {
195
+ const id = f.replace(/\.ndjson$/, '');
196
+ if (loadRun(id).events.some((e) => e.kind === 'response' && e.label === agent))
197
+ return id;
198
+ }
199
+ return null;
200
+ }
201
+ function handleOpen(runId, provider) {
202
+ let id = runId;
203
+ if (!id && provider) {
204
+ id = latestRunIdFor(PROVIDERS[provider].label) ?? undefined;
205
+ if (!id) {
206
+ process.stdout.write(`sickr: no ${PROVIDERS[provider].label} runs yet — use ${PROVIDERS[provider].name} with the hooks installed, then try again.\n`);
207
+ return;
208
+ }
209
+ }
210
+ id = id ?? latestRunId() ?? undefined;
136
211
  if (!id) {
137
- process.stdout.write('sickr: no runs recorded yet. Run `npx @sickr/replay init`, then use Claude Code.\n');
212
+ process.stdout.write('sickr: no runs recorded yet. Run `npx @sickr/replay init`, then use Claude Code or Codex.\n');
138
213
  return;
139
214
  }
140
215
  const html = renderRunHtml(loadRun(id));
141
216
  const out = join(homedir(), '.sickr', 'last.html');
142
217
  mkdirSync(join(homedir(), '.sickr'), { recursive: true });
143
218
  writeFileSync(out, html);
144
- process.stdout.write(`sickr: opened replay for ${id} → ${out}\n`);
219
+ const s = runSummary(id);
220
+ process.stdout.write(`sickr: opened ${s.agent} run ${id} · ${s.events} events${s.prompt ? ` · "${s.prompt.slice(0, 60)}"` : ''}\n` +
221
+ `→ ${out} (newest run; use \`list\` to see others, \`open <id>\` to pick one)\n`);
145
222
  openInBrowser(out);
146
223
  }
147
224
  function handleList() {
@@ -153,7 +230,13 @@ function handleList() {
153
230
  }
154
231
  files
155
232
  .sort((a, b) => statSync(join(dir, b)).mtimeMs - statSync(join(dir, a)).mtimeMs)
156
- .forEach((f) => process.stdout.write(`${f.replace(/\.ndjson$/, '')}\t${statSync(join(dir, f)).mtime.toISOString()}\n`));
233
+ .forEach((f) => {
234
+ const id = f.replace(/\.ndjson$/, '');
235
+ const s = runSummary(id);
236
+ const when = statSync(join(dir, f)).mtime.toISOString().replace('T', ' ').slice(0, 16);
237
+ const snippet = s.prompt ? ` "${s.prompt.slice(0, 48)}"` : '';
238
+ process.stdout.write(`${id} ${s.agent.padEnd(7)} ${String(s.events).padStart(4)} ev ${when}${snippet}\n`);
239
+ });
157
240
  }
158
241
  async function handleShare(runId, yes, open) {
159
242
  const id = runId ?? latestRunId();
@@ -231,16 +314,22 @@ async function main() {
231
314
  }
232
315
  const cmd = parseCommand(argv);
233
316
  const rest = argv.slice(1);
317
+ const provider = rest.includes('--codex') ? 'codex' : 'claude';
234
318
  switch (cmd) {
235
319
  case 'record':
236
- handleRecord(await readStdin());
320
+ handleRecord(await readStdin(), provider);
237
321
  return;
238
- case 'init':
239
- handleInit();
322
+ case 'init': {
323
+ const asIdx = rest.indexOf('--as');
324
+ const handle = asIdx >= 0 ? rest[asIdx + 1] : undefined;
325
+ handleInit(provider, handle);
240
326
  return;
241
- case 'open':
242
- handleOpen(argv[1]);
327
+ }
328
+ case 'open': {
329
+ const openProvider = rest.includes('--codex') ? 'codex' : rest.includes('--claude') ? 'claude' : undefined;
330
+ handleOpen(rest.find((a) => !a.startsWith('-')), openProvider);
243
331
  return;
332
+ }
244
333
  case 'list':
245
334
  handleList();
246
335
  return;
package/dist/recorder.js CHANGED
@@ -80,18 +80,22 @@ function formatQuestions(questions) {
80
80
  })
81
81
  .join('\n\n');
82
82
  }
83
- /** Map one Claude Code hook payload to a redacted run event. */
84
- export function mapEvent(cc, now = new Date()) {
83
+ /**
84
+ * Map one hook payload (Claude Code OR Codex — same field names) to a redacted
85
+ * run event. `ctx` supplies the human/agent display labels.
86
+ */
87
+ export function mapEvent(cc, now = new Date(), ctx = {}) {
85
88
  const at = now.toISOString();
86
89
  const name = String(cc.hook_event_name ?? '');
87
90
  switch (name) {
88
91
  case 'SessionStart':
89
92
  return { kind: 'start', label: 'Session', detail: redact(String(cc.cwd ?? '')), at };
90
93
  case 'UserPromptSubmit':
91
- return { kind: 'prompt', label: 'Prompt', detail: redact(String(cc.prompt ?? '')).slice(0, 400), at };
94
+ return { kind: 'prompt', label: (ctx.human || 'Human').slice(0, 40), detail: redact(String(cc.prompt ?? '')).slice(0, 400), at };
92
95
  case 'Stop': {
93
- const text = cc.transcript_path ? extractLastAssistantText(String(cc.transcript_path)) : '';
94
- return { kind: 'response', label: 'Response', detail: redact(text).slice(0, 2000), at };
96
+ // Codex hands us the reply directly; Claude Code we read from the transcript.
97
+ const text = String(cc.last_assistant_message ?? '') || (cc.transcript_path ? extractLastAssistantText(String(cc.transcript_path)) : '');
98
+ return { kind: 'response', label: (ctx.agent || 'Agent').slice(0, 40), detail: redact(text).slice(0, 2000), at };
95
99
  }
96
100
  case 'PreToolUse':
97
101
  case 'PostToolUse': {
@@ -113,10 +117,10 @@ export function mapEvent(cc, now = new Date()) {
113
117
  return { kind: 'tool', label: name || 'event', detail: '', at };
114
118
  }
115
119
  }
116
- export function appendEvent(runId, cc) {
120
+ export function appendEvent(runId, cc, ctx = {}) {
117
121
  const dir = runsDir();
118
122
  mkdirSync(dir, { recursive: true });
119
- appendFileSync(join(dir, `${runId}.ndjson`), JSON.stringify(mapEvent(cc)) + '\n');
123
+ appendFileSync(join(dir, `${runId}.ndjson`), JSON.stringify(mapEvent(cc, new Date(), ctx)) + '\n');
120
124
  }
121
125
  export function loadRun(runId) {
122
126
  const file = join(runsDir(), `${runId}.ndjson`);
package/dist/render.js CHANGED
@@ -108,6 +108,16 @@ const STYLES = `
108
108
  .msg .bubble{padding:10px 14px;border-radius:0 8px 8px 0;white-space:pre-wrap;word-break:break-word;font-size:14px;line-height:1.6}
109
109
  .msg.human .bubble{border-left:2px solid rgba(255,207,107,.55);background:rgba(255,207,107,.05);color:#f3ead7}
110
110
  .msg.agent .bubble{border-left:2px solid rgba(52,224,255,.55);background:rgba(52,224,255,.05);color:#d7e6ee}
111
+ .bubble.md{white-space:normal}
112
+ .bubble.md p{margin:0 0 8px}.bubble.md p:last-child{margin-bottom:0}
113
+ .bubble.md code{font-family:"JetBrains Mono",monospace;font-size:.92em;background:rgba(255,255,255,.07);padding:1px 5px;border-radius:4px}
114
+ .bubble.md pre{background:#04060b;border:1px solid #1b2435;border-radius:8px;padding:10px 12px;overflow:auto;margin:8px 0}
115
+ .bubble.md pre code{background:none;padding:0;font-size:12.5px;line-height:1.5}
116
+ .bubble.md ul,.bubble.md ol{margin:6px 0;padding-left:20px}.bubble.md li{margin:3px 0}
117
+ .bubble.md a{color:var(--plasma);text-decoration:underline}
118
+ .bubble.md blockquote{margin:8px 0;padding:2px 12px;border-left:2px solid #2a3850;color:#9aa6b6}
119
+ .bubble.md .md-h{font-family:"Chakra Petch","Sora",sans-serif;font-weight:700;color:#fff;font-size:15px;margin:10px 0 6px}
120
+ .bubble.md strong{color:#fff;font-weight:700}
111
121
  details.work{margin:10px 0;border:1px solid #16202f;border-radius:10px;background:rgba(8,12,20,.4)}
112
122
  details.work>summary{cursor:pointer;list-style:none;padding:10px 14px;display:flex;align-items:center;gap:10px;font-family:"JetBrains Mono",monospace;font-size:12px;color:#9aa6b6}
113
123
  details.work>summary::-webkit-details-marker{display:none}
@@ -210,10 +220,109 @@ function fmtTime(at) {
210
220
  return at ? esc(at.replace('T', ' ').slice(0, 19) + 'Z') : '';
211
221
  }
212
222
  const COPY_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>';
223
+ // Inline markdown on an ALREADY-ESCAPED string. Code spans are protected from
224
+ // emphasis. Only http(s) links are emitted. No raw HTML can survive (input is
225
+ // pre-escaped), so this is XSS-safe — it only adds a fixed set of safe tags.
226
+ function inlineMd(s) {
227
+ return s.split(/(`[^`]+`)/).map((p) => {
228
+ if (p.length >= 2 && p[0] === '`' && p[p.length - 1] === '`')
229
+ return `<code>${p.slice(1, -1)}</code>`;
230
+ return p
231
+ .replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" rel="noopener noreferrer" target="_blank">$1</a>')
232
+ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
233
+ .replace(/__([^_]+)__/g, '<strong>$1</strong>')
234
+ .replace(/\*([^*\s][^*]*)\*/g, '<em>$1</em>')
235
+ .replace(/(^|[^a-zA-Z0-9_])_([^_\s][^_]*)_/g, '$1<em>$2</em>');
236
+ }).join('');
237
+ }
238
+ /** Minimal, XSS-safe markdown → HTML for chat bubbles (prompt/response). */
239
+ export function mdToHtml(raw) {
240
+ const lines = esc(raw).split('\n');
241
+ const out = [];
242
+ let para = [];
243
+ let code = [];
244
+ let inCode = false;
245
+ let list = null;
246
+ const flushPara = () => { if (para.length) {
247
+ out.push(`<p>${inlineMd(para.join('<br>'))}</p>`);
248
+ para = [];
249
+ } };
250
+ const flushList = () => { if (list) {
251
+ out.push(`</${list}>`);
252
+ list = null;
253
+ } };
254
+ for (const line of lines) {
255
+ const fence = /^```/.test(line.trim());
256
+ if (inCode) {
257
+ if (fence) {
258
+ out.push(`<pre><code>${code.join('\n')}</code></pre>`);
259
+ code = [];
260
+ inCode = false;
261
+ }
262
+ else
263
+ code.push(line);
264
+ continue;
265
+ }
266
+ if (fence) {
267
+ flushPara();
268
+ flushList();
269
+ inCode = true;
270
+ continue;
271
+ }
272
+ if (/^\s*$/.test(line)) {
273
+ flushPara();
274
+ flushList();
275
+ continue;
276
+ }
277
+ let m;
278
+ if ((m = line.match(/^#{1,6}\s+(.*)$/))) {
279
+ flushPara();
280
+ flushList();
281
+ out.push(`<div class="md-h">${inlineMd(m[1])}</div>`);
282
+ continue;
283
+ }
284
+ if ((m = line.match(/^\s*[-*]\s+(.*)$/))) {
285
+ flushPara();
286
+ if (list !== 'ul') {
287
+ flushList();
288
+ out.push('<ul>');
289
+ list = 'ul';
290
+ }
291
+ out.push(`<li>${inlineMd(m[1])}</li>`);
292
+ continue;
293
+ }
294
+ if ((m = line.match(/^\s*\d+\.\s+(.*)$/))) {
295
+ flushPara();
296
+ if (list !== 'ol') {
297
+ flushList();
298
+ out.push('<ol>');
299
+ list = 'ol';
300
+ }
301
+ out.push(`<li>${inlineMd(m[1])}</li>`);
302
+ continue;
303
+ }
304
+ if ((m = line.match(/^>\s?(.*)$/))) {
305
+ flushPara();
306
+ flushList();
307
+ out.push(`<blockquote>${inlineMd(m[1])}</blockquote>`);
308
+ continue;
309
+ }
310
+ para.push(line);
311
+ }
312
+ if (inCode)
313
+ out.push(`<pre><code>${code.join('\n')}</code></pre>`);
314
+ flushPara();
315
+ flushList();
316
+ return out.join('\n');
317
+ }
318
+ // The speaker name travels on the event label; fall back for legacy generic labels.
319
+ function speaker(label, generic, fallback) {
320
+ return label && label !== generic ? label : fallback;
321
+ }
213
322
  function bubble(kind, who, e) {
214
323
  return `<div class="msg ${kind}">
215
- <div class="who"><span class="badge ${kind === 'human' ? 'h' : 'a'}">${who}</span><span class="ts">${fmtTime(e.at)}</span><button class="copy" type="button" aria-label="Copy text" title="Copy">${COPY_ICON}</button></div>
216
- <div class="bubble">${esc(e.detail)}</div>
324
+ <div class="who"><span class="badge ${kind === 'human' ? 'h' : 'a'}">${esc(who)}</span><span class="ts">${fmtTime(e.at)}</span><button class="copy" type="button" aria-label="Copy text" title="Copy">${COPY_ICON}</button></div>
325
+ <div class="bubble md">${mdToHtml(e.detail)}</div>
217
326
  </div>`;
218
327
  }
219
328
  function workBlock(work) {
@@ -238,9 +347,9 @@ function renderTimeline(events) {
238
347
  : '';
239
348
  const preTools = workBlock(pre.filter((e) => e.kind === 'tool'));
240
349
  const body = turns.map((t) => `<section class="turn">
241
- ${t.prompt ? bubble('human', 'You', t.prompt) : ''}
350
+ ${t.prompt ? bubble('human', speaker(t.prompt.label, 'Prompt', 'Human'), t.prompt) : ''}
242
351
  ${workBlock(t.work)}
243
- ${t.response && t.response.detail ? bubble('agent', 'Claude', t.response) : ''}
352
+ ${t.response && t.response.detail ? bubble('agent', speaker(t.response.label, 'Response', 'Agent'), t.response) : ''}
244
353
  </section>`).join('\n');
245
354
  return preamble + preTools + body;
246
355
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sickr/replay",
3
- "version": "0.3.0",
3
+ "version": "0.4.3",
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" },