copyrun 0.1.0 → 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
+ });
File without changes
package/package.json CHANGED
@@ -1,15 +1,16 @@
1
1
  {
2
2
  "name": "copyrun",
3
- "version": "0.1.0",
4
- "description": "Receipts for AI-written code. Share what your coding agent did, attach it to the pull request, and reuse runs that worked.",
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
5
  "keywords": [
6
6
  "ai",
7
7
  "coding-agent",
8
8
  "claude-code",
9
9
  "codex",
10
10
  "cursor",
11
+ "agents",
12
+ "human-in-the-loop",
11
13
  "pull-request",
12
- "code-review",
13
14
  "provenance",
14
15
  "cli"
15
16
  ],
@@ -31,17 +32,17 @@
31
32
  "engines": {
32
33
  "node": ">=20"
33
34
  },
34
- "devDependencies": {
35
- "tsup": "8.5.1",
36
- "@copyrun/core": "0.1.0",
37
- "@copyrun/extract": "0.1.0",
38
- "@copyrun/datapacks": "0.1.0"
39
- },
40
35
  "scripts": {
41
36
  "build": "tsup",
42
37
  "dev": "tsup --watch",
43
38
  "test": "vitest run",
44
39
  "typecheck": "tsc --noEmit",
45
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:*"
46
47
  }
47
- }
48
+ }