copyrun 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,349 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/receipt-hook.ts
4
+ import { spawn, spawnSync } from "child_process";
5
+ import { appendFileSync, existsSync as existsSync2, readFileSync as readFileSync2, renameSync, statSync, writeFileSync } from "fs";
6
+ import { dirname, join as join3 } from "path";
7
+ import { fileURLToPath } from "url";
8
+
9
+ // src/config.ts
10
+ import { randomUUID } from "crypto";
11
+ import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
12
+ import { homedir } from "os";
13
+ import { join as join2 } from "path";
14
+
15
+ // src/managed.ts
16
+ import { execFileSync } from "child_process";
17
+ import { existsSync, readFileSync } from "fs";
18
+ import { join } from "path";
19
+ var GATED_CAPABILITIES = ["shell", "install", "network", "settings"];
20
+ var SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
21
+ var HANDLE = /^[a-z0-9][a-z0-9-]{0,38}$/;
22
+ function parseManaged(v) {
23
+ const problems = [];
24
+ const config = {};
25
+ if (!v || typeof v !== "object" || Array.isArray(v)) return { config, problems: ["not a JSON object"] };
26
+ const o = v;
27
+ const bool = (k) => {
28
+ if (o[k] === void 0) return;
29
+ if (typeof o[k] !== "boolean") problems.push(`${k} must be true or false`);
30
+ else config[k] = o[k];
31
+ };
32
+ if (o.apiHost !== void 0) {
33
+ try {
34
+ const u = new URL(String(o.apiHost));
35
+ const local = u.hostname === "localhost" || u.hostname === "127.0.0.1";
36
+ if (u.protocol !== "https:" && !(local && u.protocol === "http:")) problems.push("apiHost must be an https URL");
37
+ else config.apiHost = u.origin;
38
+ } catch {
39
+ problems.push("apiHost must be a URL");
40
+ }
41
+ }
42
+ if (o.org !== void 0) {
43
+ const s = typeof o.org === "string" ? o.org.trim().toLowerCase() : "";
44
+ if (!SLUG.test(s)) problems.push("org must be an org slug");
45
+ else config.org = s;
46
+ }
47
+ for (const k of ["forbidPublic", "forbidAnonymous", "forbidUnlisted", "requireShape", "disableNudge", "disableLive", "disableReceipts", "disableAutopilot", "copyLink", "disableAsk", "disableMission", "disableCopy"]) bool(k);
48
+ if (o.autopilotOrg !== void 0) {
49
+ const s = typeof o.autopilotOrg === "string" ? o.autopilotOrg.trim().toLowerCase() : "";
50
+ if (!SLUG.test(s)) problems.push("autopilotOrg must be an org slug");
51
+ else config.autopilotOrg = s;
52
+ }
53
+ if (o.autopilotContent !== void 0) {
54
+ if (o.autopilotContent !== "full" && o.autopilotContent !== "actions" && o.autopilotContent !== "commands") problems.push('autopilotContent must be "full", "actions" or "commands"');
55
+ else config.autopilotContent = o.autopilotContent;
56
+ }
57
+ if (o.allowedCloneSources !== void 0) {
58
+ const a = o.allowedCloneSources;
59
+ if (a === "any" || a === "org-playbook") config.allowedCloneSources = a;
60
+ else if (Array.isArray(a) && a.every((x) => typeof x === "string" && HANDLE.test(x.trim().replace(/^@/, "").toLowerCase())))
61
+ config.allowedCloneSources = [...new Set(a.map((x) => x.trim().replace(/^@/, "").toLowerCase()))];
62
+ else problems.push('allowedCloneSources must be "any", "org-playbook" or a list of handles and org slugs');
63
+ }
64
+ if (o.extraRedactTerms !== void 0) {
65
+ if (!Array.isArray(o.extraRedactTerms) || !o.extraRedactTerms.every((t) => typeof t === "string")) problems.push("extraRedactTerms must be a list of strings");
66
+ else config.extraRedactTerms = [...new Set(o.extraRedactTerms.map((t) => t.trim()).filter((t) => t.length >= 2 && t.length <= 100))].slice(0, 200);
67
+ }
68
+ if (o.profile !== void 0) {
69
+ if (o.profile !== "finance" && o.profile !== "hipaa") problems.push('profile must be "finance" or "hipaa"');
70
+ else config.profile = o.profile;
71
+ }
72
+ if (o.archive !== void 0) {
73
+ const a = o.archive;
74
+ const org = a && typeof a.org === "string" ? a.org.trim().toLowerCase() : "";
75
+ if (!a || typeof a !== "object" || !SLUG.test(org) || a.required !== void 0 && typeof a.required !== "boolean") problems.push("archive must be { org: <slug>, required: true|false }");
76
+ else config.archive = { org, required: a.required === true };
77
+ }
78
+ if (o.restrictOrgs !== void 0) {
79
+ const list = typeof o.restrictOrgs === "string" ? o.restrictOrgs.split(",") : o.restrictOrgs;
80
+ if (!Array.isArray(list) || !list.every((x) => typeof x === "string" && SLUG.test(x.trim().toLowerCase()))) problems.push("restrictOrgs must be a list of org slugs");
81
+ else config.restrictOrgs = [...new Set(list.map((x) => x.trim().toLowerCase()))];
82
+ }
83
+ if (o.restrictMode !== void 0) {
84
+ if (o.restrictMode !== "block-all-uploads") problems.push('restrictMode must be "block-all-uploads"');
85
+ else config.restrictMode = o.restrictMode;
86
+ }
87
+ if (o.askBefore !== void 0) {
88
+ if (!Array.isArray(o.askBefore) || !o.askBefore.every((t) => typeof t === "string")) problems.push("askBefore must be a list of change areas and path patterns");
89
+ else config.askBefore = [...new Set(o.askBefore.map((t) => t.trim()).filter((t) => t && t.length <= 200))].slice(0, 50);
90
+ }
91
+ if (o.askTimeoutMinutes !== void 0) {
92
+ const n = o.askTimeoutMinutes;
93
+ if (typeof n !== "number" || !Number.isFinite(n) || n < 1 || n > 15) problems.push("askTimeoutMinutes must be a number from 1 to 15");
94
+ else config.askTimeoutMinutes = Math.round(n);
95
+ }
96
+ if (o.askOrg !== void 0) {
97
+ const s = typeof o.askOrg === "string" ? o.askOrg.trim().toLowerCase() : "";
98
+ if (!SLUG.test(s)) problems.push("askOrg must be an org slug");
99
+ else config.askOrg = s;
100
+ }
101
+ if (o.allowCloneYesWith !== void 0) {
102
+ const l = o.allowCloneYesWith;
103
+ if (!Array.isArray(l) || !l.every((x) => GATED_CAPABILITIES.includes(x))) problems.push('allowCloneYesWith must list only "shell", "install", "network" and/or "settings"');
104
+ else config.allowCloneYesWith = [...new Set(l)];
105
+ }
106
+ return { config, problems };
107
+ }
108
+ function systemManagedPaths(platform = process.platform, env = process.env) {
109
+ if (platform === "darwin")
110
+ return [
111
+ { path: "/Library/Application Support/copyrun/managed.json", kind: "json" },
112
+ // a configuration profile (Jamf, Kandji, Intune for Mac) lands here; it wins over the file
113
+ { path: "/Library/Managed Preferences/ai.copyrun.cli.plist", kind: "plist" }
114
+ ];
115
+ if (platform === "win32") return [{ path: join(env.ProgramData || env.PROGRAMDATA || "C:\\ProgramData", "copyrun", "managed.json"), kind: "json" }];
116
+ return [{ path: "/etc/copyrun/managed.json", kind: "json" }];
117
+ }
118
+ function plutil(p) {
119
+ try {
120
+ return execFileSync("/usr/bin/plutil", ["-convert", "json", "-o", "-", p], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5e3 });
121
+ } catch {
122
+ return void 0;
123
+ }
124
+ }
125
+ function loadManaged(deps = {}) {
126
+ const env = deps.env ?? process.env;
127
+ const exists = deps.exists ?? existsSync;
128
+ const read = deps.read ?? ((p) => readFileSync(p, "utf8"));
129
+ const plist = deps.plistToJson ?? plutil;
130
+ const system = systemManagedPaths(deps.platform ?? process.platform, env).filter((f) => exists(f.path));
131
+ const files = system.length ? system : env.COPYRUN_MANAGED_CONFIG ? [{ path: env.COPYRUN_MANAGED_CONFIG, kind: "json" }] : [];
132
+ const state = { config: {}, sources: [], errors: [] };
133
+ for (const f of files) {
134
+ let text;
135
+ try {
136
+ text = f.kind === "plist" ? plist(f.path) : read(f.path);
137
+ } catch (e) {
138
+ state.errors.push(`${f.path}: can't be read (${e.message})`);
139
+ continue;
140
+ }
141
+ if (text === void 0) {
142
+ if (f.kind === "plist" && files.some((x) => x.kind === "json")) continue;
143
+ state.errors.push(`${f.path}: can't be read (plutil unavailable)`);
144
+ continue;
145
+ }
146
+ let json;
147
+ try {
148
+ json = JSON.parse(text);
149
+ } catch {
150
+ state.errors.push(`${f.path}: not valid JSON`);
151
+ continue;
152
+ }
153
+ const { config, problems } = parseManaged(json);
154
+ if (problems.length) state.errors.push(`${f.path}: ${problems.join("; ")}`);
155
+ state.config = { ...state.config, ...config };
156
+ state.sources.push(f.path);
157
+ }
158
+ if (state.config.allowedCloneSources === "org-playbook" && !state.config.org) state.errors.push('allowedCloneSources "org-playbook" needs org');
159
+ if (state.config.autopilotOrg && state.config.org && state.config.autopilotOrg !== state.config.org) state.errors.push("autopilotOrg must be the same org as org");
160
+ if (state.config.askOrg && state.config.org && state.config.askOrg !== state.config.org) state.errors.push("askOrg must be the same org as org");
161
+ return state;
162
+ }
163
+ var cached;
164
+ function managed() {
165
+ return cached ??= loadManaged();
166
+ }
167
+
168
+ // src/config.ts
169
+ var VERSION = "0.2.0";
170
+ var DIR = process.env.COPYRUN_HOME ?? join2(homedir(), ".copyrun");
171
+
172
+ // src/receipt-logic.ts
173
+ var RECEIPT_STATE_FILE = "receipts.json";
174
+ var RECEIPT_LOG_FILE = "receipts.log";
175
+ var PUSH_RESHARE_MS = 10 * 6e4;
176
+ var MAX_SESSIONS = 200;
177
+ var PR_URL = /https:\/\/github\.com\/([\w.-]{1,100})\/([\w.-]{1,100})\/pull\/(\d{1,9})\b/g;
178
+ function commandText(input) {
179
+ const c = input.tool_input?.command;
180
+ if (typeof c === "string") return c.slice(0, 2e4);
181
+ if (Array.isArray(c) && c.every((x) => typeof x === "string")) return c.join(" ").slice(0, 2e4);
182
+ return void 0;
183
+ }
184
+ function responseText(resp) {
185
+ if (typeof resp === "string") return resp.slice(-2e4);
186
+ if (resp && typeof resp === "object") {
187
+ const r = resp;
188
+ return [r.stdout, r.stderr, r.output].filter((x) => typeof x === "string").join("\n").slice(-2e4);
189
+ }
190
+ return "";
191
+ }
192
+ var failed = (resp) => !!resp && typeof resp === "object" && (resp.interrupted === true || resp.is_error === true);
193
+ function segments(cmd) {
194
+ return cmd.split(/&&|\|\||[;|\n]/).map((s) => s.trim().replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S*)\s+){0,10}/, "")).filter(Boolean);
195
+ }
196
+ function prEventOf(input) {
197
+ if (input.hook_event_name && input.hook_event_name !== "PostToolUse") return void 0;
198
+ const cmd = commandText(input);
199
+ if (!cmd || failed(input.tool_response)) return void 0;
200
+ const segs = segments(cmd);
201
+ const create = segs.some((s) => /^gh\s+pr\s+create\b/.test(s) && !/\s--(?:dry-run|web|help)\b|\s-w\b|\s-h\b/.test(s));
202
+ if (create) {
203
+ const urls = [...responseText(input.tool_response).matchAll(PR_URL)];
204
+ const last = urls.at(-1);
205
+ if (last) return { kind: "created", prUrl: `https://github.com/${last[1]}/${last[2]}/pull/${last[3]}` };
206
+ return void 0;
207
+ }
208
+ const push = segs.some((s) => /^git\s+(?:-C\s+\S+\s+)?push\b/.test(s) && !/\s--(?:dry-run|delete|tags|help)\b|\s-[nd]\b/.test(s));
209
+ if (push && !/\b(?:rejected|error:|fatal:)/.test(responseText(input.tool_response))) return { kind: "pushed" };
210
+ return void 0;
211
+ }
212
+ function decideAttach(state, sessionId, ev, now, size, cwd) {
213
+ if (ev.kind === "created") return { prUrl: ev.prUrl };
214
+ const s = state.sessions?.[sessionId];
215
+ if (!s || cwd && s.cwd && s.cwd !== cwd) return void 0;
216
+ if (size <= s.size || now - s.at < PUSH_RESHARE_MS) return void 0;
217
+ return { prUrl: s.prUrl };
218
+ }
219
+ function recordAttach(state, sessionId, s) {
220
+ const sessions = { ...state.sessions ?? {}, [sessionId]: { ...state.sessions?.[sessionId] ?? {}, ...s } };
221
+ const ids = Object.keys(sessions).sort((a, b) => sessions[b].at - sessions[a].at);
222
+ for (const id of ids.slice(MAX_SESSIONS)) delete sessions[id];
223
+ return { ...state, sessions };
224
+ }
225
+ function repoOfPr(url) {
226
+ const m = [...url.matchAll(PR_URL)][0];
227
+ return m ? `${m[1]}/${m[2]}` : void 0;
228
+ }
229
+ function receiptShareArgs(transcript, repoPublic2) {
230
+ return ["share", transcript, ...repoPublic2 ? ["--pr"] : ["--private"], "--yes", "--confidential", "remove", "--no-recipe", "--json"];
231
+ }
232
+ var attachingMessage = (prUrl) => `\u{1F9FE} copyrun is attaching this session to ${prUrl} in the background, redacted like \`copyrun share --yes\` (npx copyrun receipts status \xB7 receipts off stops it)`;
233
+ function attachOutcome(stdout, stderr, status) {
234
+ let body;
235
+ for (const line of stdout.trim().split("\n").reverse()) {
236
+ try {
237
+ const v = JSON.parse(line);
238
+ if (v && typeof v === "object") {
239
+ body = v;
240
+ break;
241
+ }
242
+ } catch {
243
+ }
244
+ }
245
+ const plain = stderr.replace(/\x1b\[[0-9;]*m/g, "");
246
+ const runId = body?.ok === true && typeof body.id === "string" ? body.id : void 0;
247
+ const attached = /Added the run to https:\/\/github\.com\//.test(plain);
248
+ const error = runId ? void 0 : String(body?.message ?? body?.error ?? plain.trim().split("\n").filter(Boolean).pop() ?? `exit ${status}`).slice(0, 200);
249
+ return { ...runId ? { runId } : {}, ...typeof body?.visibility === "string" ? { visibility: body.visibility } : {}, attached, ...error ? { error } : {} };
250
+ }
251
+
252
+ // src/receipt-hook.ts
253
+ var DIR2 = process.env.COPYRUN_HOME ?? dirname(fileURLToPath(import.meta.url));
254
+ var SELF = fileURLToPath(import.meta.url);
255
+ function readJson(path) {
256
+ try {
257
+ return JSON.parse(readFileSync2(path, "utf8"));
258
+ } catch {
259
+ return void 0;
260
+ }
261
+ }
262
+ function writeState(state) {
263
+ const path = join3(DIR2, RECEIPT_STATE_FILE);
264
+ const tmp = `${path}.${process.pid}.tmp`;
265
+ writeFileSync(tmp, JSON.stringify(state), { mode: 384 });
266
+ renameSync(tmp, path);
267
+ }
268
+ function log(line) {
269
+ try {
270
+ appendFileSync(join3(DIR2, RECEIPT_LOG_FILE), `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
271
+ `, { mode: 384 });
272
+ } catch {
273
+ }
274
+ }
275
+ function readStdin() {
276
+ return new Promise((resolve) => {
277
+ let data = "";
278
+ const t = setTimeout(() => resolve(data), 1500);
279
+ process.stdin.setEncoding("utf8");
280
+ process.stdin.on("data", (d) => {
281
+ data += d;
282
+ if (data.length > 2e6) process.stdin.destroy();
283
+ });
284
+ process.stdin.on("end", () => (clearTimeout(t), resolve(data)));
285
+ process.stdin.on("error", () => (clearTimeout(t), resolve(data)));
286
+ });
287
+ }
288
+ function cliCommand(config) {
289
+ const local = process.env.COPYRUN_CLI ?? (typeof config?.receipts?.cli === "string" && existsSync2(config.receipts.cli) ? config.receipts.cli : void 0);
290
+ return local ? [process.execPath, [local]] : [process.platform === "win32" ? "npx.cmd" : "npx", ["-y", `copyrun@${VERSION}`]];
291
+ }
292
+ function repoPublic(repo, cwd) {
293
+ const gh = process.env.COPYRUN_GH ?? "gh";
294
+ const r = spawnSync(gh, ["api", `repos/${repo}`, "--jq", ".private"], { cwd, encoding: "utf8", timeout: 2e4, stdio: ["ignore", "pipe", "ignore"] });
295
+ return r.status === 0 && (r.stdout ?? "").trim() === "false";
296
+ }
297
+ function attach(cwd, transcript, sessionId, prUrl) {
298
+ const config = readJson(join3(DIR2, "config.json"));
299
+ const repo = repoOfPr(prUrl);
300
+ if (!repo || !existsSync2(transcript)) return;
301
+ const pub = repoPublic(repo, cwd);
302
+ const [cmd, pre] = cliCommand(config);
303
+ const r = spawnSync(cmd, [...pre, ...receiptShareArgs(transcript, pub)], { cwd, encoding: "utf8", timeout: 15 * 6e4, env: { ...process.env, COPYRUN_HOME: DIR2, COPYRUN_RECEIPT: "1" }, stdio: ["ignore", "pipe", "pipe"] });
304
+ const out = attachOutcome(r.stdout ?? "", r.stderr ?? "", r.status);
305
+ const state = readJson(join3(DIR2, RECEIPT_STATE_FILE)) ?? {};
306
+ const s = state.sessions?.[sessionId];
307
+ if (s) {
308
+ s.runId = out.runId ?? s.runId;
309
+ if (out.visibility) s.visibility = out.visibility;
310
+ if (out.error) s.error = out.error;
311
+ else delete s.error;
312
+ try {
313
+ writeState(state);
314
+ } catch {
315
+ }
316
+ }
317
+ if (!out.runId) log(`${prUrl}: not attached (${out.error})`);
318
+ else if (pub) log(`${prUrl}: ${out.attached ? "attached" : "shared, but the PR wasn't edited"} run ${out.runId} (${out.visibility ?? "unlisted"})`);
319
+ else log(`${prUrl}: filed run ${out.runId} privately (the repo isn't public, or gh couldn't say); on a paying team's or Pro workspace's own private repo the copyrun App links it to the PR by commit`);
320
+ }
321
+ async function main() {
322
+ if (process.argv[2] === "--attach") return attach(process.argv[3], process.argv[4], process.argv[5], process.argv[6]);
323
+ const config = readJson(join3(DIR2, "config.json"));
324
+ if (config?.receipts?.on !== true || managed().config.disableReceipts || process.env.COPYRUN_REPLAY) return;
325
+ const input = JSON.parse(await readStdin());
326
+ if (!input.session_id || !input.transcript_path || !input.cwd) return;
327
+ const ev = prEventOf(input);
328
+ if (!ev) return;
329
+ const state = readJson(join3(DIR2, RECEIPT_STATE_FILE)) ?? {};
330
+ let size = 0;
331
+ try {
332
+ size = statSync(input.transcript_path).size;
333
+ } catch {
334
+ return;
335
+ }
336
+ const now = Date.now();
337
+ const go = decideAttach(state, input.session_id, ev, now, size, input.cwd);
338
+ if (!go) return;
339
+ writeState(recordAttach(state, input.session_id, { prUrl: go.prUrl, cwd: input.cwd, at: now, size }));
340
+ const child = spawn(process.execPath, [SELF, "--attach", input.cwd, input.transcript_path, input.session_id, go.prUrl], { detached: true, stdio: "ignore", windowsHide: true, cwd: input.cwd });
341
+ child.on("error", () => {
342
+ });
343
+ child.unref();
344
+ if (ev.kind === "created") process.stdout.write(JSON.stringify({ systemMessage: attachingMessage(go.prUrl) }) + "\n");
345
+ }
346
+ main().catch(() => {
347
+ }).finally(() => {
348
+ if (process.argv[2] !== "--attach") process.exit(0);
349
+ });
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/seal-worker.ts
4
+ import { dirname } from "path";
5
+ import { fileURLToPath } from "url";
6
+
7
+ // src/session-seal.ts
8
+ import { createHash, createPrivateKey, sign } from "crypto";
9
+ import { closeSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, unlinkSync, writeFileSync } from "fs";
10
+ import { join } from "path";
11
+ var SEAL_QUEUE_DIR = "seal-queue";
12
+ var SEAL_LOG_FILE = "session-seals.json";
13
+ var HOOK_MAX_BYTES = 64 * 1024 * 1024;
14
+ var SEAL_SPAWN_THROTTLE_MS = 5 * 60 * 1e3;
15
+ var MAX_LOG = 5e3;
16
+ var MAX_ATTEMPTS = 30;
17
+ function readJson(path) {
18
+ try {
19
+ return JSON.parse(readFileSync(path, "utf8"));
20
+ } catch {
21
+ return void 0;
22
+ }
23
+ }
24
+ function writeJsonAtomic(path, value) {
25
+ const tmp = `${path}.${process.pid}.tmp`;
26
+ writeFileSync(tmp, JSON.stringify(value), { mode: 384 });
27
+ renameSync(tmp, path);
28
+ }
29
+ function deviceOf(dir) {
30
+ const d = readJson(join(dir, "config.json"))?.device;
31
+ return d && typeof d.privateKey === "string" && typeof d.token === "string" && typeof d.api === "string" ? d : void 0;
32
+ }
33
+ function readSealLog(dir) {
34
+ const v = readJson(join(dir, SEAL_LOG_FILE));
35
+ return Array.isArray(v) ? v.filter((e) => e && typeof e.sha256 === "string") : [];
36
+ }
37
+ function appendSealLog(dir, e) {
38
+ const all = readSealLog(dir).filter((x) => !(x.sha256 === e.sha256 && x.kind === e.kind && x.api === e.api));
39
+ mkdirSync(dir, { recursive: true, mode: 448 });
40
+ writeJsonAtomic(join(dir, SEAL_LOG_FILE), [...all, e].slice(-MAX_LOG));
41
+ }
42
+ function listSealQueue(dir) {
43
+ const qdir = join(dir, SEAL_QUEUE_DIR);
44
+ let names;
45
+ try {
46
+ names = readdirSync(qdir).filter((n) => n.endsWith(".json"));
47
+ } catch {
48
+ return [];
49
+ }
50
+ const out = [];
51
+ for (const n of names) {
52
+ const req = readJson(join(qdir, n));
53
+ if (req && req.v === 1 && typeof req.sha256 === "string") out.push({ path: join(qdir, n), req });
54
+ }
55
+ return out.sort((a, b) => a.req.createdAt.localeCompare(b.req.createdAt));
56
+ }
57
+ async function flushSealQueue(dir, o = {}) {
58
+ const now = o.now ?? Date.now();
59
+ const doFetch = o.fetch ?? globalThis.fetch;
60
+ const res = { sealed: [], waiting: 0, dropped: [] };
61
+ const device = deviceOf(dir);
62
+ for (const { path, req } of listSealQueue(dir)) {
63
+ if (!o.ignoreBackoff && Date.parse(req.nextAttemptAt) > now) {
64
+ res.waiting++;
65
+ continue;
66
+ }
67
+ const drop = (reason) => {
68
+ try {
69
+ unlinkSync(path);
70
+ } catch {
71
+ }
72
+ res.dropped.push({ sha256: req.sha256, reason });
73
+ };
74
+ if (!device || device.api !== req.api || device.publicKey !== req.publicKey) {
75
+ if (!device) res.waiting++;
76
+ else drop("signed by a different device key");
77
+ continue;
78
+ }
79
+ let status = 0;
80
+ let body = {};
81
+ try {
82
+ const r = await doFetch(`${req.api}/api/v1/seal`, {
83
+ method: "POST",
84
+ headers: { "content-type": "application/json", authorization: `Bearer ${device.token}` },
85
+ body: JSON.stringify({ kind: req.kind, sha256: req.sha256, signature: req.signature, publicKey: req.publicKey, ...req.sessionSha256 ? { sessionSha256: req.sessionSha256 } : {}, ...o.org ? { org: o.org } : {} }),
86
+ signal: AbortSignal.timeout(o.timeoutMs ?? 1e4)
87
+ });
88
+ status = r.status;
89
+ body = await r.json().catch(() => ({})) ?? {};
90
+ } catch {
91
+ status = 0;
92
+ }
93
+ const seal = body.seal;
94
+ if ((status === 200 || status === 201) && seal && typeof seal.sealedAt === "string" && typeof seal.serverSignature === "string") {
95
+ const entry = {
96
+ kind: req.kind,
97
+ sha256: req.sha256,
98
+ ...req.bytes !== void 0 ? { bytes: req.bytes } : {},
99
+ ...req.sessionId ? { sessionId: req.sessionId } : {},
100
+ ...req.sessionSha256 ? { sessionSha256: req.sessionSha256 } : {},
101
+ sealedAt: seal.sealedAt,
102
+ serverSignature: seal.serverSignature,
103
+ serverKeyId: String(seal.serverKeyId ?? ""),
104
+ api: req.api,
105
+ ...typeof seal.handle === "string" ? { handle: seal.handle } : {}
106
+ };
107
+ try {
108
+ appendSealLog(dir, entry);
109
+ unlinkSync(path);
110
+ } catch {
111
+ }
112
+ res.sealed.push(entry);
113
+ continue;
114
+ }
115
+ if (status === 400 || status === 403 || status === 422 || req.attempts + 1 >= MAX_ATTEMPTS) {
116
+ drop(typeof body.code === "string" ? body.code : `http_${status}`);
117
+ continue;
118
+ }
119
+ const attempts = req.attempts + 1;
120
+ const wait = Math.min(6 * 60 * 60 * 1e3, 6e4 * 2 ** Math.min(attempts - 1, 12));
121
+ try {
122
+ writeJsonAtomic(path, { ...req, attempts, nextAttemptAt: new Date(now + wait).toISOString(), lastError: typeof body.code === "string" && body.code ? body.code : status ? `http_${status}` : "network" });
123
+ } catch {
124
+ }
125
+ res.waiting++;
126
+ }
127
+ return res;
128
+ }
129
+
130
+ // src/seal-worker.ts
131
+ flushSealQueue(dirname(fileURLToPath(import.meta.url))).catch(() => {
132
+ }).finally(() => process.exit(0));
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
1
  {
2
2
  "name": "copyrun",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "A copy of you that runs your agents: every agent acting as you on one board, asks before risky steps answered from any browser, a copy of your judgment that answers the way you would where it has earned it, and a check of what your agent claimed against what actually ran.",
5
+ "keywords": [
6
+ "ai",
7
+ "coding-agent",
8
+ "claude-code",
9
+ "codex",
10
+ "cursor",
11
+ "agents",
12
+ "human-in-the-loop",
13
+ "pull-request",
14
+ "provenance",
15
+ "cli"
16
+ ],
17
+ "homepage": "https://copyrun.ai/docs",
18
+ "bugs": {
19
+ "url": "https://copyrun.ai/community",
20
+ "email": "hello@copyrun.ai"
21
+ },
22
+ "license": "MIT",
23
+ "author": "Copyrun, Inc. <hello@copyrun.ai>",
24
+ "type": "module",
25
+ "bin": {
26
+ "copyrun": "dist/index.js"
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "skills"
31
+ ],
32
+ "engines": {
33
+ "node": ">=20"
34
+ },
35
+ "scripts": {
36
+ "build": "tsup",
37
+ "dev": "tsup --watch",
38
+ "test": "vitest run",
39
+ "typecheck": "tsc --noEmit",
40
+ "build:plugin": "tsup && cp dist/autopilot.js ../plugin/scripts/autopilot.mjs && cp dist/autopilot-worker.js ../plugin/scripts/autopilot-worker.mjs"
41
+ },
42
+ "devDependencies": {
43
+ "@copyrun/core": "workspace:*",
44
+ "tsup": "8.5.1",
45
+ "@copyrun/extract": "workspace:*",
46
+ "@copyrun/datapacks": "workspace:*"
47
+ }
48
+ }
@@ -0,0 +1,97 @@
1
+ ---
2
+ name: copyrun
3
+ description: Share the current coding-agent session as a public copyrun page (redacted locally first), clone someone else's copyrun run into this agent, or search copyrun for a run that already did what the user is trying to do. Use when the user says "share this session", "share this run", "copyrun this", pastes a copyrun.ai/r/... link, asks "has anyone done X", or is stuck on a task someone else has likely solved with an agent.
4
+ ---
5
+
6
+ # copyrun
7
+
8
+ copyrun turns a Claude Code, Codex or Cursor session into a page anyone can read and clone. Everything is redacted on this machine before upload.
9
+
10
+ ## Sharing the current session
11
+
12
+ 1. Preview first. Never upload without showing the user what will be removed:
13
+
14
+ ```bash
15
+ npx -y copyrun@latest share --dry-run --json
16
+ ```
17
+
18
+ The JSON has `title`, `counts` (redactions per rule) and `findings` (each with a masked `before` and the `after`).
19
+
20
+ 2. Propose a title. The automatic one comes from the first prompt and is often vague. You know what this session accomplished, so suggest a specific, outcome-first title under 80 characters, written like a headline a developer would click: what was done, to what, and how if it's interesting. For example, "Migrated 47 Jest test files to Vitest in one pass", not "help with tests". Leave out client or company names.
21
+
22
+ 3. Show the user your proposed title and a short summary of the redactions: how many secrets, which rules, and any emails. Ask whether to publish, whether the title works, whether they want it unlisted, and whether to remove extra strings (company or client names, internal hostnames).
23
+
24
+ 4. Only after the user says yes:
25
+
26
+ ```bash
27
+ npx -y copyrun@latest share --yes --json --title "<agreed title>" [--unlisted] [--redact "Acme,internal.acme.dev"]
28
+ ```
29
+
30
+ The dry run's `suspects` are things the rules can't know are sensitive (company names, hosts, tickets, people). Offer to add them to `--redact`. If any have kind `confidential` (a sentence saying the work isn't public), ask the user whether to remove those sentences (`--confidential remove`), share them as they are (`--confidential share`), or not share at all; the share refuses without one of these.
31
+
32
+ If the output says `termsRequired: true`, this machine hasn't agreed to the Terms yet. Show the user the Terms link and what they confirm (they own the work or may share it; it isn't an employer's or client's confidential code or data). Only if they agree, re-run with `--accept-terms`. Never add it on your own.
33
+
34
+ If the repo's remote isn't public, copyrun shares unlisted by default and refuses `--public` with `--yes` unless `--public-ok` is passed: only pass it when the user explicitly asks for a public share. Policy refusals (`policyBlocked: true`) come from the user's org or the repo's `.copyrun-policy`: tell the user the message; don't work around it.
35
+
36
+ 5. The output includes a `recipe`: the reusable skill copyrun built from this run, with `title`, `inputs`, `steps`, `ungeneralized` (things to check) and `scan` (security review: `status`, `summary`, `findings`). It's saved as a draft. Summarize it for the user in a few lines: what it will do for someone who clones it, its inputs, the scan result, and anything in `ungeneralized`. If `canPublish` is true, ask whether to publish it, and only after a yes:
37
+
38
+ ```bash
39
+ npx -y copyrun@latest recipe <id> --publish --json
40
+ ```
41
+
42
+ If the scan failed, say why (from `findings`) and that it can't be published. The author can edit it at the run's `/recipe` page after claiming the run.
43
+
44
+ 6. Reply with the `url` and the `claimUrl` (opening it attaches the run to their GitHub account). Tell the user the `deleteUrl` is saved in `~/.copyrun/shares.json` and that they can run `npx copyrun shares` to see it.
45
+
46
+ 7. Offer to help them post it. The output's `kitUrl` has ready-made posts for X, Reddit, LinkedIn, YouTube and Show HN, and `clipUrl` makes a short video of the run. If they're on a branch with an open pull request, offer to add the run to the PR description, and only after a yes:
47
+
48
+ ```bash
49
+ npx -y copyrun@latest pr <id>
50
+ ```
51
+
52
+ It edits the PR through the GitHub CLI (`gh`) and replaces its own block if run again.
53
+
54
+ If the output says `blocked: true`, the session contains a private key and cannot be shared. Tell the user. Do not try to work around it.
55
+
56
+ ## Asking for a run ("copyrun it")
57
+
58
+ When the user shares a link to a post (X, YouTube, Reddit, HN, LinkedIn) where someone shows off what their AI agent did, and they want to see how it was done, point them to `https://copyrun.ai/want?url=<the post's URL, URL-encoded>`. That page collects everyone who wants the run behind the post and notifies them when the author shares it.
59
+
60
+ ## Finding a run that already did it
61
+
62
+ When the user asks whether anyone has done something, or is starting a task that is common (migrations, CI setup, framework upgrades, test fixes), search copyrun before building from scratch:
63
+
64
+ ```bash
65
+ npx -y copyrun@latest search "<what they're trying to do>" --json
66
+ ```
67
+
68
+ Each result has a `clone` command and a `markdown` URL. Show the user the top two or three with titles and the agent used. If one fits, offer to install it (`clone`) or read it (`fetch the markdown URL`) before starting. Don't search for things that are trivial or specific to this codebase.
69
+
70
+ Any copyrun run link can be read as markdown by appending `.md` to it.
71
+
72
+ ## Cloning a run
73
+
74
+ When the user pastes a copyrun link or id:
75
+
76
+ ```bash
77
+ npx -y copyrun@latest clone <id-or-url> --print
78
+ ```
79
+
80
+ Read the printed SKILL.md and summarize it for the user. If the run has a published recipe, the output also shows its security scan; mention the result. Flag anything risky yourself too (destructive commands, network calls, credential access). Install only after they agree:
81
+
82
+ ```bash
83
+ npx -y copyrun@latest clone <id-or-url> --yes
84
+ ```
85
+
86
+ It installs into this project (`.claude/skills/` at the repo root, recorded in `.copyrun/skills.lock`), not for every project; `--global` installs into the user's home folder instead, only if they ask. The output lists what the run used (skills, MCP servers) and what's missing here; pass that on.
87
+
88
+ Then tell the user to use it in this session, or to try it right away in a terminal with `npx copyrun clone <id> --replay` (it launches their agent, shows a plan first, and records whether it worked). `--replay` needs an interactive terminal, so don't run it yourself.
89
+
90
+ ## Verified shares
91
+
92
+ If the share output has no `author`, the run is anonymous and shows as unverified. Mention once that running `npx copyrun login` in a terminal (it opens the browser to approve this machine) makes future shares verified and puts them straight on the user's profile, with no claim step. Don't run `login` yourself.
93
+
94
+ ## Rules
95
+
96
+ - Never pass `--yes` to `share` before the user has seen the dry-run summary.
97
+ - Never edit the transcript files under `~/.claude`, `~/.codex` or `~/.cursor`.