copyrun 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Copyrun, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,83 @@
1
- # Temporary Holding Version
1
+ # copyrun
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Receipts for AI-written code.
4
+
5
+ copyrun turns a coding-agent session (Claude Code, Codex, Cursor) into a run page that shows what the agent actually did: the prompts, the commands it ran, the files it changed and whether the tests passed. Attach it to a pull request so reviewers can check the work, or find a run that solved your problem and reuse it.
6
+
7
+ ## Install
8
+
9
+ Requires Node.js 20 or later.
10
+
11
+ ```sh
12
+ npx copyrun --help
13
+ ```
14
+
15
+ Or install it globally:
16
+
17
+ ```sh
18
+ npm install -g copyrun
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ ```sh
24
+ copyrun share # share the latest session from this project
25
+ copyrun pr # add it to this branch's pull request
26
+ copyrun check # check the agent's claims against what it ran
27
+ copyrun search "<task>" # find runs that did a similar task
28
+ copyrun clone <id|url> # install a run's recipe as a skill in this project
29
+ copyrun worked <id|url> # report that a cloned run worked (or: copyrun failed)
30
+ ```
31
+
32
+ Run `copyrun help <command>` for a command's options, or `copyrun help --all` for the full list.
33
+
34
+ ### Sharing a session
35
+
36
+ ```sh
37
+ copyrun share --dry-run
38
+ ```
39
+
40
+ Before anything is uploaded, copyrun redacts the session on your machine: API keys, tokens, `.env` values, credentials in URLs, email addresses, home-directory paths and your username. It shows every removal, lists anything else that might identify you or your employer (company names, internal hosts, ticket ids) and asks before publishing. A session containing a private key is never uploaded.
41
+
42
+ You choose who can see the run:
43
+
44
+ | Flag | Visibility |
45
+ | --- | --- |
46
+ | `--private` | Only you. Requires `copyrun login`. |
47
+ | `--unlisted` | Anyone with the link. |
48
+ | `--public` | Listed and searchable. Never the default. |
49
+
50
+ `--shape` shares the prompts, commands and results without file contents or diffs.
51
+
52
+ ### Pull requests
53
+
54
+ `copyrun pr` adds a short block to the pull request description with a link to the run, whether it was signed by your device, which checks the session ran and how much of the diff it wrote. The diff comparison runs locally with git. It uses the [GitHub CLI](https://cli.github.com) to edit the PR; `copyrun pr --print` prints the block instead.
55
+
56
+ Maintainers can install the [copyrun GitHub App](https://copyrun.ai/docs/github-app) to add a check to pull requests.
57
+
58
+ ### Checking claims
59
+
60
+ `copyrun check` reads the latest session and flags claims that don't match what happened, such as "all tests pass" when the last test run failed. It runs entirely locally and needs no account. Add `--strict` to exit with a non-zero code for use in CI or git hooks.
61
+
62
+ ### Signing in
63
+
64
+ ```sh
65
+ copyrun login
66
+ ```
67
+
68
+ Signing in links shares to your profile and signs them with a key stored on this machine, so the run page can show it as Verified.
69
+
70
+ ## Configuration
71
+
72
+ | Variable | Description |
73
+ | --- | --- |
74
+ | `COPYRUN_API` | Server URL. Defaults to `https://copyrun.ai`. |
75
+ | `COPYRUN_HOME` | State directory. Defaults to `~/.copyrun`. |
76
+
77
+ ## Documentation
78
+
79
+ Full documentation is at [copyrun.ai/docs](https://copyrun.ai/docs). For questions and feedback, join the [community](https://copyrun.ai/community) or email hello@copyrun.ai.
80
+
81
+ ## License
82
+
83
+ [MIT](LICENSE)
@@ -0,0 +1,252 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/archive-hook.ts
4
+ import { spawn } from "child_process";
5
+ import { existsSync } from "fs";
6
+ import { dirname, join as join3 } from "path";
7
+ import { fileURLToPath } from "url";
8
+
9
+ // src/archive-logic.ts
10
+ var ARCHIVE_WORKER_SCRIPT = "archive-worker.mjs";
11
+ var ARCHIVE_SETTINGS_FILE = "archive.json";
12
+ var ARCHIVE_SPAWN_FILE = "archive-spawn.json";
13
+ var PENDING_DIR = "archive-pending";
14
+ var IDLE_MS = 10 * 60 * 1e3;
15
+ var SPAWN_THROTTLE_MS = 10 * 60 * 1e3;
16
+ var MAX_BODY_BYTES = 25 * 1024 * 1024;
17
+ var LOCK_STALE_MS = 15 * 60 * 1e3;
18
+ var MAX_BACKOFF_MS = 6 * 60 * 60 * 1e3;
19
+ var isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
20
+ var archiveActive = (s) => !!s && s.available && s.archive.enabled;
21
+ function hookShouldRecord(s, consented) {
22
+ if (!s) return true;
23
+ if (!(archiveActive(s) && s.archive.includeAll)) return false;
24
+ return !consented || consented.includeAll;
25
+ }
26
+ function safeId(s) {
27
+ return s.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 120) || "unknown";
28
+ }
29
+ function pointerName(p, cwdKey2) {
30
+ const id = p.sessionId ? safeId(p.sessionId) : `cwd-${cwdKey2(p.cwd ?? "")}`;
31
+ return `${p.tool === "claude-code" ? "cc" : p.tool}-${id}.json`;
32
+ }
33
+ function claudeHookInput(raw) {
34
+ if (!isObj(raw)) return {};
35
+ const str = (v) => typeof v === "string" && v.length < 4096 ? v : void 0;
36
+ return { sessionId: str(raw.session_id), transcriptPath: str(raw.transcript_path), cwd: str(raw.cwd) };
37
+ }
38
+ function codexNotifyInput(raw) {
39
+ if (!isObj(raw)) return void 0;
40
+ if (raw.type !== void 0 && raw.type !== "agent-turn-complete") return void 0;
41
+ const str = (v) => typeof v === "string" && v.length < 4096 ? v : void 0;
42
+ return { sessionId: str(raw["thread-id"]) ?? str(raw["session-id"]) ?? str(raw.thread_id), cwd: str(raw.cwd) };
43
+ }
44
+ function mergePointer(prev, next, now) {
45
+ return {
46
+ v: 1,
47
+ ...prev,
48
+ ...Object.fromEntries(Object.entries(next).filter(([, v]) => v !== void 0)),
49
+ ended: !!prev?.ended || next.ended,
50
+ firstSeen: prev?.firstSeen ?? now,
51
+ updatedAt: now
52
+ };
53
+ }
54
+
55
+ // src/archive-hook-core.ts
56
+ import { createHash as createHash2 } from "crypto";
57
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync as renameSync2, writeFileSync as writeFileSync2 } from "fs";
58
+ import { join as join2 } from "path";
59
+
60
+ // src/session-seal.ts
61
+ import { createHash, createPrivateKey, sign } from "crypto";
62
+ import { closeSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, unlinkSync, writeFileSync } from "fs";
63
+ import { join } from "path";
64
+ var SEAL_QUEUE_DIR = "seal-queue";
65
+ var SEAL_LOG_FILE = "session-seals.json";
66
+ var HOOK_MAX_BYTES = 64 * 1024 * 1024;
67
+ var SEAL_SPAWN_THROTTLE_MS = 5 * 60 * 1e3;
68
+ var sessionSealMessage = (hex) => `copyrun-session-v1:${hex}`;
69
+ function readJson(path) {
70
+ try {
71
+ return JSON.parse(readFileSync(path, "utf8"));
72
+ } catch {
73
+ return void 0;
74
+ }
75
+ }
76
+ function writeJsonAtomic(path, value) {
77
+ const tmp = `${path}.${process.pid}.tmp`;
78
+ writeFileSync(tmp, JSON.stringify(value), { mode: 384 });
79
+ renameSync(tmp, path);
80
+ }
81
+ var safe = (s) => s.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 120) || "unknown";
82
+ function deviceOf(dir) {
83
+ const d = readJson(join(dir, "config.json"))?.device;
84
+ return d && typeof d.privateKey === "string" && typeof d.token === "string" && typeof d.api === "string" ? d : void 0;
85
+ }
86
+ function sealingEnabled(dir) {
87
+ const cfg = readJson(join(dir, "config.json"));
88
+ if (!cfg || cfg.sealSessions === false) return false;
89
+ return cfg.sealSessions === true || !!cfg.archive;
90
+ }
91
+ function fileSha256(path, max = Infinity) {
92
+ let fd;
93
+ try {
94
+ const st = statSync(path);
95
+ if (!st.isFile() || st.size > max) return void 0;
96
+ fd = openSync(path, "r");
97
+ } catch {
98
+ return void 0;
99
+ }
100
+ try {
101
+ const h = createHash("sha256");
102
+ const buf = Buffer.alloc(1 << 20);
103
+ let bytes = 0;
104
+ for (; ; ) {
105
+ const n = readSync(fd, buf, 0, buf.length, null);
106
+ if (!n) break;
107
+ h.update(buf.subarray(0, n));
108
+ bytes += n;
109
+ }
110
+ return { sha256: h.digest("hex"), bytes };
111
+ } catch {
112
+ return void 0;
113
+ } finally {
114
+ closeSync(fd);
115
+ }
116
+ }
117
+ function readSealLog(dir) {
118
+ const v = readJson(join(dir, SEAL_LOG_FILE));
119
+ return Array.isArray(v) ? v.filter((e) => e && typeof e.sha256 === "string") : [];
120
+ }
121
+ var signWith = (message, privateKeyPem) => sign(null, Buffer.from(message, "utf8"), createPrivateKey(privateKeyPem)).toString("base64");
122
+ function queueSeal(dir, r, now = Date.now()) {
123
+ const qdir = join(dir, SEAL_QUEUE_DIR);
124
+ mkdirSync(qdir, { recursive: true, mode: 448 });
125
+ const name = `${r.kind}-${safe(r.kind === "session" && r.sessionId ? r.sessionId : r.sha256)}.json`;
126
+ const at = new Date(now).toISOString();
127
+ writeJsonAtomic(join(qdir, name), { v: 1, ...r, createdAt: at, attempts: 0, nextAttemptAt: at });
128
+ return join(qdir, name);
129
+ }
130
+ function queueSessionSeal(dir, s) {
131
+ if (!s.force && !sealingEnabled(dir)) return { queued: false, reason: "disabled" };
132
+ const device = deviceOf(dir);
133
+ if (!device) return { queued: false, reason: "not-logged-in" };
134
+ const f = fileSha256(s.transcriptPath, s.maxBytes ?? HOOK_MAX_BYTES);
135
+ if (!f) return { queued: false, reason: "unreadable" };
136
+ if (readSealLog(dir).some((e) => e.kind === "session" && e.sha256 === f.sha256 && e.api === device.api)) return { queued: false, reason: "already-sealed" };
137
+ const path = queueSeal(
138
+ dir,
139
+ { kind: "session", sha256: f.sha256, bytes: f.bytes, sessionId: s.sessionId, tool: s.tool, signature: signWith(sessionSealMessage(f.sha256), device.privateKey), publicKey: device.publicKey, api: device.api },
140
+ s.now
141
+ );
142
+ return { queued: true, sha256: f.sha256, bytes: f.bytes, path };
143
+ }
144
+
145
+ // src/archive-hook-core.ts
146
+ function readJson2(path) {
147
+ try {
148
+ return JSON.parse(readFileSync2(path, "utf8"));
149
+ } catch {
150
+ return void 0;
151
+ }
152
+ }
153
+ function writeJsonAtomic2(path, value) {
154
+ const tmp = `${path}.${process.pid}.tmp`;
155
+ writeFileSync2(tmp, JSON.stringify(value), { mode: 384 });
156
+ renameSync2(tmp, path);
157
+ }
158
+ var cwdKey = (cwd) => createHash2("sha256").update(cwd).digest("hex").slice(0, 16);
159
+ function runHook(event, input, o) {
160
+ const now = o.now ?? Date.now();
161
+ const cfg = readJson2(join2(o.dir, "config.json"))?.archive;
162
+ if (!cfg || typeof cfg.org !== "string" || typeof cfg.api !== "string") return { spawned: false, skipped: "not-set-up" };
163
+ const cache = readJson2(join2(o.dir, ARCHIVE_SETTINGS_FILE))?.[`${cfg.api}|${cfg.org}`];
164
+ let pointer;
165
+ let skipped;
166
+ const fields = event === "codex" ? codexNotifyInput(input) : (() => {
167
+ const c = claudeHookInput(input);
168
+ return c.sessionId || c.transcriptPath ? c : void 0;
169
+ })();
170
+ if (!fields) skipped = event === "codex" ? "bad-input" : "no-session";
171
+ else if (!hookShouldRecord(cache, cfg.scope)) skipped = "not-archived";
172
+ else {
173
+ const tool = event === "codex" ? "codex" : "claude-code";
174
+ const dir = join2(o.dir, PENDING_DIR);
175
+ mkdirSync2(dir, { recursive: true, mode: 448 });
176
+ const name = pointerName({ tool, sessionId: fields.sessionId, cwd: fields.cwd }, cwdKey);
177
+ const path = join2(dir, name);
178
+ const prev = readJson2(path);
179
+ const next = mergePointer(
180
+ prev && prev.v === 1 ? prev : void 0,
181
+ { tool, org: cfg.org, api: cfg.api, sessionId: fields.sessionId, transcriptPath: fields.transcriptPath, cwd: fields.cwd, ended: event === "session-end" },
182
+ new Date(now).toISOString()
183
+ );
184
+ writeJsonAtomic2(path, next);
185
+ pointer = path;
186
+ }
187
+ let sealed;
188
+ const transcript = fields?.transcriptPath;
189
+ if (transcript && event !== "codex") {
190
+ try {
191
+ sealed = queueSessionSeal(o.dir, { transcriptPath: transcript, sessionId: fields?.sessionId, tool: "claude-code", now });
192
+ } catch {
193
+ }
194
+ }
195
+ const spawnPath = join2(o.dir, ARCHIVE_SPAWN_FILE);
196
+ const last = readJson2(spawnPath)?.at ?? 0;
197
+ const due = event === "session-end" || !(last <= now && now - last < SPAWN_THROTTLE_MS);
198
+ let spawned = false;
199
+ if (due) {
200
+ try {
201
+ writeJsonAtomic2(spawnPath, { at: now });
202
+ } catch {
203
+ }
204
+ o.spawnWorker();
205
+ spawned = true;
206
+ }
207
+ return { pointer, spawned, ...skipped ? { skipped } : {}, ...sealed ? { seal: sealed } : {} };
208
+ }
209
+
210
+ // src/archive-hook.ts
211
+ var DIR = dirname(fileURLToPath(import.meta.url));
212
+ function readStdin() {
213
+ return new Promise((resolve) => {
214
+ if (process.stdin.isTTY) return resolve("");
215
+ let data = "";
216
+ const t = setTimeout(() => resolve(data), 500);
217
+ process.stdin.setEncoding("utf8");
218
+ process.stdin.on("data", (d) => {
219
+ data += d;
220
+ if (data.length > 1e6) process.stdin.destroy();
221
+ });
222
+ process.stdin.on("end", () => (clearTimeout(t), resolve(data)));
223
+ process.stdin.on("error", () => (clearTimeout(t), resolve(data)));
224
+ });
225
+ }
226
+ function spawnWorker() {
227
+ const worker = join3(DIR, ARCHIVE_WORKER_SCRIPT);
228
+ if (!existsSync(worker)) return;
229
+ const child = spawn(process.execPath, [worker], {
230
+ detached: true,
231
+ stdio: "ignore",
232
+ windowsHide: true,
233
+ env: { ...process.env, COPYRUN_HOME: DIR }
234
+ });
235
+ child.on("error", () => {
236
+ });
237
+ child.unref();
238
+ }
239
+ async function main() {
240
+ const event = process.argv[2];
241
+ if (event !== "stop" && event !== "session-end" && event !== "codex") return;
242
+ const raw = event === "codex" ? process.argv[3] ?? "" : await readStdin();
243
+ let input;
244
+ try {
245
+ input = JSON.parse(raw);
246
+ } catch {
247
+ input = void 0;
248
+ }
249
+ runHook(event, input, { dir: DIR, spawnWorker });
250
+ }
251
+ main().catch(() => {
252
+ }).finally(() => process.exit(0));