opencrater 0.2.5 → 1.0.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/package.json CHANGED
@@ -1,33 +1,55 @@
1
1
  {
2
2
  "name": "opencrater",
3
- "version": "0.2.5",
4
- "description": "OpenCrater — sponsor cards in Claude Code and Codex. Free, one command, opt out anytime.",
3
+ "version": "1.0.0",
4
+ "description": "OpenCrater — sponsor cards for AI coding terminals (Claude Code, Codex, Gemini CLI, Copilot CLI). One command, auto-wires every host, opt out anytime.",
5
5
  "keywords": [
6
6
  "opencrater",
7
- "claude-code",
8
- "codex",
7
+ "sponsorship",
8
+ "cli",
9
+ "mcp",
9
10
  "terminal",
10
- "sponsor"
11
+ "ads"
11
12
  ],
12
13
  "homepage": "https://opencrater.to",
13
- "license": "Apache-2.0",
14
+ "license": "SEE LICENSE IN LICENSE",
14
15
  "type": "commonjs",
15
16
  "engines": {
16
17
  "node": ">=18.17"
17
18
  },
18
19
  "bin": {
19
- "opencrater": "cli.js"
20
+ "opencrater": "dist/cli.js",
21
+ "opencrater-hook": "dist/hook.js",
22
+ "opencrater-paint": "dist/paint.js",
23
+ "opencrater-dismiss": "dist/dismiss.js"
24
+ },
25
+ "main": "./dist/index.js",
26
+ "module": "./dist/index.mjs",
27
+ "types": "./dist/index.d.ts",
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/index.d.ts",
31
+ "import": "./dist/index.mjs",
32
+ "require": "./dist/index.js"
33
+ },
34
+ "./package.json": "./package.json"
20
35
  },
21
36
  "files": [
22
- "cli.js",
37
+ "dist",
23
38
  "README.md",
24
39
  "LICENSE"
25
40
  ],
