@sickr/replay 0.1.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 +137 -0
- package/dist/hookConfig.js +19 -0
- package/dist/recorder.js +57 -0
- package/dist/redact.js +19 -0
- package/dist/render.js +45 -0
- package/dist/share.js +14 -0
- package/package.json +21 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { appendEvent, loadRun, runsDir, latestRunId } from './recorder.js';
|
|
8
|
+
import { mergeHooks } from './hookConfig.js';
|
|
9
|
+
import { renderRunHtml } from './render.js';
|
|
10
|
+
import { buildSharePayload, publish } from './share.js';
|
|
11
|
+
const REPLAY_ENDPOINT = process.env.SICKR_REPLAY_ENDPOINT ?? 'https://sickr.ai/api/replay';
|
|
12
|
+
const COMMANDS = ['init', 'record', 'open', 'list', 'share'];
|
|
13
|
+
export function parseCommand(argv) {
|
|
14
|
+
const c = argv[0];
|
|
15
|
+
return c && COMMANDS.includes(c) ? c : null;
|
|
16
|
+
}
|
|
17
|
+
export function currentRunId(cc) {
|
|
18
|
+
return String(cc.session_id ?? 'session');
|
|
19
|
+
}
|
|
20
|
+
/** Ingest one Claude Code hook payload. Must never throw — a hook error would break the agent. */
|
|
21
|
+
export function handleRecord(input) {
|
|
22
|
+
try {
|
|
23
|
+
const cc = JSON.parse(input);
|
|
24
|
+
appendEvent(currentRunId(cc), cc);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
/* swallow: recording is best-effort and must not disrupt the session */
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function handleInit() {
|
|
31
|
+
const settingsPath = join(process.cwd(), '.claude', 'settings.json');
|
|
32
|
+
const settings = existsSync(settingsPath) ? JSON.parse(readFileSync(settingsPath, 'utf8')) : {};
|
|
33
|
+
const merged = mergeHooks(settings, 'npx @sickr/replay');
|
|
34
|
+
mkdirSync(join(process.cwd(), '.claude'), { recursive: true });
|
|
35
|
+
writeFileSync(settingsPath, JSON.stringify(merged, null, 2) + '\n');
|
|
36
|
+
mkdirSync(runsDir(), { recursive: true });
|
|
37
|
+
process.stdout.write(`sickr: installed Claude Code hooks in ${settingsPath}\n` +
|
|
38
|
+
`Runs are recorded locally to ${runsDir()} (secrets redacted).\n` +
|
|
39
|
+
`Use Claude Code as normal, then: npx @sickr/replay open\n`);
|
|
40
|
+
}
|
|
41
|
+
function openInBrowser(file) {
|
|
42
|
+
const cmd = process.platform === 'win32' ? 'cmd' : process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
43
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', file] : [file];
|
|
44
|
+
try {
|
|
45
|
+
spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref();
|
|
46
|
+
}
|
|
47
|
+
catch { /* ignore */ }
|
|
48
|
+
}
|
|
49
|
+
function handleOpen(runId) {
|
|
50
|
+
const id = runId ?? latestRunId();
|
|
51
|
+
if (!id) {
|
|
52
|
+
process.stdout.write('sickr: no runs recorded yet. Run `npx @sickr/replay init`, then use Claude Code.\n');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const html = renderRunHtml(loadRun(id));
|
|
56
|
+
const out = join(homedir(), '.sickr', 'last.html');
|
|
57
|
+
mkdirSync(join(homedir(), '.sickr'), { recursive: true });
|
|
58
|
+
writeFileSync(out, html);
|
|
59
|
+
process.stdout.write(`sickr: opened replay for ${id} → ${out}\n`);
|
|
60
|
+
openInBrowser(out);
|
|
61
|
+
}
|
|
62
|
+
function handleList() {
|
|
63
|
+
const dir = runsDir();
|
|
64
|
+
const files = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.ndjson')) : [];
|
|
65
|
+
if (files.length === 0) {
|
|
66
|
+
process.stdout.write('sickr: no runs yet.\n');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
files
|
|
70
|
+
.sort((a, b) => statSync(join(dir, b)).mtimeMs - statSync(join(dir, a)).mtimeMs)
|
|
71
|
+
.forEach((f) => process.stdout.write(`${f.replace(/\.ndjson$/, '')}\t${statSync(join(dir, f)).mtime.toISOString()}\n`));
|
|
72
|
+
}
|
|
73
|
+
async function handleShare(runId, yes) {
|
|
74
|
+
const id = runId ?? latestRunId();
|
|
75
|
+
if (!id) {
|
|
76
|
+
process.stderr.write('sickr: no runs to share. Use Claude Code first.\n');
|
|
77
|
+
process.exit(1);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const run = loadRun(id);
|
|
81
|
+
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`);
|
|
85
|
+
if (!yes) {
|
|
86
|
+
if (!process.stdin.isTTY) {
|
|
87
|
+
process.stderr.write('sickr: re-run with --yes to publish non-interactively.\n');
|
|
88
|
+
process.exit(1);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
process.stdout.write('Publish this run publicly? [y/N] ');
|
|
92
|
+
const answer = await new Promise((resolve) => {
|
|
93
|
+
process.stdin.once('data', (d) => resolve(d.toString().trim().toLowerCase()));
|
|
94
|
+
});
|
|
95
|
+
if (answer !== 'y' && answer !== 'yes') {
|
|
96
|
+
process.stdout.write('sickr: cancelled.\n');
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const { url } = await publish(payload, REPLAY_ENDPOINT);
|
|
101
|
+
process.stdout.write(`sickr: published → ${url}\n`);
|
|
102
|
+
}
|
|
103
|
+
async function readStdin() {
|
|
104
|
+
const chunks = [];
|
|
105
|
+
for await (const chunk of process.stdin)
|
|
106
|
+
chunks.push(chunk);
|
|
107
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
108
|
+
}
|
|
109
|
+
async function main() {
|
|
110
|
+
const argv = process.argv.slice(2);
|
|
111
|
+
const cmd = parseCommand(argv);
|
|
112
|
+
switch (cmd) {
|
|
113
|
+
case 'record':
|
|
114
|
+
handleRecord(await readStdin());
|
|
115
|
+
return;
|
|
116
|
+
case 'init':
|
|
117
|
+
handleInit();
|
|
118
|
+
return;
|
|
119
|
+
case 'open':
|
|
120
|
+
handleOpen(argv[1]);
|
|
121
|
+
return;
|
|
122
|
+
case 'list':
|
|
123
|
+
handleList();
|
|
124
|
+
return;
|
|
125
|
+
case 'share': {
|
|
126
|
+
const rest = argv.slice(1);
|
|
127
|
+
await handleShare(rest.find((a) => !a.startsWith('-')), rest.includes('--yes'));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
default:
|
|
131
|
+
process.stderr.write('usage: npx @sickr/replay <init|record|open|list|share>\n');
|
|
132
|
+
process.exit(argv[0] ? 1 : 0);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
136
|
+
if (invokedDirectly)
|
|
137
|
+
void main();
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop'];
|
|
2
|
+
const TAG = '@sickr/replay record';
|
|
3
|
+
/**
|
|
4
|
+
* Merge the SICKR recording hooks into a Claude Code settings object.
|
|
5
|
+
* Idempotent — re-running never duplicates the SICKR hook. Preserves any
|
|
6
|
+
* existing unrelated hooks.
|
|
7
|
+
*/
|
|
8
|
+
export function mergeHooks(settings, binPath) {
|
|
9
|
+
const next = { ...(settings ?? {}) };
|
|
10
|
+
next.hooks = { ...(next.hooks ?? {}) };
|
|
11
|
+
for (const ev of EVENTS) {
|
|
12
|
+
const groups = Array.isArray(next.hooks[ev]) ? [...next.hooks[ev]] : [];
|
|
13
|
+
const present = JSON.stringify(groups).includes(TAG);
|
|
14
|
+
if (!present)
|
|
15
|
+
groups.push({ hooks: [{ type: 'command', command: `${binPath} record` }] });
|
|
16
|
+
next.hooks[ev] = groups;
|
|
17
|
+
}
|
|
18
|
+
return next;
|
|
19
|
+
}
|
package/dist/recorder.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { mkdirSync, appendFileSync, readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { redact } from './redact.js';
|
|
5
|
+
export function runsDir() {
|
|
6
|
+
return join(homedir(), '.sickr', 'runs');
|
|
7
|
+
}
|
|
8
|
+
/** Map one Claude Code hook payload to a redacted run event. */
|
|
9
|
+
export function mapEvent(cc, now = new Date()) {
|
|
10
|
+
const at = now.toISOString();
|
|
11
|
+
const name = String(cc.hook_event_name ?? '');
|
|
12
|
+
switch (name) {
|
|
13
|
+
case 'SessionStart':
|
|
14
|
+
return { kind: 'start', label: 'Session', detail: redact(String(cc.cwd ?? '')), at };
|
|
15
|
+
case 'UserPromptSubmit':
|
|
16
|
+
return { kind: 'prompt', label: 'Prompt', detail: redact(String(cc.prompt ?? '')).slice(0, 400), at };
|
|
17
|
+
case 'Stop':
|
|
18
|
+
return { kind: 'stop', label: 'Stop', detail: '', at };
|
|
19
|
+
case 'PreToolUse':
|
|
20
|
+
case 'PostToolUse': {
|
|
21
|
+
const tool = String(cc.tool_name ?? 'tool');
|
|
22
|
+
const input = (cc.tool_input ?? {});
|
|
23
|
+
const raw = String(input.command ?? input.file_path ?? input.path ?? JSON.stringify(input));
|
|
24
|
+
return { kind: 'tool', label: tool, detail: redact(raw).slice(0, 400), at };
|
|
25
|
+
}
|
|
26
|
+
default:
|
|
27
|
+
return { kind: 'tool', label: name || 'event', detail: '', at };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function appendEvent(runId, cc) {
|
|
31
|
+
const dir = runsDir();
|
|
32
|
+
mkdirSync(dir, { recursive: true });
|
|
33
|
+
appendFileSync(join(dir, `${runId}.ndjson`), JSON.stringify(mapEvent(cc)) + '\n');
|
|
34
|
+
}
|
|
35
|
+
export function loadRun(runId) {
|
|
36
|
+
const file = join(runsDir(), `${runId}.ndjson`);
|
|
37
|
+
const events = existsSync(file)
|
|
38
|
+
? readFileSync(file, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l))
|
|
39
|
+
: [];
|
|
40
|
+
return {
|
|
41
|
+
id: runId,
|
|
42
|
+
cwd: events.find((e) => e.kind === 'start')?.detail ?? '',
|
|
43
|
+
startedAt: events[0]?.at ?? '',
|
|
44
|
+
events,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** Most recently modified run id, or null if none. */
|
|
48
|
+
export function latestRunId() {
|
|
49
|
+
const dir = runsDir();
|
|
50
|
+
if (!existsSync(dir))
|
|
51
|
+
return null;
|
|
52
|
+
const files = readdirSync(dir).filter((f) => f.endsWith('.ndjson'));
|
|
53
|
+
if (files.length === 0)
|
|
54
|
+
return null;
|
|
55
|
+
files.sort((a, b) => statSync(join(dir, b)).mtimeMs - statSync(join(dir, a)).mtimeMs);
|
|
56
|
+
return files[0].replace(/\.ndjson$/, '');
|
|
57
|
+
}
|
package/dist/redact.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const MASK = '‹redacted›'; // ‹redacted›
|
|
2
|
+
// Assignment of a secret-ish-named var: KEY=..., TOKEN: "...", etc.
|
|
3
|
+
const ASSIGN = /\b([A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL)[A-Z0-9_]*)\s*[:=]\s*("?)([^\s"']+)\2/g;
|
|
4
|
+
// Standalone secret shapes.
|
|
5
|
+
const SHAPES = [
|
|
6
|
+
/\bBearer\s+[A-Za-z0-9._\-]+/g,
|
|
7
|
+
/\bsk-[A-Za-z0-9]{16,}/g,
|
|
8
|
+
/\bghp_[A-Za-z0-9]{20,}/g,
|
|
9
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}/g,
|
|
10
|
+
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
11
|
+
/\beyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+/g, // JWT
|
|
12
|
+
];
|
|
13
|
+
/** Mask secrets before anything is written to disk or shared. */
|
|
14
|
+
export function redact(input) {
|
|
15
|
+
let out = input.replace(ASSIGN, (_m, key) => `${key}=${MASK}`);
|
|
16
|
+
for (const re of SHAPES)
|
|
17
|
+
out = out.replace(re, MASK);
|
|
18
|
+
return out;
|
|
19
|
+
}
|
package/dist/render.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const PLASMA = '#34e0ff';
|
|
2
|
+
function esc(s) {
|
|
3
|
+
return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
4
|
+
}
|
|
5
|
+
function dot(kind) {
|
|
6
|
+
return kind === 'tool' ? PLASMA : kind === 'prompt' ? '#8b95a7' : '#5f6b80';
|
|
7
|
+
}
|
|
8
|
+
function row(e) {
|
|
9
|
+
const time = e.at ? esc(e.at.replace('T', ' ').slice(0, 19) + 'Z') : '';
|
|
10
|
+
return `<li>
|
|
11
|
+
<span class="dot" style="background:${dot(e.kind)}"></span>
|
|
12
|
+
<div><span class="lbl">${esc(e.label)}</span> <span class="kind">${esc(e.kind)}</span>
|
|
13
|
+
${e.detail ? `<div class="detail">${esc(e.detail)}</div>` : ''}
|
|
14
|
+
<div class="time">${time}</div></div>
|
|
15
|
+
</li>`;
|
|
16
|
+
}
|
|
17
|
+
/** Self-contained, redacted-in audit-timeline HTML for one local run. */
|
|
18
|
+
export function renderRunHtml(run) {
|
|
19
|
+
const events = run.events.map(row).join('\n');
|
|
20
|
+
return `<!doctype html>
|
|
21
|
+
<html lang="en"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
|
|
22
|
+
<title>SICKR Replay — ${esc(run.id)}</title>
|
|
23
|
+
<style>
|
|
24
|
+
body{margin:0;background:#06080d;color:#e7ecf3;font-family:Sora,system-ui,Arial,sans-serif;padding:40px 20px}
|
|
25
|
+
.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}
|
|
28
|
+
ol{list-style:none;margin:0;padding:0;position:relative}
|
|
29
|
+
ol::before{content:"";position:absolute;left:6px;top:6px;bottom:6px;width:1px;background:linear-gradient(${PLASMA},transparent)}
|
|
30
|
+
li{position:relative;padding:0 0 18px 28px}
|
|
31
|
+
.dot{position:absolute;left:0;top:3px;width:13px;height:13px;border-radius:50%}
|
|
32
|
+
.lbl{font-family:"Chakra Petch",sans-serif;font-weight:600;color:#fff}
|
|
33
|
+
.kind{font-family:"JetBrains Mono",monospace;font-size:10px;text-transform:uppercase;letter-spacing:.12em;color:${PLASMA};margin-left:6px}
|
|
34
|
+
.detail{font-family:"JetBrains Mono",monospace;font-size:12.5px;color:#cdd5e1;margin-top:4px;white-space:pre-wrap;word-break:break-word}
|
|
35
|
+
.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}
|
|
38
|
+
</style></head>
|
|
39
|
+
<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>
|
|
42
|
+
<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>
|
|
44
|
+
</div></body></html>`;
|
|
45
|
+
}
|
package/dist/share.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Strip the local id; events are already redacted at capture time. */
|
|
2
|
+
export function buildSharePayload(run) {
|
|
3
|
+
return { run: { cwd: run.cwd, startedAt: run.startedAt, events: run.events } };
|
|
4
|
+
}
|
|
5
|
+
export async function publish(payload, endpoint) {
|
|
6
|
+
const res = await fetch(endpoint, {
|
|
7
|
+
method: 'POST',
|
|
8
|
+
headers: { 'Content-Type': 'application/json' },
|
|
9
|
+
body: JSON.stringify(payload),
|
|
10
|
+
});
|
|
11
|
+
if (!res.ok)
|
|
12
|
+
throw new Error(`publish failed: ${res.status}`);
|
|
13
|
+
return (await res.json());
|
|
14
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sickr/replay",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "npx @sickr/replay — local Claude Code audit + one-click share. The free wedge into SICKR.",
|
|
6
|
+
"bin": { "replay": "dist/cli.js" },
|
|
7
|
+
"files": ["dist"],
|
|
8
|
+
"publishConfig": { "access": "public" },
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"test": "vitest run",
|
|
12
|
+
"dev": "tsc -w"
|
|
13
|
+
},
|
|
14
|
+
"engines": { "node": ">=20" },
|
|
15
|
+
"license": "UNLICENSED",
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@types/node": "^20.14.0",
|
|
18
|
+
"typescript": "^5.6.2",
|
|
19
|
+
"vitest": "^2.1.1"
|
|
20
|
+
}
|
|
21
|
+
}
|