@sickr/replay 0.6.0 → 0.8.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/agentAuth.js +76 -0
- package/dist/cli.js +362 -71
- package/dist/share.js +10 -6
- package/dist/ui.js +63 -0
- package/package.json +21 -21
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, chmodSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
export const AGENT_API_URL = (process.env.SICKR_API_URL ?? 'https://support-backend.sickr.ai').replace(/\/+$/, '');
|
|
5
|
+
function agentCredsPath() {
|
|
6
|
+
return join(homedir(), '.sickr', 'agent.json');
|
|
7
|
+
}
|
|
8
|
+
export function readAgentCredentials() {
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(readFileSync(agentCredsPath(), 'utf8'));
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function writeAgentCredentials(c) {
|
|
17
|
+
const dir = join(homedir(), '.sickr');
|
|
18
|
+
mkdirSync(dir, { recursive: true });
|
|
19
|
+
const p = agentCredsPath();
|
|
20
|
+
writeFileSync(p, JSON.stringify(c, null, 2) + '\n');
|
|
21
|
+
try {
|
|
22
|
+
chmodSync(p, 0o600);
|
|
23
|
+
}
|
|
24
|
+
catch { /* Windows: skip */ }
|
|
25
|
+
}
|
|
26
|
+
export function clearAgentCredentials() {
|
|
27
|
+
try {
|
|
28
|
+
unlinkSync(agentCredsPath());
|
|
29
|
+
}
|
|
30
|
+
catch { /* none */ }
|
|
31
|
+
}
|
|
32
|
+
export async function startAgentConnect(apiUrl, agentId) {
|
|
33
|
+
const r = await fetch(`${apiUrl.replace(/\/+$/, '')}/agent-connect/start`, {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: { 'Content-Type': 'application/json' },
|
|
36
|
+
body: JSON.stringify({ agent_id: agentId }),
|
|
37
|
+
});
|
|
38
|
+
if (!r.ok)
|
|
39
|
+
throw new Error(`agent-connect/start failed: ${r.status}`);
|
|
40
|
+
return await r.json();
|
|
41
|
+
}
|
|
42
|
+
export async function pollAgentConnect(apiUrl, deviceCode) {
|
|
43
|
+
const r = await fetch(`${apiUrl.replace(/\/+$/, '')}/agent-connect/poll`, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: { 'Content-Type': 'application/json' },
|
|
46
|
+
body: JSON.stringify({ device_code: deviceCode }),
|
|
47
|
+
});
|
|
48
|
+
if (!r.ok)
|
|
49
|
+
throw new Error(`agent-connect/poll failed: ${r.status}`);
|
|
50
|
+
return await r.json();
|
|
51
|
+
}
|
|
52
|
+
export async function fetchAgentStatus(c) {
|
|
53
|
+
const r = await fetch(`${c.api_url.replace(/\/+$/, '')}/agent/self/status`, {
|
|
54
|
+
headers: { Authorization: `Bearer ${c.api_key}` },
|
|
55
|
+
});
|
|
56
|
+
if (!r.ok)
|
|
57
|
+
throw new Error(`agent/self/status failed: ${r.status}`);
|
|
58
|
+
return await r.json();
|
|
59
|
+
}
|
|
60
|
+
export async function rotateAgentKey(c) {
|
|
61
|
+
const r = await fetch(`${c.api_url.replace(/\/+$/, '')}/agent/self/rotate`, {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers: { Authorization: `Bearer ${c.api_key}` },
|
|
64
|
+
});
|
|
65
|
+
if (!r.ok)
|
|
66
|
+
throw new Error(`agent/self/rotate failed: ${r.status}`);
|
|
67
|
+
return await r.json();
|
|
68
|
+
}
|
|
69
|
+
export async function disconnectAgent(c) {
|
|
70
|
+
const r = await fetch(`${c.api_url.replace(/\/+$/, '')}/agent/self/disconnect`, {
|
|
71
|
+
method: 'POST',
|
|
72
|
+
headers: { Authorization: `Bearer ${c.api_key}` },
|
|
73
|
+
});
|
|
74
|
+
if (!r.ok)
|
|
75
|
+
throw new Error(`agent/self/disconnect failed: ${r.status}`);
|
|
76
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -9,69 +9,78 @@ import { mergeHooks, removeHooks } from './hookConfig.js';
|
|
|
9
9
|
import { renderRunHtml, renderCombinedHtml } from './render.js';
|
|
10
10
|
import { buildSharePayload, buildCombinedPayload, publish, PublishError } from './share.js';
|
|
11
11
|
import { readCredentials, writeCredentials, clearCredentials, startDevice, pollDevice, sleep } from './auth.js';
|
|
12
|
+
import { ui, card, kv } from './ui.js';
|
|
13
|
+
import { AGENT_API_URL, clearAgentCredentials, disconnectAgent, fetchAgentStatus, pollAgentConnect, readAgentCredentials, rotateAgentKey, startAgentConnect, writeAgentCredentials, } from './agentAuth.js';
|
|
12
14
|
const REPLAY_ENDPOINT = process.env.SICKR_REPLAY_ENDPOINT ?? 'https://sickr.ai/api/replay';
|
|
13
|
-
const COMMANDS = ['init', 'record', 'open', 'list', 'share', 'stop', 'clear', 'login', 'logout', 'whoami', 'help'];
|
|
15
|
+
const COMMANDS = ['init', 'record', 'open', 'list', 'share', 'stop', 'clear', 'login', 'logout', 'whoami', 'agent', 'help'];
|
|
14
16
|
export function parseCommand(argv) {
|
|
15
17
|
const c = argv[0];
|
|
16
18
|
return c && COMMANDS.includes(c) ? c : null;
|
|
17
19
|
}
|
|
18
|
-
export const HELP = `SICKR Replay — audit & replay what your AI coding agent did.
|
|
19
|
-
|
|
20
|
-
Records your Claude Code or Codex session (prompts, edits, commands) to a local,
|
|
21
|
-
redacted timeline you can replay — and optionally share as a public link.
|
|
22
|
-
|
|
23
|
-
Why: a durable record of every agent action — a dashcam for your coding agent.
|
|
24
|
-
If your agent (Claude or Codex) loses context or can't reload a past chat, the
|
|
25
|
-
replay log helps you — and it — recall exactly what was just done.
|
|
26
|
-
|
|
27
|
-
Usage: npx @sickr/replay <command> [options]
|
|
28
|
-
|
|
29
|
-
Commands:
|
|
30
|
-
init <agent> Install recording hooks for an agent (REQUIRED — no default)
|
|
31
|
-
and start capturing to ~/.sickr/runs (secrets redacted):
|
|
32
|
-
claude Claude Code (.claude/settings.json)
|
|
33
|
-
codex Codex (.codex/hooks.json — needs Codex 0.133+)
|
|
34
|
-
all both of the above (feeds the combined view)
|
|
35
|
-
Flag: --no-name (label prompts "Human", not your login name)
|
|
36
|
-
open [run] Render a run to a local HTML timeline and open it. 100% local.
|
|
37
|
-
Defaults to the newest run; pass a run id, or --codex/--claude
|
|
38
|
-
for the newest run of that agent. Combine across agents with a
|
|
39
|
-
window: --today, --since <2h|30m|1d>, or --all (interleaved,
|
|
40
|
-
filterable by agent, sortable by prompt/response time).
|
|
41
|
-
share [run] Redact and publish ONE run to a public sickr.ai/r/<id> link
|
|
42
|
-
(shows a preview and asks first). Links expire after 24h.
|
|
43
|
-
--open also open the published link in your browser
|
|
44
|
-
--yes skip the confirmation prompt
|
|
45
|
-
Or publish a COMBINED multi-agent view with a window:
|
|
46
|
-
--today / --since <2h|30m|1d> / --all (+ --claude/--codex).
|
|
47
|
-
list List recorded runs, newest first.
|
|
48
|
-
stop Stop recording — removes SICKR's hooks from this project.
|
|
49
|
-
Your recorded runs are kept; run \`init\` to start again.
|
|
50
|
-
clear Delete all local runs in ~/.sickr/runs (asks first).
|
|
51
|
-
login Sign in with GitHub (optional — unlocks persistent shares and
|
|
52
|
-
Replay Pro cohort eligibility). Zero-account use still works.
|
|
53
|
-
logout Forget the local login. Server-side session stays valid until
|
|
54
|
-
it expires; revoke from your account page if needed.
|
|
55
|
-
whoami Show who you're logged in as.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
20
|
+
export const HELP = `SICKR Replay — audit & replay what your AI coding agent did.
|
|
21
|
+
|
|
22
|
+
Records your Claude Code or Codex session (prompts, edits, commands) to a local,
|
|
23
|
+
redacted timeline you can replay — and optionally share as a public link.
|
|
24
|
+
|
|
25
|
+
Why: a durable record of every agent action — a dashcam for your coding agent.
|
|
26
|
+
If your agent (Claude or Codex) loses context or can't reload a past chat, the
|
|
27
|
+
replay log helps you — and it — recall exactly what was just done.
|
|
28
|
+
|
|
29
|
+
Usage: npx @sickr/replay <command> [options]
|
|
30
|
+
|
|
31
|
+
Commands:
|
|
32
|
+
init <agent> Install recording hooks for an agent (REQUIRED — no default)
|
|
33
|
+
and start capturing to ~/.sickr/runs (secrets redacted):
|
|
34
|
+
claude Claude Code (.claude/settings.json)
|
|
35
|
+
codex Codex (.codex/hooks.json — needs Codex 0.133+)
|
|
36
|
+
all both of the above (feeds the combined view)
|
|
37
|
+
Flag: --no-name (label prompts "Human", not your login name)
|
|
38
|
+
open [run] Render a run to a local HTML timeline and open it. 100% local.
|
|
39
|
+
Defaults to the newest run; pass a run id, or --codex/--claude
|
|
40
|
+
for the newest run of that agent. Combine across agents with a
|
|
41
|
+
window: --today, --since <2h|30m|1d>, or --all (interleaved,
|
|
42
|
+
filterable by agent, sortable by prompt/response time).
|
|
43
|
+
share [run] Redact and publish ONE run to a public sickr.ai/r/<id> link
|
|
44
|
+
(shows a preview and asks first). Links expire after 24h.
|
|
45
|
+
--open also open the published link in your browser
|
|
46
|
+
--yes skip the confirmation prompt
|
|
47
|
+
Or publish a COMBINED multi-agent view with a window:
|
|
48
|
+
--today / --since <2h|30m|1d> / --all (+ --claude/--codex).
|
|
49
|
+
list List recorded runs, newest first.
|
|
50
|
+
stop Stop recording — removes SICKR's hooks from this project.
|
|
51
|
+
Your recorded runs are kept; run \`init\` to start again.
|
|
52
|
+
clear Delete all local runs in ~/.sickr/runs (asks first).
|
|
53
|
+
login Sign in with GitHub (optional — unlocks persistent shares and
|
|
54
|
+
Replay Pro cohort eligibility). Zero-account use still works.
|
|
55
|
+
logout Forget the local login. Server-side session stays valid until
|
|
56
|
+
it expires; revoke from your account page if needed.
|
|
57
|
+
whoami Show who you're logged in as.
|
|
58
|
+
agent connect --agent-id <id>
|
|
59
|
+
Connect this machine to a configured SICKR agent using GitHub
|
|
60
|
+
browser approval. Stores the agent key in ~/.sickr/agent.json.
|
|
61
|
+
agent status Show the connected agent, org and team.
|
|
62
|
+
agent rotate Rotate this machine's agent key.
|
|
63
|
+
agent disconnect
|
|
64
|
+
Revoke this machine's agent key and remove it locally.
|
|
65
|
+
help Show this help.
|
|
66
|
+
|
|
67
|
+
Requires Node 18+. Codex capture needs Codex CLI 0.133+ (run /hooks to trust);
|
|
68
|
+
Claude Code: any hooks-capable build.
|
|
69
|
+
|
|
70
|
+
────────────────────────────────────────────────────────────────────
|
|
71
|
+
This replays your AI coding agents on ONE machine. SICKR governs your whole team.
|
|
72
|
+
Issue tracking + your team + automation + agents — one governed workflow for
|
|
73
|
+
audit, accountability, productivity and confidence.
|
|
74
|
+
|
|
75
|
+
· Gates & approvals — work holds at plan sign-off, review, merge and
|
|
76
|
+
validation checks until each one passes.
|
|
77
|
+
· Humans + agents on one board — agents are first-class teammates with
|
|
78
|
+
roles, capacity and accountability, not a side channel.
|
|
79
|
+
· A full, signed-off audit trail across every actor and every change.
|
|
80
|
+
· Runs 24/7 — produce as much work as you like; the team handles it.
|
|
81
|
+
|
|
82
|
+
Free tier available · bring your own Claude or Codex subscription.
|
|
83
|
+
→ https://sickr.ai
|
|
75
84
|
`;
|
|
76
85
|
export function currentRunId(cc) {
|
|
77
86
|
return String(cc.session_id ?? 'session');
|
|
@@ -350,17 +359,24 @@ async function confirmPublish(yes, what) {
|
|
|
350
359
|
}
|
|
351
360
|
return true;
|
|
352
361
|
}
|
|
353
|
-
/** Publish with a single friendly retry on 429 (the WAF allows ~1/10s).
|
|
362
|
+
/** Publish with a single friendly retry on 429 (the WAF allows ~1/10s).
|
|
363
|
+
* If the user is logged in, attach their token so the share lands in the
|
|
364
|
+
* 7-day daily slot (free_authed) instead of the 24h anon dedup pool. */
|
|
354
365
|
async function publishWithRetry(payload) {
|
|
366
|
+
const token = readCredentials()?.token ?? null;
|
|
367
|
+
const post = async () => {
|
|
368
|
+
const r = await publish(payload, REPLAY_ENDPOINT, { token });
|
|
369
|
+
return { url: r.url, ttl_days: r.ttl_days ?? 1 };
|
|
370
|
+
};
|
|
355
371
|
try {
|
|
356
|
-
return
|
|
372
|
+
return await post();
|
|
357
373
|
}
|
|
358
374
|
catch (err) {
|
|
359
375
|
if (err instanceof PublishError && err.status === 429) {
|
|
360
376
|
process.stdout.write('sickr: rate-limited — you can publish about once every 10s. Waiting to retry once...\n');
|
|
361
377
|
await new Promise((r) => setTimeout(r, 11_000));
|
|
362
378
|
try {
|
|
363
|
-
return
|
|
379
|
+
return await post();
|
|
364
380
|
}
|
|
365
381
|
catch (retryErr) {
|
|
366
382
|
if (retryErr instanceof PublishError && retryErr.status === 429) {
|
|
@@ -373,6 +389,49 @@ async function publishWithRetry(payload) {
|
|
|
373
389
|
throw err;
|
|
374
390
|
}
|
|
375
391
|
}
|
|
392
|
+
function expiryCopy(ttl_days) {
|
|
393
|
+
if (ttl_days >= 30)
|
|
394
|
+
return {
|
|
395
|
+
kind: 'pro',
|
|
396
|
+
value: `${ttl_days} days`,
|
|
397
|
+
tag: 'Replay Pro',
|
|
398
|
+
footer: [],
|
|
399
|
+
};
|
|
400
|
+
if (ttl_days >= 2)
|
|
401
|
+
return {
|
|
402
|
+
kind: 'authed',
|
|
403
|
+
value: `in ${ttl_days} days`,
|
|
404
|
+
tag: 'signed-in link',
|
|
405
|
+
footer: ['re-share before it expires to roll the window forward.'],
|
|
406
|
+
};
|
|
407
|
+
return {
|
|
408
|
+
kind: 'anon',
|
|
409
|
+
value: 'in 24h',
|
|
410
|
+
tag: 'anon link',
|
|
411
|
+
footer: [
|
|
412
|
+
'run `npx @sickr/replay login` to extend new links to 7 days.',
|
|
413
|
+
'Replay Pro (live + remote) — early access, rolling cohorts:',
|
|
414
|
+
' https://sickr.ai/#waitlist',
|
|
415
|
+
],
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
function expiryValueStyled(e) {
|
|
419
|
+
if (e.kind === 'anon')
|
|
420
|
+
return ui.warn(e.value) + ' ' + ui.dim(ui.glyph.tag + ' ' + e.tag);
|
|
421
|
+
if (e.kind === 'pro')
|
|
422
|
+
return ui.brand(e.value) + ' ' + ui.accent(ui.bold(ui.glyph.tag + ' ' + e.tag));
|
|
423
|
+
return ui.brand(e.value) + ' ' + ui.dim(ui.glyph.tag + ' ' + e.tag);
|
|
424
|
+
}
|
|
425
|
+
function tipLine(text) {
|
|
426
|
+
return ' ' + ui.accent(ui.glyph.tip) + ' ' + ui.dim(text) + '\n';
|
|
427
|
+
}
|
|
428
|
+
function legacyExpiryLine(ttl_days) {
|
|
429
|
+
if (ttl_days >= 30)
|
|
430
|
+
return `sickr: this link is live for ${ttl_days} days (Replay Pro retention).\n`;
|
|
431
|
+
if (ttl_days >= 2)
|
|
432
|
+
return `sickr: this link is live for ${ttl_days} days — re-share before it expires to extend.\n`;
|
|
433
|
+
return `sickr: this link expires in 24h. Run \`replay login\` to extend to 7 days.\n`;
|
|
434
|
+
}
|
|
376
435
|
async function handleShare(runId, yes, open) {
|
|
377
436
|
const id = runId ?? latestRunId();
|
|
378
437
|
if (!id) {
|
|
@@ -380,13 +439,44 @@ async function handleShare(runId, yes, open) {
|
|
|
380
439
|
process.exit(1);
|
|
381
440
|
return;
|
|
382
441
|
}
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
|
|
442
|
+
const run = loadRun(id);
|
|
443
|
+
const payload = buildSharePayload(run);
|
|
444
|
+
const agentSet = Array.from(new Set(run.events.map((e) => e.label).filter((a) => a && a !== '—' && a !== 'Response')));
|
|
445
|
+
const agent = agentSet.length ? agentSet.join(', ') : 'Agent';
|
|
446
|
+
const target = REPLAY_ENDPOINT.replace(/^https?:\/\//, '');
|
|
447
|
+
if (ui.enabled()) {
|
|
448
|
+
process.stdout.write(card('publish preview', [
|
|
449
|
+
kv('run', ui.white(id)),
|
|
450
|
+
kv('agent', ui.white(agent)),
|
|
451
|
+
kv('events', ui.white(String(payload.run.events.length))),
|
|
452
|
+
kv('secrets', `redacted ${ui.ok(ui.glyph.check)}`),
|
|
453
|
+
kv('target', ui.white(target)),
|
|
454
|
+
]));
|
|
455
|
+
process.stdout.write(tipLine(`run \`npx @sickr/replay open ${id}\` to review locally first.`));
|
|
456
|
+
process.stdout.write('\n');
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
process.stdout.write(`sickr: about to publish run "${id}" (${payload.run.events.length} events, secrets already redacted) to ${REPLAY_ENDPOINT}\n` +
|
|
460
|
+
`sickr: tip — run \`npx @sickr/replay open ${id}\` to review the full timeline locally before sharing.\n`);
|
|
461
|
+
}
|
|
386
462
|
if (!(await confirmPublish(yes, 'this run')))
|
|
387
463
|
return;
|
|
388
|
-
const url = await publishWithRetry(payload);
|
|
389
|
-
|
|
464
|
+
const { url, ttl_days } = await publishWithRetry(payload);
|
|
465
|
+
const exp = expiryCopy(ttl_days);
|
|
466
|
+
if (ui.enabled()) {
|
|
467
|
+
process.stdout.write(card('published', [
|
|
468
|
+
kv('url', ui.underline(ui.white(url))),
|
|
469
|
+
kv('run', ui.white(`${id} · ${payload.run.events.length} events`)),
|
|
470
|
+
kv(exp.kind === 'pro' ? 'retention' : 'expires', expiryValueStyled(exp)),
|
|
471
|
+
]));
|
|
472
|
+
for (const line of exp.footer)
|
|
473
|
+
process.stdout.write(tipLine(line));
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
process.stdout.write(`sickr: published → ${url}\n` +
|
|
477
|
+
legacyExpiryLine(ttl_days) +
|
|
478
|
+
(exp.kind === 'anon' ? `sickr: Replay Pro (live + remote) — early access, rolling out in cohorts → https://sickr.ai/#waitlist\n` : ''));
|
|
479
|
+
}
|
|
390
480
|
if (open)
|
|
391
481
|
openInBrowser(url);
|
|
392
482
|
}
|
|
@@ -400,14 +490,46 @@ async function handleShareCombined(sel, yes, open) {
|
|
|
400
490
|
return;
|
|
401
491
|
}
|
|
402
492
|
const turns = runs.reduce((n, r) => n + r.events.filter((e) => e.kind === 'prompt').length, 0);
|
|
403
|
-
|
|
493
|
+
// Drop the placeholder dash that runSummary returns when an agent label is
|
|
494
|
+
// missing — otherwise the join produces "across —, Codex, Claude".
|
|
495
|
+
const agentSet = Array.from(new Set(runs.map((r) => r.agent).filter((a) => a && a !== '—')));
|
|
496
|
+
const agents = agentSet.length ? agentSet.join(', ') : 'Agent';
|
|
404
497
|
const payload = buildCombinedPayload(runs, sel.label);
|
|
405
|
-
|
|
406
|
-
|
|
498
|
+
const target = REPLAY_ENDPOINT.replace(/^https?:\/\//, '');
|
|
499
|
+
if (ui.enabled()) {
|
|
500
|
+
process.stdout.write(card('publish preview', [
|
|
501
|
+
kv('scope', ui.white(sel.label)),
|
|
502
|
+
kv('runs', ui.white(String(runs.length))),
|
|
503
|
+
kv('turns', ui.white(`~${turns}`)),
|
|
504
|
+
kv('agents', ui.white(agents)),
|
|
505
|
+
kv('secrets', `redacted ${ui.ok(ui.glyph.check)}`),
|
|
506
|
+
kv('target', ui.white(target)),
|
|
507
|
+
]));
|
|
508
|
+
process.stdout.write(tipLine('run the matching `open` window to review locally first.'));
|
|
509
|
+
process.stdout.write('\n');
|
|
510
|
+
}
|
|
511
|
+
else {
|
|
512
|
+
process.stdout.write(`sickr: about to publish a combined replay (${sel.label}) — ${runs.length} runs, ~${turns} turns across ${agents}, secrets already redacted, to ${REPLAY_ENDPOINT}\n` +
|
|
513
|
+
`sickr: tip — run the matching \`open\` window to review locally before sharing.\n`);
|
|
514
|
+
}
|
|
407
515
|
if (!(await confirmPublish(yes, 'this combined replay')))
|
|
408
516
|
return;
|
|
409
|
-
const url = await publishWithRetry(payload);
|
|
410
|
-
|
|
517
|
+
const { url, ttl_days } = await publishWithRetry(payload);
|
|
518
|
+
const exp = expiryCopy(ttl_days);
|
|
519
|
+
if (ui.enabled()) {
|
|
520
|
+
process.stdout.write(card('published', [
|
|
521
|
+
kv('url', ui.underline(ui.white(url))),
|
|
522
|
+
kv('scope', ui.white(`${sel.label} · ${runs.length} runs · ~${turns} turns`)),
|
|
523
|
+
kv(exp.kind === 'pro' ? 'retention' : 'expires', expiryValueStyled(exp)),
|
|
524
|
+
]));
|
|
525
|
+
for (const line of exp.footer)
|
|
526
|
+
process.stdout.write(tipLine(line));
|
|
527
|
+
}
|
|
528
|
+
else {
|
|
529
|
+
process.stdout.write(`sickr: published → ${url}\n` +
|
|
530
|
+
legacyExpiryLine(ttl_days) +
|
|
531
|
+
(exp.kind === 'anon' ? `sickr: Replay Pro (live + remote) — early access, rolling out in cohorts → https://sickr.ai/#waitlist\n` : ''));
|
|
532
|
+
}
|
|
411
533
|
if (open)
|
|
412
534
|
openInBrowser(url);
|
|
413
535
|
}
|
|
@@ -498,6 +620,152 @@ export function handleWhoami() {
|
|
|
498
620
|
}
|
|
499
621
|
process.stdout.write(`sickr: ${c.login}${c.name ? ` (${c.name})` : ''} · since ${c.login_at}\n`);
|
|
500
622
|
}
|
|
623
|
+
function valueAt(rest, flag) {
|
|
624
|
+
const i = rest.indexOf(flag);
|
|
625
|
+
return i >= 0 && rest[i + 1] && !rest[i + 1].startsWith('-') ? rest[i + 1] : null;
|
|
626
|
+
}
|
|
627
|
+
function isRecord(value) {
|
|
628
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
629
|
+
}
|
|
630
|
+
function agentContextLabel(context) {
|
|
631
|
+
const root = isRecord(context) ? context : {};
|
|
632
|
+
const agent = isRecord(root.agent) ? root.agent : {};
|
|
633
|
+
const org = isRecord(root.org) ? root.org : {};
|
|
634
|
+
const team = isRecord(root.team) ? root.team : {};
|
|
635
|
+
return {
|
|
636
|
+
agentId: typeof agent.agent_id === 'string' ? agent.agent_id : 'unknown',
|
|
637
|
+
provider: typeof agent.provider === 'string' ? agent.provider : 'unknown',
|
|
638
|
+
status: typeof agent.status === 'string' ? agent.status : 'unknown',
|
|
639
|
+
org: typeof org.name === 'string' ? org.name : 'unknown org',
|
|
640
|
+
team: typeof team.name === 'string' ? team.name : 'unknown team',
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
export async function handleAgentStatus() {
|
|
644
|
+
const c = readAgentCredentials();
|
|
645
|
+
if (!c) {
|
|
646
|
+
process.stdout.write('sickr: agent is not connected. Run `sickr agent connect --agent-id <id>`.\n');
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
try {
|
|
650
|
+
const label = agentContextLabel(await fetchAgentStatus(c));
|
|
651
|
+
process.stdout.write(`sickr: connected as ${label.agentId} (${label.provider}, ${label.status})\n` +
|
|
652
|
+
`org: ${label.org} team: ${label.team}\n` +
|
|
653
|
+
`api: ${c.api_url}\n`);
|
|
654
|
+
}
|
|
655
|
+
catch (e) {
|
|
656
|
+
process.stderr.write(`sickr: agent status failed (${e.message}).\n`);
|
|
657
|
+
process.exit(1);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
export async function handleAgentRotate() {
|
|
661
|
+
const c = readAgentCredentials();
|
|
662
|
+
if (!c) {
|
|
663
|
+
process.stderr.write('sickr: agent is not connected. Run `sickr agent connect --agent-id <id>`.\n');
|
|
664
|
+
process.exit(1);
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
try {
|
|
668
|
+
const rotated = await rotateAgentKey(c);
|
|
669
|
+
writeAgentCredentials({ ...c, api_key: rotated.api_key, key_id: rotated.key?.id ?? c.key_id });
|
|
670
|
+
process.stdout.write(`sickr: rotated key for ${c.agent_id}.\n`);
|
|
671
|
+
}
|
|
672
|
+
catch (e) {
|
|
673
|
+
process.stderr.write(`sickr: agent rotate failed (${e.message}).\n`);
|
|
674
|
+
process.exit(1);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
export async function handleAgentDisconnect() {
|
|
678
|
+
const c = readAgentCredentials();
|
|
679
|
+
if (!c) {
|
|
680
|
+
process.stdout.write('sickr: agent is not connected.\n');
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
try {
|
|
684
|
+
await disconnectAgent(c);
|
|
685
|
+
clearAgentCredentials();
|
|
686
|
+
process.stdout.write(`sickr: disconnected ${c.agent_id} and removed the local key.\n`);
|
|
687
|
+
}
|
|
688
|
+
catch (e) {
|
|
689
|
+
process.stderr.write(`sickr: agent disconnect failed (${e.message}).\n`);
|
|
690
|
+
process.exit(1);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
function storeApprovedAgent(apiUrl, result) {
|
|
694
|
+
writeAgentCredentials({
|
|
695
|
+
api_url: apiUrl,
|
|
696
|
+
agent_id: result.agent_id,
|
|
697
|
+
api_key: result.api_key,
|
|
698
|
+
key_id: result.key_id ?? null,
|
|
699
|
+
org_id: result.org_id,
|
|
700
|
+
team_id: result.team_id,
|
|
701
|
+
connected_at: new Date().toISOString(),
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
export async function handleAgentConnect(rest) {
|
|
705
|
+
const agentId = valueAt(rest, '--agent-id') ?? rest.find((a) => !a.startsWith('-')) ?? null;
|
|
706
|
+
if (!agentId) {
|
|
707
|
+
process.stderr.write('sickr: `agent connect` requires --agent-id <id>.\n');
|
|
708
|
+
process.exit(1);
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
const apiUrl = (valueAt(rest, '--api-url') ?? AGENT_API_URL).replace(/\/+$/, '');
|
|
712
|
+
let started;
|
|
713
|
+
try {
|
|
714
|
+
started = await startAgentConnect(apiUrl, agentId);
|
|
715
|
+
}
|
|
716
|
+
catch (e) {
|
|
717
|
+
process.stderr.write(`sickr: could not start agent connect (${e.message}).\n`);
|
|
718
|
+
process.exit(1);
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
const verifyUrl = started.verification_uri_complete ?? started.verification_uri;
|
|
722
|
+
process.stdout.write(`\nsickr: approve agent ${agentId} in your browser:\n\n` +
|
|
723
|
+
` ${verifyUrl}\n\n` +
|
|
724
|
+
`code: ${started.user_code}\n` +
|
|
725
|
+
`(opening browser...)\n`);
|
|
726
|
+
openInBrowser(verifyUrl);
|
|
727
|
+
const deadline = Date.now() + started.expires_in * 1000;
|
|
728
|
+
const intervalMs = Math.max(1, started.interval) * 1000;
|
|
729
|
+
while (Date.now() < deadline) {
|
|
730
|
+
await sleep(intervalMs);
|
|
731
|
+
let polled;
|
|
732
|
+
try {
|
|
733
|
+
polled = await pollAgentConnect(apiUrl, started.device_code);
|
|
734
|
+
}
|
|
735
|
+
catch (e) {
|
|
736
|
+
process.stderr.write(`sickr: agent connect poll failed (${e.message}).\n`);
|
|
737
|
+
process.exit(1);
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
if (polled.status === 'pending')
|
|
741
|
+
continue;
|
|
742
|
+
if (polled.status === 'approved') {
|
|
743
|
+
storeApprovedAgent(apiUrl, polled);
|
|
744
|
+
process.stdout.write(`\nsickr: connected ${polled.agent_id}. Run \`sickr agent status\` to verify.\n`);
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
if (polled.status === 'expired') {
|
|
748
|
+
process.stderr.write('sickr: agent connect code expired. Run `sickr agent connect` again.\n');
|
|
749
|
+
process.exit(1);
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
if (polled.status === 'denied') {
|
|
753
|
+
process.stderr.write('sickr: agent connect was denied.\n');
|
|
754
|
+
process.exit(1);
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
if (polled.status === 'consumed') {
|
|
758
|
+
process.stderr.write('sickr: agent connect code was already used. Run `sickr agent connect` again.\n');
|
|
759
|
+
process.exit(1);
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
process.stderr.write(`sickr: agent connect error: ${polled.error ?? 'unknown'}.\n`);
|
|
763
|
+
process.exit(1);
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
process.stderr.write('sickr: agent connect timed out. Run `sickr agent connect` again.\n');
|
|
767
|
+
process.exit(1);
|
|
768
|
+
}
|
|
501
769
|
async function readStdin() {
|
|
502
770
|
const chunks = [];
|
|
503
771
|
for await (const chunk of process.stdin)
|
|
@@ -563,6 +831,29 @@ async function main() {
|
|
|
563
831
|
case 'whoami':
|
|
564
832
|
handleWhoami();
|
|
565
833
|
return;
|
|
834
|
+
case 'agent': {
|
|
835
|
+
const sub = rest[0];
|
|
836
|
+
const agentRest = rest.slice(1);
|
|
837
|
+
if (sub === 'connect') {
|
|
838
|
+
await handleAgentConnect(agentRest);
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
if (sub === 'status') {
|
|
842
|
+
await handleAgentStatus();
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
if (sub === 'disconnect') {
|
|
846
|
+
await handleAgentDisconnect();
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
if (sub === 'rotate') {
|
|
850
|
+
await handleAgentRotate();
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
process.stderr.write('sickr: unknown agent command. Use `sickr agent connect|status|disconnect|rotate`.\n');
|
|
854
|
+
process.exit(1);
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
566
857
|
case 'share': {
|
|
567
858
|
const yes = rest.includes('--yes') || rest.includes('-y');
|
|
568
859
|
const openAfter = rest.includes('--open');
|
package/dist/share.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { gzipSync } from 'node:zlib';
|
|
1
2
|
/** Strip the local id; events are already redacted at capture time. */
|
|
2
3
|
export function buildSharePayload(run) {
|
|
3
4
|
return { run: { cwd: run.cwd, startedAt: run.startedAt, events: run.events } };
|
|
@@ -14,12 +15,15 @@ export class PublishError extends Error {
|
|
|
14
15
|
this.name = 'PublishError';
|
|
15
16
|
}
|
|
16
17
|
}
|
|
17
|
-
export async function publish(payload, endpoint) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
18
|
+
export async function publish(payload, endpoint, opts = {}) {
|
|
19
|
+
// Gzip the JSON body: ~5-10x smaller for typical sessions, comfortably fits
|
|
20
|
+
// the worker's 4 MB cap even for combined multi-day windows.
|
|
21
|
+
const raw = JSON.stringify(payload);
|
|
22
|
+
const gz = gzipSync(raw);
|
|
23
|
+
const headers = { 'Content-Type': 'application/json', 'Content-Encoding': 'gzip' };
|
|
24
|
+
if (opts.token)
|
|
25
|
+
headers.Authorization = `Bearer ${opts.token}`;
|
|
26
|
+
const res = await fetch(endpoint, { method: 'POST', headers, body: gz });
|
|
23
27
|
if (!res.ok)
|
|
24
28
|
throw new PublishError(res.status);
|
|
25
29
|
return (await res.json());
|
package/dist/ui.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Tiny inline ANSI + box helper. No deps — chalk/ora would balloon the install
|
|
2
|
+
// and clash with the "npx and forget" wedge. One place that decides when to
|
|
3
|
+
// down-grade to plain `sickr: ...` lines: noColor() — NO_COLOR / CI / non-TTY /
|
|
4
|
+
// dumb terminals / legacy cmd.exe. Every other code path goes through ui.enabled().
|
|
5
|
+
//
|
|
6
|
+
// Brand: Sickr wordmark cyan (51), plasma accent magenta (201), dim chrome
|
|
7
|
+
// gray (240/245), values bright white (255), amber (214) for anon-link
|
|
8
|
+
// urgency, green (42) for the redacted check. Matches the existing /r/<id>
|
|
9
|
+
// dark/plasma palette without hard-coding hex (terminals translate 256-color).
|
|
10
|
+
const ESC = '\x1b[';
|
|
11
|
+
function isStdoutTTY() {
|
|
12
|
+
return process.stdout && typeof process.stdout.isTTY === 'boolean' && process.stdout.isTTY;
|
|
13
|
+
}
|
|
14
|
+
function noColor() {
|
|
15
|
+
if (process.env.NO_COLOR != null || process.env.SICKR_NO_COLOR != null)
|
|
16
|
+
return true;
|
|
17
|
+
if (process.env.CI === 'true')
|
|
18
|
+
return true;
|
|
19
|
+
if (process.env.TERM === 'dumb')
|
|
20
|
+
return true;
|
|
21
|
+
// Legacy cmd.exe: claims TTY but mangles 256-color SGR. Modern Windows
|
|
22
|
+
// Terminal sets WT_SESSION; VS Code sets TERM_PROGRAM; both safe.
|
|
23
|
+
if (process.platform === 'win32' && !process.env.WT_SESSION && !process.env.TERM_PROGRAM)
|
|
24
|
+
return true;
|
|
25
|
+
return !isStdoutTTY();
|
|
26
|
+
}
|
|
27
|
+
const c = (code) => (s) => noColor() ? s : `${ESC}${code}m${s}${ESC}0m`;
|
|
28
|
+
const c256 = (n) => (s) => noColor() ? s : `${ESC}38;5;${n}m${s}${ESC}0m`;
|
|
29
|
+
export const ui = {
|
|
30
|
+
enabled() { return !noColor(); },
|
|
31
|
+
bold: c(1),
|
|
32
|
+
dim: c256(245),
|
|
33
|
+
border: c256(240),
|
|
34
|
+
brand: c256(51), // cyan — Sickr wordmark
|
|
35
|
+
accent: c256(201), // magenta — plasma glyphs / Pro tag
|
|
36
|
+
ok: c256(42), // green — redacted check
|
|
37
|
+
warn: c256(214), // amber — anon-link urgency
|
|
38
|
+
white: c256(255),
|
|
39
|
+
underline(s) { return noColor() ? s : `${ESC}4m${s}${ESC}24m`; },
|
|
40
|
+
glyph: { tip: '▸', check: '✓', tag: '⌗' },
|
|
41
|
+
};
|
|
42
|
+
function stripAnsi(s) { return s.replace(/\x1b\[[0-9;]*m/g, ''); }
|
|
43
|
+
/** "label value" with the label dim-gray padded to a fixed column. */
|
|
44
|
+
export function kv(label, value, labelWidth = 10) {
|
|
45
|
+
return ui.dim(label.padEnd(labelWidth)) + value;
|
|
46
|
+
}
|
|
47
|
+
/** Render a Sickr-titled box around pre-formatted rows. Caller should gate on
|
|
48
|
+
* ui.enabled() — in plain mode emit `sickr: ...` lines directly instead. */
|
|
49
|
+
export function card(title, rows) {
|
|
50
|
+
const widths = rows.map((r) => stripAnsi(r).length);
|
|
51
|
+
const inner = Math.max(56, ...widths) + 2; // +2 = one space pad each side inside the border
|
|
52
|
+
const dash = (n) => '─'.repeat(Math.max(1, n));
|
|
53
|
+
// Title bar: "╭─ sickr ─ <title> ─────────╮"
|
|
54
|
+
const titleTextLen = 'sickr '.length + 2 + title.length + 1; // "sickr " + "─ " + title + " "
|
|
55
|
+
const tailLen = Math.max(1, inner - titleTextLen - 1);
|
|
56
|
+
const top = ui.border('╭─ ') + ui.brand(ui.bold('sickr')) + ui.border(' ─ ') + ui.dim(title) + ' ' + ui.border(dash(tailLen) + '╮');
|
|
57
|
+
const bot = ui.border('╰' + dash(inner) + '╯');
|
|
58
|
+
const body = rows.map((r, i) => {
|
|
59
|
+
const pad = ' '.repeat(Math.max(0, inner - widths[i] - 2));
|
|
60
|
+
return ui.border('│ ') + r + pad + ui.border(' │');
|
|
61
|
+
}).join('\n');
|
|
62
|
+
return `${top}\n${body}\n${bot}\n`;
|
|
63
|
+
}
|
package/package.json
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@sickr/replay",
|
|
3
|
-
"version": "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
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@sickr/replay",
|
|
3
|
+
"version": "0.8.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", "sickr": "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
|
+
}
|