26
- "repository": {
27
- "type": "git",
28
- "url": "git+https://github.com/open-crater/ads.git"
41
+ "scripts": {
42
+ "build": "tsup",
43
+ "typecheck": "tsc --noEmit",
44
+ "test": "vitest run",
45
+ "test:watch": "vitest",
46
+ "version": "node scripts/sync-version.mjs && git add src/version.ts",
47
+ "prepublishOnly": "npm run typecheck && npm run build && npm test"
29
48
  },
30
- "bugs": {
31
- "url": "https://github.com/open-crater/ads/issues"
49
+ "devDependencies": {
50
+ "@types/node": "^18.19.0",
51
+ "tsup": "^8.0.0",
52
+ "typescript": "^5.4.0",
53
+ "vitest": "^3.0.0"
32
54
  }
33
55
  }
package/cli.js DELETED
@@ -1,416 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * opencrater — the DIRECT channel: any Claude Code / Codex user can opt
4
- * in to OpenCrater sponsor cards with one command, no publisher needed.
5
- *
6
- * npx opencrater on enable (Claude Code + Codex hooks)
7
- * npx opencrater off disable (removes only our entries)
8
- * npx opencrater status show what is installed
9
- *
10
- * Coexistence with publisher packages that embed OpenCrater:
11
- * - hook entries are marker-isolated ("--package opencrater" identifies
12
- * ours), so installs/uninstalls never touch a publisher's hooks;
13
- * - the SDK's machine-wide frequency cap (~/.config/opencrater/state.json)
14
- * means at most ONE card per interval across ALL sources, and the
15
- * painter's lock file guarantees only one card is ever on screen.
16
- *
17
- * Rendering runs through @opencrater/sdk (opencrater-hook), the same engine
18
- * publishers use. Opt out anytime: `npx opencrater off` or
19
- * OPENCRATER_DISABLE=1.
20
- */
21
- "use strict";
22
- const fs = require("node:fs");
23
- const path = require("node:path");
24
- const os = require("node:os");
25
-
26
- // Default = the house channel. Publishers integrating OpenCrater into
27
- // their OWN package pass their credentials instead:
28
- // npx opencrater on --key ock_… --package my-tool
29
- // npx opencrater off --package my-tool
30
- // so revenue attributes to them and `off` removes only their entries.
31
- // Flags are parsed up front; everything below reads KEY/PKG.
32
- let KEY = "ock_MUFntM66VEbx7e7fKoQr2a43VBx762rHkKw1G8vj";
33
- let PKG = "opencrater";
34
- {
35
- const argv = process.argv;
36
- for (let i = 2; i < argv.length; i++) {
37
- if (argv[i] === "--key" && argv[i + 1]) KEY = argv[++i];
38
- else if (argv[i] === "--package" && argv[i + 1]) PKG = argv[++i];
39
- }
40
- if (!/^ock_[A-Za-z0-9]{8,64}$/.test(KEY)) {
41
- console.error("opencrater: --key must look like ock_…");
42
- process.exit(1);
43
- }
44
- if (!/^[A-Za-z0-9@/_.-]{1,128}$/.test(PKG)) {
45
- console.error("opencrater: --package must be 1-128 chars of [a-zA-Z0-9@/_.-]");
46
- process.exit(1);
47
- }
48
- }
49
- // EVERY host hook is registered as a trigger; the SDK + platform decide
50
- // which ones actually render (recommended session-edge hooks by default,
51
- // remotely tunable). Registering everything also feeds the recommendation
52
- // engine's anonymized topic signal so cards match what you're working on.
53
- const CLAUDE_EVENTS = [
54
- "SessionStart", "SessionEnd", "Stop", "StopFailure", "SubagentStart", "SubagentStop",
55
- "Notification", "PreToolUse", "PostToolUse", "PostToolUseFailure", "PostToolBatch",
56
- "PermissionRequest", "PermissionDenied", "UserPromptSubmit", "UserPromptExpansion",
57
- "PreCompact", "PostCompact", "TaskCreated", "TaskCompleted", "Setup", "TeammateIdle",
58
- "Elicitation", "ElicitationResult", "ConfigChange", "InstructionsLoaded",
59
- "WorktreeCreate", "WorktreeRemove", "CwdChanged", "FileChanged", "MessageDisplay",
60
- ];
61
- // Gemini CLI hooks (verified HookEventName set, GA since v0.26.0):
62
- // ~/.gemini/settings.json takes the same rule shape as Claude Code.
63
- const GEMINI_EVENTS = [
64
- "SessionStart", "SessionEnd", "BeforeAgent", "AfterAgent", "BeforeModel",
65
- "AfterModel", "BeforeToolSelection", "BeforeTool", "AfterTool",
66
- "PreCompress", "Notification",
67
- ];
68
- const CODEX_EVENTS = [
69
- "SessionStart", "Stop", "UserPromptSubmit", "PreToolUse", "PostToolUse",
70
- "PermissionRequest", "PreCompact", "PostCompact",
71
- ];
72
- const MARKER = "--package " + PKG + " "; // identifies OUR hook entries only
73
-
74
- // Resilient hook command: prefer the pre-installed local runtime (no npx on
75
- // the hot path — concurrent fires racing a cold npx cache caused
76
- // "opencrater-hook: command not found" noise in the host); fall back to npx;
77
- // ALWAYS exit 0 with stderr silenced — a sponsor hook must never surface an
78
- // error inside someone's session.
79
- const cmdFor = (event, host) => {
80
- const args = "--placement " + event + " --key " + KEY +
81
- " --package " + PKG + " --host " + host;
82
- const runtime = '"$HOME/.config/opencrater/runtime/node_modules/@opencrater/sdk/dist/hook.js"';
83
- return (
84
- "{ if [ -f " + runtime + " ]; then node " + runtime + " " + args +
85
- "; else npx -y -p @opencrater/sdk opencrater-hook " + args +
86
- "; fi; } 2>/dev/null || true"
87
- );
88
- };
89
-
90
- const CLAUDE_SETTINGS = path.join(os.homedir(), ".claude", "settings.json");
91
- const CODEX_HOOKS = path.join(
92
- process.env.CODEX_HOME || path.join(os.homedir(), ".codex"),
93
- "hooks.json",
94
- );
95
- const GEMINI_SETTINGS = path.join(os.homedir(), ".gemini", "settings.json");
96
-
97
- function readJson(p) {
98
- try {
99
- const raw = fs.readFileSync(p, "utf8");
100
- if (!raw.trim()) return {};
101
- const v = JSON.parse(raw);
102
- return v && typeof v === "object" && !Array.isArray(v) ? v : {};
103
- } catch (e) {
104
- if (e.code === "ENOENT") return {};
105
- throw new Error("Could not parse " + p + " — fix it by hand and retry.");
106
- }
107
- }
108
-
109
- function writeJson(p, v) {
110
- fs.mkdirSync(path.dirname(p), { recursive: true });
111
- const tmp = p + ".opencrater-tmp";
112
- fs.writeFileSync(tmp, JSON.stringify(v, null, 2) + "\n");
113
- fs.renameSync(tmp, p); // atomic — never leave a half-written settings file
114
- }
115
-
116
- const isOurs = (rule) =>
117
- Array.isArray(rule.hooks) &&
118
- rule.hooks.some(
119
- (h) => typeof h.command === "string" &&
120
- h.command.includes("opencrater-hook") && h.command.includes(MARKER),
121
- );
122
-
123
- function enableIn(file, host) {
124
- const events =
125
- host === "codex" ? CODEX_EVENTS :
126
- host === "gemini_cli" ? GEMINI_EVENTS : CLAUDE_EVENTS;
127
- const settings = readJson(file);
128
- if (!settings.hooks) settings.hooks = {};
129
- let changed = false;
130
- for (const event of events) {
131
- const list = Array.isArray(settings.hooks[event]) ? settings.hooks[event] : [];
132
- const desired = {
133
- matcher: "",
134
- hooks: [{ type: "command", command: cmdFor(event, host) }],
135
- };
136
- // Remove EVERY entry of ours, then add exactly one. findIndex+replace only
137
- // fixed the first match, so repeated installs / older command shapes left
138
- // duplicate opencrater-hook entries piling up. Filtering collapses them.
139
- const others = list.filter((r) => !isOurs(r));
140
- const mine = list.filter(isOurs);
141
- const alreadyCorrect =
142
- mine.length === 1 && JSON.stringify(mine[0]) === JSON.stringify(desired);
143
- if (!alreadyCorrect) {
144
- settings.hooks[event] = [...others, desired];
145
- changed = true;
146
- }
147
- }
148
- if (changed) writeJson(file, settings);
149
- return changed;
150
- }
151
-
152
- function disableIn(file) {
153
- const settings = readJson(file);
154
- if (!settings.hooks) return false;
155
- let changed = false;
156
- for (const event of Object.keys(settings.hooks)) {
157
- const list = settings.hooks[event];
158
- if (!Array.isArray(list)) continue;
159
- const kept = list.filter((r) => !isOurs(r));
160
- if (kept.length !== list.length) {
161
- changed = true;
162
- if (kept.length === 0) delete settings.hooks[event];
163
- else settings.hooks[event] = kept;
164
- }
165
- }
166
- if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
167
- if (changed) writeJson(file, settings);
168
- return changed;
169
- }
170
-
171
- function statusIn(file) {
172
- const settings = readJson(file);
173
- if (!settings.hooks) return [];
174
- return Object.keys(settings.hooks).filter(
175
- (ev) => Array.isArray(settings.hooks[ev]) && settings.hooks[ev].some(isOurs),
176
- );
177
- }
178
-
179
- /* Dismiss the sponsor card currently on screen — no browser needed.
180
- Writes the painter's local dismiss signal, records the dismissal against
181
- the API (idempotent), and SIGTERMs the painter so the card clears NOW. */
182
- async function dismissNow() {
183
- const dir = path.join(os.homedir(), ".config", "opencrater");
184
- let lock = null;
185
- try {
186
- lock = JSON.parse(fs.readFileSync(path.join(dir, "painter.lock"), "utf8"));
187
- } catch {
188
- console.log("opencrater: no sponsor card is on screen.");
189
- return;
190
- }
191
- try {
192
- fs.mkdirSync(dir, { recursive: true });
193
- fs.writeFileSync(
194
- path.join(dir, "dismiss.signal"),
195
- JSON.stringify({ impressionId: lock.impressionId, at: Date.now() }),
196
- );
197
- } catch {}
198
- if (lock.dismissUrl) {
199
- try { await fetch(lock.dismissUrl, { signal: AbortSignal.timeout(1000) }); } catch {}
200
- }
201
- if (lock.impressionId) {
202
- const origin = process.env.OPENCRATER_API_ORIGIN || "https://api.opencrater.to";
203
- try {
204
- await fetch(origin + "/x/" + lock.impressionId, { signal: AbortSignal.timeout(2500) });
205
- } catch {}
206
- }
207
- if (lock.pid && lock.pid > 1) {
208
- try { process.kill(lock.pid, "SIGTERM"); } catch {}
209
- }
210
- console.log("opencrater: ad dismissed — feedback recorded.");
211
- console.log(" changed your mind? npx opencrater show");
212
- }
213
-
214
- /* Report the sponsor card currently on screen (or the last one shown).
215
- Blocks the campaign on this machine permanently (reported-ads.json — the
216
- SDK skips reported campaigns forever), clears the card, and opens the
217
- hosted report screen so the user can pick a reason. */
218
- async function reportAd() {
219
- const dir = path.join(os.homedir(), ".config", "opencrater");
220
- let lock = null;
221
- let saved = null;
222
- try { lock = JSON.parse(fs.readFileSync(path.join(dir, "painter.lock"), "utf8")); } catch {}
223
- try { saved = JSON.parse(fs.readFileSync(path.join(dir, "last-ad.json"), "utf8")); } catch {}
224
- const impressionId = (lock && lock.impressionId) || (saved && saved.payload && saved.payload.impressionId);
225
- const campaignId = saved && saved.payload && saved.payload.campaignId;
226
- if (!impressionId && !campaignId) {
227
- console.log("opencrater: no sponsor card to report.");
228
- return;
229
- }
230
- // permanent local block — never render this campaign here again
231
- if (campaignId) {
232
- const file = path.join(dir, "reported-ads.json");
233
- let reported = {};
234
- try { reported = JSON.parse(fs.readFileSync(file, "utf8")) || {}; } catch {}
235
- reported[campaignId] = Date.now();
236
- try {
237
- fs.mkdirSync(dir, { recursive: true });
238
- fs.writeFileSync(file, JSON.stringify(reported));
239
- } catch {}
240
- }
241
- // clear the live card, if any
242
- if (lock) {
243
- try {
244
- fs.writeFileSync(
245
- path.join(dir, "dismiss.signal"),
246
- JSON.stringify({ impressionId: lock.impressionId, at: Date.now() }),
247
- );
248
- } catch {}
249
- if (lock.pid && lock.pid > 1) {
250
- try { process.kill(lock.pid, "SIGTERM"); } catch {}
251
- }
252
- }
253
- console.log("opencrater: reported — you won't see this ad on this machine again.");
254
- if (impressionId) {
255
- const origin = process.env.OPENCRATER_API_ORIGIN || "https://api.opencrater.to";
256
- const url = origin + "/r/" + impressionId;
257
- const { spawnSync } = require("node:child_process");
258
- const opener = process.platform === "darwin" ? "open" : "xdg-open";
259
- const res = spawnSync(opener, [url], { stdio: "ignore" });
260
- if (res.status === 0) {
261
- console.log(" tell us why in the tab that just opened (10 seconds).");
262
- } else {
263
- console.log(" tell us why (optional): " + url);
264
- }
265
- }
266
- }
267
-
268
- /* Repaint the last sponsor card ("show ad again"). Reads the payload the
269
- painter saved, resolves THIS terminal's tty, and spawns the local-runtime
270
- painter detached — exactly how the hook does it. */
271
- function showAgain() {
272
- const dir = path.join(os.homedir(), ".config", "opencrater");
273
- let saved = null;
274
- try {
275
- saved = JSON.parse(fs.readFileSync(path.join(dir, "last-ad.json"), "utf8"));
276
- } catch {
277
- console.log("opencrater: no recent ad to show.");
278
- return;
279
- }
280
- const painter = path.join(dir, "runtime", "node_modules", "@opencrater", "sdk", "dist", "paint.js");
281
- if (!fs.existsSync(painter)) {
282
- console.log("opencrater: hook runtime not installed yet — run: npx opencrater on");
283
- return;
284
- }
285
- const { spawnSync, spawn } = require("node:child_process");
286
- const isWin = process.platform === "win32";
287
- // Replay: live ✕ (local), but never re-poll or re-record the original
288
- // impression — it was already dismissed once.
289
- const payload = { ...saved.payload, replay: true };
290
- delete payload.impressionId;
291
- const env = { ...process.env, OPENCRATER_PAINT: JSON.stringify(payload) };
292
- // Unix: resolve our terminal device so the detached painter can open it.
293
- // Windows: no `tty` command and no device path — the painter opens CONOUT$
294
- // of the console it inherits, so we must NOT detach (detach = new/hidden
295
- // console = nothing visible). Mirror the SDK's painterSpawnOptions.
296
- if (!isWin) {
297
- const tty = spawnSync("tty", { stdio: ["inherit", "pipe", "ignore"], encoding: "utf8" })
298
- .stdout?.trim();
299
- if (tty && tty.startsWith("/dev/")) env.OPENCRATER_TTY = tty;
300
- }
301
- const child = spawn(process.execPath, [painter], {
302
- detached: !isWin,
303
- stdio: "ignore",
304
- env,
305
- ...(isWin ? { windowsHide: true } : {}),
306
- });
307
- child.on("error", () => {});
308
- child.unref();
309
- console.log("opencrater: showing the last ad again.");
310
- }
311
-
312
- /* Auto-detect which AI CLIs exist on this machine — `on` connects every
313
- detected host's config automatically, and new hosts picked up here flow
314
- into detection as we add them. */
315
- function hasBin(name) {
316
- try {
317
- const { spawnSync } = require("node:child_process");
318
- return spawnSync("which", [name], { stdio: "ignore" }).status === 0;
319
- } catch { return false; }
320
- }
321
- function detectHosts() {
322
- return {
323
- claude_code: fs.existsSync(path.join(os.homedir(), ".claude")) || hasBin("claude"),
324
- codex: fs.existsSync(path.join(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"))) || hasBin("codex"),
325
- openclaw: fs.existsSync(path.join(os.homedir(), ".openclaw")) || hasBin("openclaw"),
326
- gemini_cli: fs.existsSync(path.join(os.homedir(), ".gemini")) || hasBin("gemini"),
327
- };
328
- }
329
-
330
- const cmd = process.argv[2] || "status";
331
- if (cmd === "on" || cmd === "enable") {
332
- const hosts = detectHosts();
333
- if (!hosts.claude_code && !hosts.codex) {
334
- console.log("No supported AI CLI found (looked for Claude Code and Codex).");
335
- console.log("Install one, then run: npx opencrater on");
336
- process.exit(1);
337
- }
338
- if (hosts.claude_code) {
339
- enableIn(CLAUDE_SETTINGS, "claude_code");
340
- console.log("✓ Claude Code detected — connected (" + CLAUDE_SETTINGS + ")");
341
- } else {
342
- console.log("· Claude Code not found — skipped");
343
- }
344
- if (hosts.codex) {
345
- enableIn(CODEX_HOOKS, "codex");
346
- console.log("✓ Codex detected — connected (" + CODEX_HOOKS + ")");
347
- console.log(" Codex will ask once to trust the new hooks on its next launch — approve to enable.");
348
- } else {
349
- console.log("· Codex not found — skipped");
350
- }
351
- if (hosts.gemini_cli) {
352
- enableIn(GEMINI_SETTINGS, "gemini_cli");
353
- console.log("✓ Gemini CLI detected — connected (" + GEMINI_SETTINGS + ")");
354
- } else {
355
- console.log("· Gemini CLI not found — skipped");
356
- }
357
- if (hosts.openclaw) {
358
- console.log("· OpenClaw detected — its hooks run inside the gateway (no terminal");
359
- console.log(" overlay surface); message-level sponsorship is on the roadmap.");
360
- }
361
- // Pre-install the local hook runtime (background): hooks then run via
362
- // plain node — no npx resolution latency, no cold-cache races. Best-effort
363
- // and non-fatal — hooks fall back to `npx -y` when the runtime is absent.
364
- try {
365
- const { spawn } = require("node:child_process");
366
- // Windows: `npm` is `npm.cmd`, spawnable only through a shell — without
367
- // shell:true this throws ENOENT as an async 'error' event that escapes
368
- // try/catch and crashes the process. shell:true + an error handler keep
369
- // it silent on every platform.
370
- const isWin = process.platform === "win32";
371
- const child = spawn("npm", ["install", "--prefix",
372
- path.join(os.homedir(), ".config", "opencrater", "runtime"),
373
- "@opencrater/sdk@latest", "--silent", "--no-audit", "--no-fund"],
374
- { detached: true, stdio: "ignore", shell: isWin, windowsHide: true });
375
- child.on("error", () => {});
376
- child.unref();
377
- } catch {}
378
- console.log("OpenCrater ads enabled for Claude Code + Codex.");
379
- console.log("All hooks registered as triggers; cards render only at session edges");
380
- console.log("(SessionStart, Stop, SessionEnd, Notification) with a machine-wide frequency cap.");
381
- console.log("Ads are personalized to what you're working on via anonymized keywords");
382
- console.log("(never raw prompts, paths, or secrets).");
383
- console.log("Turn off anytime: npx opencrater off (or OPENCRATER_DISABLE=1)");
384
- console.log("");
385
- console.log("VS Code tip: the first time you ⌘-click a sponsor link, choose");
386
- console.log("'Configure Trusted Domains' → trust api.opencrater.to and the");
387
- console.log("confirmation dialog never appears again.");
388
- } else if (cmd === "off" || cmd === "disable") {
389
- const a = disableIn(CLAUDE_SETTINGS);
390
- const b = disableIn(CODEX_HOOKS);
391
- const c = disableIn(GEMINI_SETTINGS);
392
- void c;
393
- console.log(a || b ? "OpenCrater ads disabled. Publisher hooks (if any) were not touched." : "Nothing to remove — OpenCrater ads were not enabled.");
394
- } else if (cmd === "x" || cmd === "dismiss") {
395
- dismissNow();
396
- } else if (cmd === "report" || cmd === "flag") {
397
- reportAd();
398
- } else if (cmd === "show" || cmd === "again") {
399
- showAgain();
400
- } else if (cmd === "status") {
401
- const hosts = detectHosts();
402
- const cc = statusIn(CLAUDE_SETTINGS);
403
- const cx = statusIn(CODEX_HOOKS);
404
- const gm = statusIn(GEMINI_SETTINGS);
405
- console.log("Claude Code:", !hosts.claude_code ? "not installed" : cc.length ? "enabled (" + cc.length + " hooks)" : "detected — run: npx opencrater on");
406
- console.log("Codex: ", !hosts.codex ? "not installed" : cx.length ? "enabled (" + cx.length + " hooks)" : "detected — run: npx opencrater on");
407
- console.log("Gemini CLI:", !hosts.gemini_cli ? "not installed" : gm.length ? "enabled (" + gm.length + " hooks)" : "detected — run: npx opencrater on");
408
- if (hosts.openclaw) console.log("OpenClaw: detected — message-level sponsorship on the roadmap");
409
- } else {
410
- console.log("Usage: npx opencrater <on|off|status|x|report|show> [--key ock_… --package name]");
411
- console.log(" --key/--package publisher attribution (defaults to the OpenCrater house channel)");
412
- console.log(" x dismiss the sponsor card currently on screen");
413
- console.log(" report report the ad (never shows on this machine again)");
414
- console.log(" show bring the last dismissed ad back");
415
- process.exitCode = 1;
416
- }