@sickr/cli 0.9.2
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/auth.js +52 -0
- package/dist/cli.js +1109 -0
- package/dist/hookConfig.js +49 -0
- package/dist/live.js +392 -0
- package/dist/recorder.js +154 -0
- package/dist/redact.js +132 -0
- package/dist/render.js +528 -0
- package/dist/share.js +30 -0
- package/dist/ui.js +63 -0
- package/package.json +22 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1109 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, unlinkSync } from 'node:fs';
|
|
4
|
+
import { homedir, userInfo } from 'node:os';
|
|
5
|
+
import { join, dirname } from 'node:path';
|
|
6
|
+
import { spawn, spawnSync, execFileSync } from 'node:child_process';
|
|
7
|
+
import { appendEvent, loadRun, runsDir, latestRunId } from './recorder.js';
|
|
8
|
+
import { mergeHooks, removeHooks } from './hookConfig.js';
|
|
9
|
+
import { renderRunHtml, renderCombinedHtml } from './render.js';
|
|
10
|
+
import { buildSharePayload, buildCombinedPayload, publish, PublishError } from './share.js';
|
|
11
|
+
import { AUTH_ENDPOINT, 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';
|
|
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'];
|
|
16
|
+
export function parseCommand(argv) {
|
|
17
|
+
const c = argv[0];
|
|
18
|
+
return c && COMMANDS.includes(c) ? c : null;
|
|
19
|
+
}
|
|
20
|
+
export function replaySubcommand(rest) {
|
|
21
|
+
const sub = rest.find((a) => !a.startsWith('-'));
|
|
22
|
+
if (!sub)
|
|
23
|
+
return 'auto';
|
|
24
|
+
if (sub === 'init' || sub === 'share' || sub === 'status' || sub === 'live' || sub === 'open' || sub === 'list' || sub === 'clear' || sub === 'stop') {
|
|
25
|
+
return sub;
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
export function workflowAgentAlias(rest) {
|
|
30
|
+
const sub = rest[0];
|
|
31
|
+
if (sub === 'connect' || sub === 'rotate' || sub === 'disconnect')
|
|
32
|
+
return [...rest];
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
function withoutFirst(rest) {
|
|
36
|
+
return rest.slice(1);
|
|
37
|
+
}
|
|
38
|
+
export function buildWorkflowInvocation(rest, command = 'uvx') {
|
|
39
|
+
const sub = rest[0] ?? 'status';
|
|
40
|
+
const tail = withoutFirst(rest);
|
|
41
|
+
if (sub === 'start') {
|
|
42
|
+
return { command, args: ['kindle', ...tail] };
|
|
43
|
+
}
|
|
44
|
+
if (sub === 'status') {
|
|
45
|
+
const hasRoot = tail.includes('--root-dir');
|
|
46
|
+
return { command, args: ['status', ...(hasRoot ? tail : [...tail, '--root-dir', '.'])] };
|
|
47
|
+
}
|
|
48
|
+
if (sub === 'stop') {
|
|
49
|
+
const hasRoot = tail.includes('--root-dir');
|
|
50
|
+
return { command, args: ['workflow', 'stop', ...(hasRoot ? tail : [...tail, '--root-dir', '.'])] };
|
|
51
|
+
}
|
|
52
|
+
return { command, args: rest };
|
|
53
|
+
}
|
|
54
|
+
export const HELP = `SICKR Replay — audit & replay what your AI coding agent did.
|
|
55
|
+
|
|
56
|
+
Records your Claude Code or Codex session (prompts, edits, commands) to a local,
|
|
57
|
+
redacted timeline you can replay — and optionally share as a public link.
|
|
58
|
+
|
|
59
|
+
Why: a durable record of every agent action — a dashcam for your coding agent.
|
|
60
|
+
If your agent (Claude or Codex) loses context or can't reload a past chat, the
|
|
61
|
+
replay log helps you — and it — recall exactly what was just done.
|
|
62
|
+
|
|
63
|
+
Usage: npx @sickr/cli <command> [options]
|
|
64
|
+
|
|
65
|
+
Commands:
|
|
66
|
+
replay Auto mode: installs Claude + Codex recording hooks. If the
|
|
67
|
+
signed-in user has Replay Pro, starts Live Look as well.
|
|
68
|
+
replay init all|claude|codex
|
|
69
|
+
Install recording hooks under the unified command name.
|
|
70
|
+
replay share Publish a redacted replay to sickr.ai/r/<id>.
|
|
71
|
+
replay status Show Live Look sidecar status.
|
|
72
|
+
workflow connect --agent-id <id>
|
|
73
|
+
Connect this machine to a configured SICKR workflow agent.
|
|
74
|
+
workflow start --agent-id <id>
|
|
75
|
+
Start the workflow orchestrator for that configured agent.
|
|
76
|
+
workflow status
|
|
77
|
+
Show running workflow daemon status.
|
|
78
|
+
workflow rotate | workflow disconnect
|
|
79
|
+
Rotate or revoke this machine's workflow agent key.
|
|
80
|
+
|
|
81
|
+
Legacy replay commands remain supported:
|
|
82
|
+
init <agent> Install recording hooks for an agent (REQUIRED — no default)
|
|
83
|
+
and start capturing to ~/.sickr/runs (secrets redacted):
|
|
84
|
+
claude Claude Code (.claude/settings.json)
|
|
85
|
+
codex Codex (.codex/hooks.json — needs Codex 0.133+)
|
|
86
|
+
all both of the above (feeds the combined view)
|
|
87
|
+
Flag: --no-name (label prompts "Human", not your login name)
|
|
88
|
+
open [run] Render a run to a local HTML timeline and open it. 100% local.
|
|
89
|
+
Defaults to the newest run; pass a run id, or --codex/--claude
|
|
90
|
+
for the newest run of that agent. Combine across agents with a
|
|
91
|
+
window: --today, --since <2h|30m|1d>, or --all (interleaved,
|
|
92
|
+
filterable by agent, sortable by prompt/response time).
|
|
93
|
+
share [run] Redact and publish ONE run to a public sickr.ai/r/<id> link
|
|
94
|
+
(shows a preview and asks first). Links expire after 24h.
|
|
95
|
+
--open also open the published link in your browser
|
|
96
|
+
--yes skip the confirmation prompt
|
|
97
|
+
Or publish a COMBINED multi-agent view with a window:
|
|
98
|
+
--today / --since <2h|30m|1d> / --all (+ --claude/--codex).
|
|
99
|
+
list List recorded runs, newest first.
|
|
100
|
+
stop Stop recording — removes SICKR's hooks from this project.
|
|
101
|
+
Your recorded runs are kept; run \`init\` to start again.
|
|
102
|
+
clear Delete all local runs in ~/.sickr/runs (asks first).
|
|
103
|
+
login Sign in with GitHub (optional — unlocks persistent shares and
|
|
104
|
+
Replay Pro cohort eligibility). Zero-account use still works.
|
|
105
|
+
logout Forget the local login. Server-side session stays valid until
|
|
106
|
+
it expires; revoke from your account page if needed.
|
|
107
|
+
whoami Show who you're logged in as.
|
|
108
|
+
live Replay Pro: stream the current session to sickr.ai/r/<your-link>
|
|
109
|
+
in real time. Requires \`login\` and Replay Pro entitlement.
|
|
110
|
+
replay live start the sidecar (foreground; ^C exits)
|
|
111
|
+
replay live status show pid + connection state
|
|
112
|
+
replay live stop stop the sidecar
|
|
113
|
+
While running, steer messages from the watching browser are
|
|
114
|
+
saved to ~/.sickr/inbox/<urlid>.md and printed in your terminal.
|
|
115
|
+
agent connect --agent-id <id>
|
|
116
|
+
Connect this machine to a configured SICKR agent using GitHub
|
|
117
|
+
browser approval. Stores the agent key in ~/.sickr/agent.json.
|
|
118
|
+
agent status Show the connected agent, org and team.
|
|
119
|
+
agent rotate Rotate this machine's agent key.
|
|
120
|
+
agent disconnect
|
|
121
|
+
Revoke this machine's agent key and remove it locally.
|
|
122
|
+
help Show this help.
|
|
123
|
+
|
|
124
|
+
Requires Node 18+. Codex capture needs Codex CLI 0.133+ (run /hooks to trust);
|
|
125
|
+
Claude Code: any hooks-capable build.
|
|
126
|
+
|
|
127
|
+
────────────────────────────────────────────────────────────────────
|
|
128
|
+
This replays your AI coding agents on ONE machine. SICKR governs your whole team.
|
|
129
|
+
Issue tracking + your team + automation + agents — one governed workflow for
|
|
130
|
+
audit, accountability, productivity and confidence.
|
|
131
|
+
|
|
132
|
+
· Gates & approvals — work holds at plan sign-off, review, merge and
|
|
133
|
+
validation checks until each one passes.
|
|
134
|
+
· Humans + agents on one board — agents are first-class teammates with
|
|
135
|
+
roles, capacity and accountability, not a side channel.
|
|
136
|
+
· A full, signed-off audit trail across every actor and every change.
|
|
137
|
+
· Runs 24/7 — produce as much work as you like; the team handles it.
|
|
138
|
+
|
|
139
|
+
Free tier available · bring your own Claude or Codex subscription.
|
|
140
|
+
→ https://sickr.ai
|
|
141
|
+
`;
|
|
142
|
+
export function currentRunId(cc) {
|
|
143
|
+
return String(cc.session_id ?? 'session');
|
|
144
|
+
}
|
|
145
|
+
const PROVIDERS = {
|
|
146
|
+
claude: { name: 'Claude Code', label: 'Claude', settingsPath: () => join(process.cwd(), '.claude', 'settings.json') },
|
|
147
|
+
codex: { name: 'Codex', label: 'Codex', settingsPath: () => join(process.cwd(), '.codex', 'hooks.json') },
|
|
148
|
+
};
|
|
149
|
+
function configPath() {
|
|
150
|
+
return join(homedir(), '.sickr', 'config.json');
|
|
151
|
+
}
|
|
152
|
+
/** The machine's own identity — git user.name, else OS username, else "Human". */
|
|
153
|
+
function loginName() {
|
|
154
|
+
try {
|
|
155
|
+
const n = execFileSync('git', ['config', 'user.name'], { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
156
|
+
if (n)
|
|
157
|
+
return n;
|
|
158
|
+
}
|
|
159
|
+
catch { /* git not configured */ }
|
|
160
|
+
try {
|
|
161
|
+
const u = userInfo().username;
|
|
162
|
+
if (u)
|
|
163
|
+
return u;
|
|
164
|
+
}
|
|
165
|
+
catch { /* no os user */ }
|
|
166
|
+
return 'Human';
|
|
167
|
+
}
|
|
168
|
+
/** Human label for prompts: the name stored at init (login name, or "Human" if anonymized). */
|
|
169
|
+
function resolveName() {
|
|
170
|
+
try {
|
|
171
|
+
const c = JSON.parse(readFileSync(configPath(), 'utf8'));
|
|
172
|
+
if (c.name)
|
|
173
|
+
return c.name;
|
|
174
|
+
}
|
|
175
|
+
catch { /* no config */ }
|
|
176
|
+
return 'Human';
|
|
177
|
+
}
|
|
178
|
+
/** Ingest one hook payload (Claude Code or Codex). Must never throw. */
|
|
179
|
+
export function handleRecord(input, provider = 'claude') {
|
|
180
|
+
try {
|
|
181
|
+
const cc = JSON.parse(input);
|
|
182
|
+
appendEvent(currentRunId(cc), cc, { human: resolveName(), agent: PROVIDERS[provider].label });
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
/* swallow: recording is best-effort and must not disrupt the session */
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
export function handleInit(provider, noName = false) {
|
|
189
|
+
const p = PROVIDERS[provider];
|
|
190
|
+
const settingsPath = p.settingsPath();
|
|
191
|
+
const settings = existsSync(settingsPath) ? JSON.parse(readFileSync(settingsPath, 'utf8')) : {};
|
|
192
|
+
const command = `npx @sickr/cli record${provider === 'codex' ? ' --codex' : ''}`;
|
|
193
|
+
// Remove any prior SICKR hook first, then install the current command — so
|
|
194
|
+
// re-running init (or a CLI upgrade that changes the command) self-heals
|
|
195
|
+
// instead of leaving a stale hook. Scoped to this provider's file.
|
|
196
|
+
const merged = mergeHooks(removeHooks(settings), command);
|
|
197
|
+
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
198
|
+
writeFileSync(settingsPath, JSON.stringify(merged, null, 2) + '\n');
|
|
199
|
+
mkdirSync(runsDir(), { recursive: true });
|
|
200
|
+
// Always (re)write the name so re-running init resets it — no arbitrary names
|
|
201
|
+
// (avoids impersonation on public shares); just the login name, or "Human".
|
|
202
|
+
const name = noName ? 'Human' : loginName();
|
|
203
|
+
writeFileSync(configPath(), JSON.stringify({ name }, null, 2) + '\n');
|
|
204
|
+
const labelLine = `Your prompts will be labelled "${name}"${noName ? '' : ' — run `init --no-name` to anonymize'}.\n`;
|
|
205
|
+
const nextSteps = provider === 'codex'
|
|
206
|
+
? 'Next: in Codex, run `/hooks` to review & trust these hooks (Codex gates new hooks),\nthen use Codex as normal and: npx @sickr/cli replay open --codex\n'
|
|
207
|
+
: 'Use Claude Code as normal, then: npx @sickr/cli replay open\n';
|
|
208
|
+
process.stdout.write(`sickr: installed ${p.name} recording hooks in ${settingsPath}\n` +
|
|
209
|
+
`Runs are recorded locally to ${runsDir()} (secrets redacted).\n` +
|
|
210
|
+
labelLine + nextSteps);
|
|
211
|
+
}
|
|
212
|
+
/** Stop recording: remove SICKR's hooks from this project (both providers), keep runs. */
|
|
213
|
+
export function handleStop() {
|
|
214
|
+
const targets = [PROVIDERS.claude.settingsPath(), PROVIDERS.codex.settingsPath()];
|
|
215
|
+
const cleaned = [];
|
|
216
|
+
for (const settingsPath of targets) {
|
|
217
|
+
if (!existsSync(settingsPath))
|
|
218
|
+
continue;
|
|
219
|
+
let settings;
|
|
220
|
+
try {
|
|
221
|
+
settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
process.stderr.write(`sickr: could not parse ${settingsPath}; left it unchanged.\n`);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
writeFileSync(settingsPath, JSON.stringify(removeHooks(settings), null, 2) + '\n');
|
|
228
|
+
cleaned.push(settingsPath);
|
|
229
|
+
}
|
|
230
|
+
if (cleaned.length === 0) {
|
|
231
|
+
process.stdout.write('sickr: no SICKR hooks found here — not recording in this project.\n');
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
process.stdout.write(`sickr: recording stopped — removed SICKR hooks from ${cleaned.join(', ')}.\n` +
|
|
235
|
+
'Your recorded runs are kept. Run `npx @sickr/cli replay init all` to start again.\n');
|
|
236
|
+
}
|
|
237
|
+
/** Delete all local runs. Destructive — confirms unless `yes` is set. */
|
|
238
|
+
export async function handleClear(yes) {
|
|
239
|
+
const dir = runsDir();
|
|
240
|
+
const files = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.ndjson')) : [];
|
|
241
|
+
if (files.length === 0) {
|
|
242
|
+
process.stdout.write('sickr: no local runs to clear.\n');
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (!yes) {
|
|
246
|
+
if (!process.stdin.isTTY) {
|
|
247
|
+
process.stderr.write(`sickr: ${files.length} run(s) in ${dir}. Re-run with --yes to delete them.\n`);
|
|
248
|
+
process.exit(1);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
process.stdout.write(`Delete ${files.length} recorded run(s) from ${dir}? This cannot be undone. [y/N] `);
|
|
252
|
+
const answer = await promptLine();
|
|
253
|
+
if (answer !== 'y' && answer !== 'yes') {
|
|
254
|
+
process.stdout.write('sickr: cancelled.\n');
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
for (const f of files)
|
|
259
|
+
unlinkSync(join(dir, f));
|
|
260
|
+
process.stdout.write(`sickr: cleared ${files.length} run(s).\n`);
|
|
261
|
+
}
|
|
262
|
+
function openInBrowser(file) {
|
|
263
|
+
const cmd = process.platform === 'win32' ? 'cmd' : process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
264
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', file] : [file];
|
|
265
|
+
try {
|
|
266
|
+
spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref();
|
|
267
|
+
}
|
|
268
|
+
catch { /* ignore */ }
|
|
269
|
+
}
|
|
270
|
+
/** A short, human-readable summary of a run: agent + first prompt + event count. */
|
|
271
|
+
function runSummary(id) {
|
|
272
|
+
const run = loadRun(id);
|
|
273
|
+
// Use the LATEST response label — long-lived runs may carry pre-provider
|
|
274
|
+
// ('Response') labels from older CLI versions in their early events.
|
|
275
|
+
const responses = run.events.filter((e) => e.kind === 'response');
|
|
276
|
+
const last = responses.length ? responses[responses.length - 1].label : '';
|
|
277
|
+
const agent = last && last !== 'Response' ? last : '—';
|
|
278
|
+
const prompt = (run.events.find((e) => e.kind === 'prompt')?.detail || '').replace(/\s+/g, ' ').trim();
|
|
279
|
+
return { agent, prompt, events: run.events.length };
|
|
280
|
+
}
|
|
281
|
+
/** Newest run whose agent (response label) matches `agent`, or null. */
|
|
282
|
+
export function latestRunIdFor(agent) {
|
|
283
|
+
const dir = runsDir();
|
|
284
|
+
if (!existsSync(dir))
|
|
285
|
+
return null;
|
|
286
|
+
const files = readdirSync(dir)
|
|
287
|
+
.filter((f) => f.endsWith('.ndjson'))
|
|
288
|
+
.sort((a, b) => statSync(join(dir, b)).mtimeMs - statSync(join(dir, a)).mtimeMs);
|
|
289
|
+
for (const f of files) {
|
|
290
|
+
const id = f.replace(/\.ndjson$/, '');
|
|
291
|
+
if (loadRun(id).events.some((e) => e.kind === 'response' && e.label === agent))
|
|
292
|
+
return id;
|
|
293
|
+
}
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
function handleOpen(runId, provider) {
|
|
297
|
+
let id = runId;
|
|
298
|
+
if (!id && provider) {
|
|
299
|
+
id = latestRunIdFor(PROVIDERS[provider].label) ?? undefined;
|
|
300
|
+
if (!id) {
|
|
301
|
+
process.stdout.write(`sickr: no ${PROVIDERS[provider].label} runs yet — use ${PROVIDERS[provider].name} with the hooks installed, then try again.\n`);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
id = id ?? latestRunId() ?? undefined;
|
|
306
|
+
if (!id) {
|
|
307
|
+
process.stdout.write('sickr: no runs recorded yet. Run `npx @sickr/cli replay init all`, then use Claude Code or Codex.\n');
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const html = renderRunHtml(loadRun(id));
|
|
311
|
+
const out = join(homedir(), '.sickr', 'last.html');
|
|
312
|
+
mkdirSync(join(homedir(), '.sickr'), { recursive: true });
|
|
313
|
+
writeFileSync(out, html);
|
|
314
|
+
const s = runSummary(id);
|
|
315
|
+
process.stdout.write(`sickr: opened ${s.agent} run ${id} · ${s.events} events${s.prompt ? ` · "${s.prompt.slice(0, 60)}"` : ''}\n` +
|
|
316
|
+
`→ ${out} (newest run; use \`list\` to see others, \`open <id>\` to pick one)\n`);
|
|
317
|
+
openInBrowser(out);
|
|
318
|
+
}
|
|
319
|
+
function parseDur(s) {
|
|
320
|
+
const m = /^(\d+)(m|h|d)$/.exec(String(s ?? ''));
|
|
321
|
+
if (!m)
|
|
322
|
+
return null;
|
|
323
|
+
return Number(m[1]) * (m[2] === 'm' ? 60_000 : m[2] === 'h' ? 3_600_000 : 86_400_000);
|
|
324
|
+
}
|
|
325
|
+
/** Select runs by window flag (--today/--since <dur>/--all) + optional agent. Null if no window flag. */
|
|
326
|
+
export function selectWindow(rest) {
|
|
327
|
+
let pred = null;
|
|
328
|
+
let label = '';
|
|
329
|
+
const now = Date.now();
|
|
330
|
+
if (rest.includes('--all')) {
|
|
331
|
+
pred = () => true;
|
|
332
|
+
label = 'all runs';
|
|
333
|
+
}
|
|
334
|
+
else if (rest.includes('--today')) {
|
|
335
|
+
const d = new Date();
|
|
336
|
+
d.setHours(0, 0, 0, 0);
|
|
337
|
+
const t = d.getTime();
|
|
338
|
+
pred = (m) => m >= t;
|
|
339
|
+
label = 'today';
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
const i = rest.indexOf('--since');
|
|
343
|
+
if (i >= 0) {
|
|
344
|
+
const ms = parseDur(rest[i + 1]);
|
|
345
|
+
if (ms == null) {
|
|
346
|
+
process.stderr.write('sickr: --since takes a duration like 2h, 30m, or 1d.\n');
|
|
347
|
+
process.exit(1);
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
pred = (m) => m >= now - ms;
|
|
351
|
+
label = `last ${rest[i + 1]}`;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (!pred)
|
|
355
|
+
return null;
|
|
356
|
+
const dir = runsDir();
|
|
357
|
+
const wantAgent = rest.includes('--codex') ? 'Codex' : rest.includes('--claude') ? 'Claude' : null;
|
|
358
|
+
let ids = existsSync(dir)
|
|
359
|
+
? readdirSync(dir).filter((f) => f.endsWith('.ndjson')).filter((f) => pred(statSync(join(dir, f)).mtimeMs)).map((f) => f.replace(/\.ndjson$/, ''))
|
|
360
|
+
: [];
|
|
361
|
+
if (wantAgent) {
|
|
362
|
+
ids = ids.filter((id) => runSummary(id).agent === wantAgent);
|
|
363
|
+
label += ` · ${wantAgent}`;
|
|
364
|
+
}
|
|
365
|
+
return { ids, label };
|
|
366
|
+
}
|
|
367
|
+
function handleOpenCombined(sel) {
|
|
368
|
+
const runs = sel.ids
|
|
369
|
+
.map((id) => ({ id, events: loadRun(id).events }))
|
|
370
|
+
.filter((r) => r.events.length)
|
|
371
|
+
.map((r) => ({ agent: runSummary(r.id).agent, events: r.events }));
|
|
372
|
+
if (runs.length === 0) {
|
|
373
|
+
process.stdout.write(`sickr: no runs in ${sel.label}.\n`);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const out = join(homedir(), '.sickr', 'last.html');
|
|
377
|
+
mkdirSync(join(homedir(), '.sickr'), { recursive: true });
|
|
378
|
+
writeFileSync(out, renderCombinedHtml(runs, sel.label));
|
|
379
|
+
process.stdout.write(`sickr: opened combined replay (${sel.label}) · ${runs.length} runs → ${out}\n`);
|
|
380
|
+
openInBrowser(out);
|
|
381
|
+
}
|
|
382
|
+
function handleList(provider) {
|
|
383
|
+
const dir = runsDir();
|
|
384
|
+
let files = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.ndjson')) : [];
|
|
385
|
+
if (provider) {
|
|
386
|
+
const want = PROVIDERS[provider].label;
|
|
387
|
+
files = files.filter((f) => loadRun(f.replace(/\.ndjson$/, '')).events.some((e) => e.kind === 'response' && e.label === want));
|
|
388
|
+
}
|
|
389
|
+
if (files.length === 0) {
|
|
390
|
+
process.stdout.write(provider ? `sickr: no ${PROVIDERS[provider].label} runs yet.\n` : 'sickr: no runs yet.\n');
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
files
|
|
394
|
+
.sort((a, b) => statSync(join(dir, b)).mtimeMs - statSync(join(dir, a)).mtimeMs)
|
|
395
|
+
.forEach((f) => {
|
|
396
|
+
const id = f.replace(/\.ndjson$/, '');
|
|
397
|
+
const s = runSummary(id);
|
|
398
|
+
const when = statSync(join(dir, f)).mtime.toISOString().replace('T', ' ').slice(0, 16);
|
|
399
|
+
const snippet = s.prompt ? ` "${s.prompt.slice(0, 48)}"` : '';
|
|
400
|
+
process.stdout.write(`${id} ${s.agent.padEnd(7)} ${String(s.events).padStart(4)} ev ${when}${snippet}\n`);
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
async function confirmPublish(yes, what) {
|
|
404
|
+
if (yes)
|
|
405
|
+
return true;
|
|
406
|
+
if (!process.stdin.isTTY) {
|
|
407
|
+
process.stderr.write('sickr: re-run with --yes to publish non-interactively.\n');
|
|
408
|
+
process.exit(1);
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
process.stdout.write(`Publish ${what} publicly? [y/N] `);
|
|
412
|
+
const a = await promptLine();
|
|
413
|
+
if (a !== 'y' && a !== 'yes') {
|
|
414
|
+
process.stdout.write('sickr: cancelled.\n');
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
return true;
|
|
418
|
+
}
|
|
419
|
+
/** Publish with a single friendly retry on 429 (the WAF allows ~1/10s).
|
|
420
|
+
* If the user is logged in, attach their token so the share lands in the
|
|
421
|
+
* 7-day daily slot (free_authed) instead of the 24h anon dedup pool. */
|
|
422
|
+
async function publishWithRetry(payload) {
|
|
423
|
+
const token = readCredentials()?.token ?? null;
|
|
424
|
+
const post = async () => {
|
|
425
|
+
const r = await publish(payload, REPLAY_ENDPOINT, { token });
|
|
426
|
+
return { url: r.url, ttl_days: r.ttl_days ?? 1 };
|
|
427
|
+
};
|
|
428
|
+
try {
|
|
429
|
+
return await post();
|
|
430
|
+
}
|
|
431
|
+
catch (err) {
|
|
432
|
+
if (err instanceof PublishError && err.status === 429) {
|
|
433
|
+
process.stdout.write('sickr: rate-limited — you can publish about once every 10s. Waiting to retry once...\n');
|
|
434
|
+
await new Promise((r) => setTimeout(r, 11_000));
|
|
435
|
+
try {
|
|
436
|
+
return await post();
|
|
437
|
+
}
|
|
438
|
+
catch (retryErr) {
|
|
439
|
+
if (retryErr instanceof PublishError && retryErr.status === 429) {
|
|
440
|
+
process.stderr.write('sickr: still rate-limited. Give it a minute and run `share` again.\n');
|
|
441
|
+
process.exit(1);
|
|
442
|
+
}
|
|
443
|
+
throw retryErr;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
throw err;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
function expiryCopy(ttl_days) {
|
|
450
|
+
if (ttl_days >= 30)
|
|
451
|
+
return {
|
|
452
|
+
kind: 'pro',
|
|
453
|
+
value: `${ttl_days} days`,
|
|
454
|
+
tag: 'Replay Pro',
|
|
455
|
+
footer: [],
|
|
456
|
+
};
|
|
457
|
+
if (ttl_days >= 2)
|
|
458
|
+
return {
|
|
459
|
+
kind: 'authed',
|
|
460
|
+
value: `in ${ttl_days} days`,
|
|
461
|
+
tag: 'signed-in link',
|
|
462
|
+
footer: ['re-share before it expires to roll the window forward.'],
|
|
463
|
+
};
|
|
464
|
+
return {
|
|
465
|
+
kind: 'anon',
|
|
466
|
+
value: 'in 24h',
|
|
467
|
+
tag: 'anon link',
|
|
468
|
+
footer: [
|
|
469
|
+
'run `npx @sickr/cli login` to extend new links to 7 days.',
|
|
470
|
+
'Replay Pro (live + remote) — early access, rolling cohorts:',
|
|
471
|
+
' https://sickr.ai/#waitlist',
|
|
472
|
+
],
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
function expiryValueStyled(e) {
|
|
476
|
+
if (e.kind === 'anon')
|
|
477
|
+
return ui.warn(e.value) + ' ' + ui.dim(ui.glyph.tag + ' ' + e.tag);
|
|
478
|
+
if (e.kind === 'pro')
|
|
479
|
+
return ui.brand(e.value) + ' ' + ui.accent(ui.bold(ui.glyph.tag + ' ' + e.tag));
|
|
480
|
+
return ui.brand(e.value) + ' ' + ui.dim(ui.glyph.tag + ' ' + e.tag);
|
|
481
|
+
}
|
|
482
|
+
function tipLine(text) {
|
|
483
|
+
return ' ' + ui.accent(ui.glyph.tip) + ' ' + ui.dim(text) + '\n';
|
|
484
|
+
}
|
|
485
|
+
function legacyExpiryLine(ttl_days) {
|
|
486
|
+
if (ttl_days >= 30)
|
|
487
|
+
return `sickr: this link is live for ${ttl_days} days (Replay Pro retention).\n`;
|
|
488
|
+
if (ttl_days >= 2)
|
|
489
|
+
return `sickr: this link is live for ${ttl_days} days — re-share before it expires to extend.\n`;
|
|
490
|
+
return `sickr: this link expires in 24h. Run \`npx @sickr/cli login\` to extend to 7 days.\n`;
|
|
491
|
+
}
|
|
492
|
+
async function handleShare(runId, yes, open) {
|
|
493
|
+
const id = runId ?? latestRunId();
|
|
494
|
+
if (!id) {
|
|
495
|
+
process.stderr.write('sickr: no runs to share. Use Claude Code or Codex first.\n');
|
|
496
|
+
process.exit(1);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const run = loadRun(id);
|
|
500
|
+
const payload = buildSharePayload(run);
|
|
501
|
+
const agentSet = Array.from(new Set(run.events.map((e) => e.label).filter((a) => a && a !== '—' && a !== 'Response')));
|
|
502
|
+
const agent = agentSet.length ? agentSet.join(', ') : 'Agent';
|
|
503
|
+
const target = REPLAY_ENDPOINT.replace(/^https?:\/\//, '');
|
|
504
|
+
if (ui.enabled()) {
|
|
505
|
+
process.stdout.write(card('publish preview', [
|
|
506
|
+
kv('run', ui.white(id)),
|
|
507
|
+
kv('agent', ui.white(agent)),
|
|
508
|
+
kv('events', ui.white(String(payload.run.events.length))),
|
|
509
|
+
kv('secrets', `redacted ${ui.ok(ui.glyph.check)}`),
|
|
510
|
+
kv('target', ui.white(target)),
|
|
511
|
+
]));
|
|
512
|
+
process.stdout.write(tipLine(`run \`npx @sickr/cli replay open ${id}\` to review locally first.`));
|
|
513
|
+
process.stdout.write('\n');
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
process.stdout.write(`sickr: about to publish run "${id}" (${payload.run.events.length} events, secrets already redacted) to ${REPLAY_ENDPOINT}\n` +
|
|
517
|
+
`sickr: tip — run \`npx @sickr/cli replay open ${id}\` to review the full timeline locally before sharing.\n`);
|
|
518
|
+
}
|
|
519
|
+
if (!(await confirmPublish(yes, 'this run')))
|
|
520
|
+
return;
|
|
521
|
+
const { url, ttl_days } = await publishWithRetry(payload);
|
|
522
|
+
const exp = expiryCopy(ttl_days);
|
|
523
|
+
if (ui.enabled()) {
|
|
524
|
+
process.stdout.write(card('published', [
|
|
525
|
+
kv('url', ui.underline(ui.white(url))),
|
|
526
|
+
kv('run', ui.white(`${id} · ${payload.run.events.length} events`)),
|
|
527
|
+
kv(exp.kind === 'pro' ? 'retention' : 'expires', expiryValueStyled(exp)),
|
|
528
|
+
]));
|
|
529
|
+
for (const line of exp.footer)
|
|
530
|
+
process.stdout.write(tipLine(line));
|
|
531
|
+
}
|
|
532
|
+
else {
|
|
533
|
+
process.stdout.write(`sickr: published → ${url}\n` +
|
|
534
|
+
legacyExpiryLine(ttl_days) +
|
|
535
|
+
(exp.kind === 'anon' ? `sickr: Replay Pro (live + remote) — early access, rolling out in cohorts → https://sickr.ai/#waitlist\n` : ''));
|
|
536
|
+
}
|
|
537
|
+
if (open)
|
|
538
|
+
openInBrowser(url);
|
|
539
|
+
}
|
|
540
|
+
async function handleShareCombined(sel, yes, open) {
|
|
541
|
+
const runs = sel.ids
|
|
542
|
+
.map((id) => ({ id, events: loadRun(id).events }))
|
|
543
|
+
.filter((r) => r.events.length)
|
|
544
|
+
.map((r) => ({ agent: runSummary(r.id).agent, events: r.events }));
|
|
545
|
+
if (runs.length === 0) {
|
|
546
|
+
process.stdout.write(`sickr: no runs in ${sel.label} to share.\n`);
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
const turns = runs.reduce((n, r) => n + r.events.filter((e) => e.kind === 'prompt').length, 0);
|
|
550
|
+
// Drop the placeholder dash that runSummary returns when an agent label is
|
|
551
|
+
// missing — otherwise the join produces "across —, Codex, Claude".
|
|
552
|
+
const agentSet = Array.from(new Set(runs.map((r) => r.agent).filter((a) => a && a !== '—')));
|
|
553
|
+
const agents = agentSet.length ? agentSet.join(', ') : 'Agent';
|
|
554
|
+
const payload = buildCombinedPayload(runs, sel.label);
|
|
555
|
+
const target = REPLAY_ENDPOINT.replace(/^https?:\/\//, '');
|
|
556
|
+
if (ui.enabled()) {
|
|
557
|
+
process.stdout.write(card('publish preview', [
|
|
558
|
+
kv('scope', ui.white(sel.label)),
|
|
559
|
+
kv('runs', ui.white(String(runs.length))),
|
|
560
|
+
kv('turns', ui.white(`~${turns}`)),
|
|
561
|
+
kv('agents', ui.white(agents)),
|
|
562
|
+
kv('secrets', `redacted ${ui.ok(ui.glyph.check)}`),
|
|
563
|
+
kv('target', ui.white(target)),
|
|
564
|
+
]));
|
|
565
|
+
process.stdout.write(tipLine('run the matching `open` window to review locally first.'));
|
|
566
|
+
process.stdout.write('\n');
|
|
567
|
+
}
|
|
568
|
+
else {
|
|
569
|
+
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` +
|
|
570
|
+
`sickr: tip — run the matching \`open\` window to review locally before sharing.\n`);
|
|
571
|
+
}
|
|
572
|
+
if (!(await confirmPublish(yes, 'this combined replay')))
|
|
573
|
+
return;
|
|
574
|
+
const { url, ttl_days } = await publishWithRetry(payload);
|
|
575
|
+
const exp = expiryCopy(ttl_days);
|
|
576
|
+
if (ui.enabled()) {
|
|
577
|
+
process.stdout.write(card('published', [
|
|
578
|
+
kv('url', ui.underline(ui.white(url))),
|
|
579
|
+
kv('scope', ui.white(`${sel.label} · ${runs.length} runs · ~${turns} turns`)),
|
|
580
|
+
kv(exp.kind === 'pro' ? 'retention' : 'expires', expiryValueStyled(exp)),
|
|
581
|
+
]));
|
|
582
|
+
for (const line of exp.footer)
|
|
583
|
+
process.stdout.write(tipLine(line));
|
|
584
|
+
}
|
|
585
|
+
else {
|
|
586
|
+
process.stdout.write(`sickr: published → ${url}\n` +
|
|
587
|
+
legacyExpiryLine(ttl_days) +
|
|
588
|
+
(exp.kind === 'anon' ? `sickr: Replay Pro (live + remote) — early access, rolling out in cohorts → https://sickr.ai/#waitlist\n` : ''));
|
|
589
|
+
}
|
|
590
|
+
if (open)
|
|
591
|
+
openInBrowser(url);
|
|
592
|
+
}
|
|
593
|
+
/** Read a single line of input, then release stdin so the process can exit. */
|
|
594
|
+
async function promptLine() {
|
|
595
|
+
return new Promise((resolve) => {
|
|
596
|
+
process.stdin.once('data', (d) => {
|
|
597
|
+
process.stdin.pause(); // unref stdin — otherwise the event loop never drains and the CLI hangs
|
|
598
|
+
resolve(d.toString().trim().toLowerCase());
|
|
599
|
+
});
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
/** GitHub Device Flow login — optional, but unlocks persistent shares + Pro eligibility. */
|
|
603
|
+
export async function handleLogin() {
|
|
604
|
+
const existing = readCredentials();
|
|
605
|
+
if (existing) {
|
|
606
|
+
process.stdout.write(`sickr: already logged in as ${existing.login}. Run \`logout\` to switch accounts.\n`);
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
let device;
|
|
610
|
+
try {
|
|
611
|
+
device = await startDevice();
|
|
612
|
+
}
|
|
613
|
+
catch (e) {
|
|
614
|
+
process.stderr.write(`sickr: could not start GitHub login (${e.message}).\n`);
|
|
615
|
+
process.exit(1);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
const verifyUrl = device.verification_uri_complete ?? device.verification_uri;
|
|
619
|
+
process.stdout.write(`\nsickr: open ${device.verification_uri} in your browser and enter this code:\n\n` +
|
|
620
|
+
` ${device.user_code}\n\n` +
|
|
621
|
+
`(opening ${verifyUrl} for you …)\n`);
|
|
622
|
+
openInBrowser(verifyUrl);
|
|
623
|
+
const deadline = Date.now() + device.expires_in * 1000;
|
|
624
|
+
let intervalMs = Math.max(1, device.interval) * 1000;
|
|
625
|
+
while (Date.now() < deadline) {
|
|
626
|
+
await sleep(intervalMs);
|
|
627
|
+
let result;
|
|
628
|
+
try {
|
|
629
|
+
result = await pollDevice(device.device_code);
|
|
630
|
+
}
|
|
631
|
+
catch (e) {
|
|
632
|
+
process.stderr.write(`sickr: login poll failed (${e.message}).\n`);
|
|
633
|
+
process.exit(1);
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
if (result.status === 'success') {
|
|
637
|
+
writeCredentials({
|
|
638
|
+
token: result.token, login: result.login, github_user_id: result.github_user_id,
|
|
639
|
+
name: result.name ?? null, login_at: new Date().toISOString(),
|
|
640
|
+
});
|
|
641
|
+
process.stdout.write(`\nsickr: logged in as ${result.login}. Your shares are now persistent and claimable.\n`);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
if (result.status === 'pending')
|
|
645
|
+
continue;
|
|
646
|
+
if (result.status === 'slow_down') {
|
|
647
|
+
intervalMs += 5000;
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
if (result.status === 'expired') {
|
|
651
|
+
process.stderr.write('sickr: login code expired. Run `login` again.\n');
|
|
652
|
+
process.exit(1);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
if (result.status === 'denied') {
|
|
656
|
+
process.stderr.write('sickr: authorization denied.\n');
|
|
657
|
+
process.exit(1);
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
process.stderr.write(`sickr: login error: ${result.error ?? 'unknown'}.\n`);
|
|
661
|
+
process.exit(1);
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
process.stderr.write('sickr: login timed out. Run `login` again.\n');
|
|
665
|
+
process.exit(1);
|
|
666
|
+
}
|
|
667
|
+
export function handleLogout() {
|
|
668
|
+
const c = readCredentials();
|
|
669
|
+
clearCredentials();
|
|
670
|
+
process.stdout.write(c ? `sickr: logged out (was ${c.login}).\n` : 'sickr: not logged in.\n');
|
|
671
|
+
}
|
|
672
|
+
export function handleWhoami() {
|
|
673
|
+
const c = readCredentials();
|
|
674
|
+
if (!c) {
|
|
675
|
+
process.stdout.write('sickr: not logged in. Run `npx @sickr/cli login`.\n');
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
process.stdout.write(`sickr: ${c.login}${c.name ? ` (${c.name})` : ''} · since ${c.login_at}\n`);
|
|
679
|
+
}
|
|
680
|
+
function valueAt(rest, flag) {
|
|
681
|
+
const i = rest.indexOf(flag);
|
|
682
|
+
return i >= 0 && rest[i + 1] && !rest[i + 1].startsWith('-') ? rest[i + 1] : null;
|
|
683
|
+
}
|
|
684
|
+
function isRecord(value) {
|
|
685
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
686
|
+
}
|
|
687
|
+
function agentContextLabel(context) {
|
|
688
|
+
const root = isRecord(context) ? context : {};
|
|
689
|
+
const agent = isRecord(root.agent) ? root.agent : {};
|
|
690
|
+
const org = isRecord(root.org) ? root.org : {};
|
|
691
|
+
const team = isRecord(root.team) ? root.team : {};
|
|
692
|
+
return {
|
|
693
|
+
agentId: typeof agent.agent_id === 'string' ? agent.agent_id : 'unknown',
|
|
694
|
+
provider: typeof agent.provider === 'string' ? agent.provider : 'unknown',
|
|
695
|
+
status: typeof agent.status === 'string' ? agent.status : 'unknown',
|
|
696
|
+
org: typeof org.name === 'string' ? org.name : 'unknown org',
|
|
697
|
+
team: typeof team.name === 'string' ? team.name : 'unknown team',
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
export async function handleAgentStatus() {
|
|
701
|
+
const c = readAgentCredentials();
|
|
702
|
+
if (!c) {
|
|
703
|
+
process.stdout.write('sickr: agent is not connected. Run `sickr agent connect --agent-id <id>`.\n');
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
try {
|
|
707
|
+
const label = agentContextLabel(await fetchAgentStatus(c));
|
|
708
|
+
process.stdout.write(`sickr: connected as ${label.agentId} (${label.provider}, ${label.status})\n` +
|
|
709
|
+
`org: ${label.org} team: ${label.team}\n` +
|
|
710
|
+
`api: ${c.api_url}\n`);
|
|
711
|
+
}
|
|
712
|
+
catch (e) {
|
|
713
|
+
process.stderr.write(`sickr: agent status failed (${e.message}).\n`);
|
|
714
|
+
process.exit(1);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
export async function handleAgentRotate() {
|
|
718
|
+
const c = readAgentCredentials();
|
|
719
|
+
if (!c) {
|
|
720
|
+
process.stderr.write('sickr: agent is not connected. Run `sickr agent connect --agent-id <id>`.\n');
|
|
721
|
+
process.exit(1);
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
try {
|
|
725
|
+
const rotated = await rotateAgentKey(c);
|
|
726
|
+
writeAgentCredentials({ ...c, api_key: rotated.api_key, key_id: rotated.key?.id ?? c.key_id });
|
|
727
|
+
process.stdout.write(`sickr: rotated key for ${c.agent_id}.\n`);
|
|
728
|
+
}
|
|
729
|
+
catch (e) {
|
|
730
|
+
process.stderr.write(`sickr: agent rotate failed (${e.message}).\n`);
|
|
731
|
+
process.exit(1);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
export async function handleAgentDisconnect() {
|
|
735
|
+
const c = readAgentCredentials();
|
|
736
|
+
if (!c) {
|
|
737
|
+
process.stdout.write('sickr: agent is not connected.\n');
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
try {
|
|
741
|
+
await disconnectAgent(c);
|
|
742
|
+
clearAgentCredentials();
|
|
743
|
+
process.stdout.write(`sickr: disconnected ${c.agent_id} and removed the local key.\n`);
|
|
744
|
+
}
|
|
745
|
+
catch (e) {
|
|
746
|
+
process.stderr.write(`sickr: agent disconnect failed (${e.message}).\n`);
|
|
747
|
+
process.exit(1);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
function storeApprovedAgent(apiUrl, result) {
|
|
751
|
+
writeAgentCredentials({
|
|
752
|
+
api_url: apiUrl,
|
|
753
|
+
agent_id: result.agent_id,
|
|
754
|
+
api_key: result.api_key,
|
|
755
|
+
key_id: result.key_id ?? null,
|
|
756
|
+
org_id: result.org_id,
|
|
757
|
+
team_id: result.team_id,
|
|
758
|
+
connected_at: new Date().toISOString(),
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
export async function handleAgentConnect(rest) {
|
|
762
|
+
const agentId = valueAt(rest, '--agent-id') ?? rest.find((a) => !a.startsWith('-')) ?? null;
|
|
763
|
+
if (!agentId) {
|
|
764
|
+
process.stderr.write('sickr: `agent connect` requires --agent-id <id>.\n');
|
|
765
|
+
process.exit(1);
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
const apiUrl = (valueAt(rest, '--api-url') ?? AGENT_API_URL).replace(/\/+$/, '');
|
|
769
|
+
let started;
|
|
770
|
+
try {
|
|
771
|
+
started = await startAgentConnect(apiUrl, agentId);
|
|
772
|
+
}
|
|
773
|
+
catch (e) {
|
|
774
|
+
process.stderr.write(`sickr: could not start agent connect (${e.message}).\n`);
|
|
775
|
+
process.exit(1);
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
const verifyUrl = started.verification_uri_complete ?? started.verification_uri;
|
|
779
|
+
process.stdout.write(`\nsickr: approve agent ${agentId} in your browser:\n\n` +
|
|
780
|
+
` ${verifyUrl}\n\n` +
|
|
781
|
+
`code: ${started.user_code}\n` +
|
|
782
|
+
`(opening browser...)\n`);
|
|
783
|
+
openInBrowser(verifyUrl);
|
|
784
|
+
const deadline = Date.now() + started.expires_in * 1000;
|
|
785
|
+
const intervalMs = Math.max(1, started.interval) * 1000;
|
|
786
|
+
while (Date.now() < deadline) {
|
|
787
|
+
await sleep(intervalMs);
|
|
788
|
+
let polled;
|
|
789
|
+
try {
|
|
790
|
+
polled = await pollAgentConnect(apiUrl, started.device_code);
|
|
791
|
+
}
|
|
792
|
+
catch (e) {
|
|
793
|
+
process.stderr.write(`sickr: agent connect poll failed (${e.message}).\n`);
|
|
794
|
+
process.exit(1);
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
if (polled.status === 'pending')
|
|
798
|
+
continue;
|
|
799
|
+
if (polled.status === 'approved') {
|
|
800
|
+
storeApprovedAgent(apiUrl, polled);
|
|
801
|
+
process.stdout.write(`\nsickr: connected ${polled.agent_id}. Run \`sickr agent status\` to verify.\n`);
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
if (polled.status === 'expired') {
|
|
805
|
+
process.stderr.write('sickr: agent connect code expired. Run `sickr agent connect` again.\n');
|
|
806
|
+
process.exit(1);
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
if (polled.status === 'denied') {
|
|
810
|
+
process.stderr.write('sickr: agent connect was denied.\n');
|
|
811
|
+
process.exit(1);
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
if (polled.status === 'consumed') {
|
|
815
|
+
process.stderr.write('sickr: agent connect code was already used. Run `sickr agent connect` again.\n');
|
|
816
|
+
process.exit(1);
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
process.stderr.write(`sickr: agent connect error: ${polled.error ?? 'unknown'}.\n`);
|
|
820
|
+
process.exit(1);
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
process.stderr.write('sickr: agent connect timed out. Run `sickr agent connect` again.\n');
|
|
824
|
+
process.exit(1);
|
|
825
|
+
}
|
|
826
|
+
async function fetchReplayProEntitlement() {
|
|
827
|
+
const creds = readCredentials();
|
|
828
|
+
if (!creds)
|
|
829
|
+
return false;
|
|
830
|
+
try {
|
|
831
|
+
const r = await fetch(`${AUTH_ENDPOINT}/me`, {
|
|
832
|
+
headers: { Authorization: `Bearer ${creds.token}` },
|
|
833
|
+
});
|
|
834
|
+
if (!r.ok)
|
|
835
|
+
return false;
|
|
836
|
+
const me = await r.json();
|
|
837
|
+
return !!me.entitlements?.replay_pro || me.tier === 'replay_pro';
|
|
838
|
+
}
|
|
839
|
+
catch {
|
|
840
|
+
return false;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
async function handleReplay(rest) {
|
|
844
|
+
const sub = replaySubcommand(rest);
|
|
845
|
+
if (!sub) {
|
|
846
|
+
process.stderr.write('sickr: unknown replay command. Use `sickr replay`, `sickr replay init all`, `sickr replay share`, or `sickr replay status`.\n');
|
|
847
|
+
process.exit(1);
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
const replayRest = sub === 'auto' ? [] : rest.slice(1);
|
|
851
|
+
if (sub === 'init') {
|
|
852
|
+
const noName = replayRest.includes('--no-name');
|
|
853
|
+
const agent = replayRest.find((a) => !a.startsWith('-')) ?? 'all';
|
|
854
|
+
if (agent === 'all') {
|
|
855
|
+
handleInit('claude', noName);
|
|
856
|
+
handleInit('codex', noName);
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
if (agent === 'claude' || agent === 'codex') {
|
|
860
|
+
handleInit(agent, noName);
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
process.stderr.write('sickr: choose an agent - `sickr replay init claude`, `codex`, or `all`.\n');
|
|
864
|
+
process.exit(1);
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
if (sub === 'share') {
|
|
868
|
+
const yes = replayRest.includes('--yes') || replayRest.includes('-y');
|
|
869
|
+
const openAfter = replayRest.includes('--open');
|
|
870
|
+
const sel = selectWindow(replayRest);
|
|
871
|
+
if (sel) {
|
|
872
|
+
await handleShareCombined(sel, yes, openAfter);
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
await handleShare(replayRest.find((a) => !a.startsWith('-')), yes, openAfter);
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
if (sub === 'open') {
|
|
879
|
+
const sel = selectWindow(replayRest);
|
|
880
|
+
if (sel) {
|
|
881
|
+
handleOpenCombined(sel);
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
const openProvider = replayRest.includes('--codex') ? 'codex' : replayRest.includes('--claude') ? 'claude' : undefined;
|
|
885
|
+
handleOpen(replayRest.find((a) => !a.startsWith('-')), openProvider);
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
if (sub === 'list') {
|
|
889
|
+
const listProvider = replayRest.includes('--codex') ? 'codex' : replayRest.includes('--claude') ? 'claude' : undefined;
|
|
890
|
+
handleList(listProvider);
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
if (sub === 'clear') {
|
|
894
|
+
await handleClear(replayRest.includes('--yes') || replayRest.includes('-y'));
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
if (sub === 'stop') {
|
|
898
|
+
handleStop();
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
const { startLive, stopLive, liveStatus } = await import('./live.js');
|
|
902
|
+
if (sub === 'status') {
|
|
903
|
+
liveStatus();
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
if (sub === 'live') {
|
|
907
|
+
const liveSub = replayRest[0];
|
|
908
|
+
if (liveSub === 'stop') {
|
|
909
|
+
stopLive();
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
if (liveSub === 'status') {
|
|
913
|
+
liveStatus();
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
await startLive({ verbose: replayRest.includes('--verbose') || replayRest.includes('-v'), background: replayRest.includes('--background') });
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
handleInit('claude');
|
|
920
|
+
handleInit('codex');
|
|
921
|
+
if (await fetchReplayProEntitlement()) {
|
|
922
|
+
await startLive({ verbose: rest.includes('--verbose') || rest.includes('-v'), background: rest.includes('--background') });
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
process.stdout.write('sickr: replay recording is ready for Claude and Codex.\n' +
|
|
926
|
+
' Replay Pro unlocks live viewing with `sickr replay live`.\n');
|
|
927
|
+
}
|
|
928
|
+
function commandCandidates(mappedArgs) {
|
|
929
|
+
const override = process.env.SICKR_ORCHESTRATOR_CMD?.trim();
|
|
930
|
+
if (override) {
|
|
931
|
+
const [command, ...prefix] = override.split(/\s+/);
|
|
932
|
+
return [{ command, args: [...prefix, ...mappedArgs] }];
|
|
933
|
+
}
|
|
934
|
+
return [
|
|
935
|
+
{ command: 'uvx', args: ['sickr', ...mappedArgs] },
|
|
936
|
+
{ command: process.platform === 'win32' ? 'python' : 'python3', args: ['-m', 'labudi_orchestrator.cli', ...mappedArgs] },
|
|
937
|
+
{ command: 'python', args: ['-m', 'labudi_orchestrator.cli', ...mappedArgs] },
|
|
938
|
+
];
|
|
939
|
+
}
|
|
940
|
+
function runWorkflow(rest) {
|
|
941
|
+
const mapped = buildWorkflowInvocation(rest).args;
|
|
942
|
+
const candidates = commandCandidates(mapped);
|
|
943
|
+
let lastStatus = 1;
|
|
944
|
+
for (const candidate of candidates) {
|
|
945
|
+
const result = spawnSync(candidate.command, candidate.args, { stdio: 'inherit', env: process.env });
|
|
946
|
+
if (result.error && result.error.code === 'ENOENT') {
|
|
947
|
+
lastStatus = 127;
|
|
948
|
+
continue;
|
|
949
|
+
}
|
|
950
|
+
if (result.error) {
|
|
951
|
+
process.stderr.write(`sickr: workflow command failed to start: ${result.error.message}\n`);
|
|
952
|
+
process.exit(1);
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
process.exit(result.status ?? 0);
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
process.stderr.write('sickr: workflow orchestrator is not available. Install Python package `sickr` with `uv tool install sickr`, or set SICKR_ORCHESTRATOR_CMD.\n');
|
|
959
|
+
process.exit(lastStatus);
|
|
960
|
+
}
|
|
961
|
+
async function handleWorkflow(rest) {
|
|
962
|
+
const alias = workflowAgentAlias(rest);
|
|
963
|
+
if (alias) {
|
|
964
|
+
const sub = alias[0];
|
|
965
|
+
const agentRest = alias.slice(1);
|
|
966
|
+
if (sub === 'connect') {
|
|
967
|
+
await handleAgentConnect(agentRest);
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
if (sub === 'rotate') {
|
|
971
|
+
await handleAgentRotate();
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
if (sub === 'disconnect') {
|
|
975
|
+
await handleAgentDisconnect();
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
runWorkflow(rest.length ? rest : ['status']);
|
|
980
|
+
}
|
|
981
|
+
async function readStdin() {
|
|
982
|
+
const chunks = [];
|
|
983
|
+
for await (const chunk of process.stdin)
|
|
984
|
+
chunks.push(chunk);
|
|
985
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
986
|
+
}
|
|
987
|
+
async function main() {
|
|
988
|
+
const argv = process.argv.slice(2);
|
|
989
|
+
if (argv.length === 0 || argv[0] === 'help' || argv.includes('--help') || argv.includes('-h')) {
|
|
990
|
+
process.stdout.write(HELP);
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
const cmd = parseCommand(argv);
|
|
994
|
+
const rest = argv.slice(1);
|
|
995
|
+
const provider = rest.includes('--codex') ? 'codex' : 'claude';
|
|
996
|
+
switch (cmd) {
|
|
997
|
+
case 'record':
|
|
998
|
+
handleRecord(await readStdin(), provider);
|
|
999
|
+
return;
|
|
1000
|
+
case 'init': {
|
|
1001
|
+
const noName = rest.includes('--no-name');
|
|
1002
|
+
const agent = rest.find((a) => !a.startsWith('-'));
|
|
1003
|
+
if (agent === 'all') {
|
|
1004
|
+
handleInit('claude', noName);
|
|
1005
|
+
handleInit('codex', noName);
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
if (agent === 'claude' || agent === 'codex') {
|
|
1009
|
+
handleInit(agent, noName);
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
process.stderr.write('sickr: choose an agent — `init claude`, `init codex`, or `init all`.\n');
|
|
1013
|
+
process.exit(1);
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
case 'open': {
|
|
1017
|
+
const sel = selectWindow(rest);
|
|
1018
|
+
if (sel) {
|
|
1019
|
+
handleOpenCombined(sel);
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
const openProvider = rest.includes('--codex') ? 'codex' : rest.includes('--claude') ? 'claude' : undefined;
|
|
1023
|
+
handleOpen(rest.find((a) => !a.startsWith('-')), openProvider);
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
case 'list': {
|
|
1027
|
+
const listProvider = rest.includes('--codex') ? 'codex' : rest.includes('--claude') ? 'claude' : undefined;
|
|
1028
|
+
handleList(listProvider);
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
case 'stop':
|
|
1032
|
+
handleStop();
|
|
1033
|
+
return;
|
|
1034
|
+
case 'clear':
|
|
1035
|
+
await handleClear(rest.includes('--yes') || rest.includes('-y'));
|
|
1036
|
+
return;
|
|
1037
|
+
case 'login':
|
|
1038
|
+
await handleLogin();
|
|
1039
|
+
return;
|
|
1040
|
+
case 'logout':
|
|
1041
|
+
handleLogout();
|
|
1042
|
+
return;
|
|
1043
|
+
case 'whoami':
|
|
1044
|
+
handleWhoami();
|
|
1045
|
+
return;
|
|
1046
|
+
case 'replay':
|
|
1047
|
+
await handleReplay(rest);
|
|
1048
|
+
return;
|
|
1049
|
+
case 'workflow':
|
|
1050
|
+
await handleWorkflow(rest);
|
|
1051
|
+
return;
|
|
1052
|
+
case 'agent': {
|
|
1053
|
+
const sub = rest[0];
|
|
1054
|
+
const agentRest = rest.slice(1);
|
|
1055
|
+
if (sub === 'connect') {
|
|
1056
|
+
await handleAgentConnect(agentRest);
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
if (sub === 'status') {
|
|
1060
|
+
await handleAgentStatus();
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
if (sub === 'disconnect') {
|
|
1064
|
+
await handleAgentDisconnect();
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
if (sub === 'rotate') {
|
|
1068
|
+
await handleAgentRotate();
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
process.stderr.write('sickr: unknown agent command. Use `sickr agent connect|status|disconnect|rotate`.\n');
|
|
1072
|
+
process.exit(1);
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
case 'live': {
|
|
1076
|
+
const sub = rest[0];
|
|
1077
|
+
const { startLive, stopLive, liveStatus } = await import('./live.js');
|
|
1078
|
+
if (sub === 'stop') {
|
|
1079
|
+
stopLive();
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
if (sub === 'status') {
|
|
1083
|
+
liveStatus();
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
// default: start (foreground)
|
|
1087
|
+
const opts = { verbose: rest.includes('--verbose') || rest.includes('-v'), background: rest.includes('--background') };
|
|
1088
|
+
await startLive(opts);
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
case 'share': {
|
|
1092
|
+
const yes = rest.includes('--yes') || rest.includes('-y');
|
|
1093
|
+
const openAfter = rest.includes('--open');
|
|
1094
|
+
const sel = selectWindow(rest);
|
|
1095
|
+
if (sel) {
|
|
1096
|
+
await handleShareCombined(sel, yes, openAfter);
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
await handleShare(rest.find((a) => !a.startsWith('-')), yes, openAfter);
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
default:
|
|
1103
|
+
process.stderr.write('sickr: unknown command. Run `npx @sickr/cli help`.\n');
|
|
1104
|
+
process.exit(1);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
1108
|
+
if (invokedDirectly)
|
|
1109
|
+
void main();
|