@promptai.credit/cli 0.1.0 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +36 -4
  2. package/dist/index.js +536 -43
  3. package/package.json +1 -6
package/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # promptai CLI
2
2
 
3
3
  Brings the earn loop (prompt → opt-in ad during the wait → server-side verification →
4
- credit → USDC claim) to terminal agents. Claude Code is supported today; Codex CLI is next.
4
+ credit → USDC claim) to agents beyond the classic Cursor IDE. Claude Code and the
5
+ Cursor Agents Window are supported today; Codex CLI is next.
5
6
 
6
7
  ## How it works
7
8
 
@@ -14,7 +15,29 @@ credit → USDC claim) to terminal agents. Claude Code is supported today; Codex
14
15
  server-verified ad session against it via the API.
15
16
  2. The `/watch` page creates the ad session server-side on load, so SSV wall-clock
16
17
  timing is enforced by the server; the browser countdown is cosmetic.
17
- 3. `promptai claim` pays out your verified balance as USDC on Base Sepolia.
18
+ 3. `promptai claim` pays out your verified balance as USDC on Base.
19
+
20
+ ## Cursor Agents Window (Glass)
21
+
22
+ The Agents Window doesn't run VS Code extensions, so the promptai extension can't
23
+ cover it. `promptai install cursor` wires `beforeSubmitPrompt` + `stop` into
24
+ `~/.cursor/hooks.json` (core Cursor hooks fire in both the Agents Window and the
25
+ classic IDE):
26
+
27
+ - Hook payloads are routed by shape (`cursor_version`/`conversation_id`), so events
28
+ reach the right adapter even if Cursor loads our Claude-format hooks through its
29
+ third-party mapping.
30
+ - Cursor 3.7+ reports real token usage on the `stop` payload (`input_tokens`,
31
+ `output_tokens`, cache splits); cost is priced from that, with cached reads/writes
32
+ discounted. On older versions the cost falls back to a transcript character-volume
33
+ **estimate** (Glass transcripts carry no token counts and redact assistant prose).
34
+ - When the classic IDE's extension host is running it already credits prompts, so
35
+ the CLI detects its listener file (live pid) and stands down - no double credit.
36
+ - Duplicate stop events for the same generation are collapsed with an atomic
37
+ once-lock in `~/.promptai/locks/`.
38
+ - On remote/SSH workspaces (`CURSOR_CODE_REMOTE`), the remote host has no display:
39
+ no ad tabs open there. Bank credits with `promptai watch` from any browser
40
+ (`/watch?device=<your device id>`) instead.
18
41
 
19
42
  State lives in `~/.promptai/` (`config.json`, `state.json`, `cli.log`).
20
43
 
@@ -22,13 +45,15 @@ State lives in `~/.promptai/` (`config.json`, `state.json`, `cli.log`).
22
45
 
23
46
  ```bash
24
47
  npm install -g @promptai.credit/cli
25
- promptai install claude
48
+ promptai install claude # Claude Code hooks
49
+ promptai install cursor # Cursor Agents Window / classic IDE hooks
26
50
 
27
51
  # then:
28
52
  promptai status # device, balance, banked ad watches, recent prompts
29
53
  promptai watch # open a rewarded ad now (banks a credit for later)
54
+ promptai set email you@x.com # override the account email (required for claims)
30
55
  promptai set wallet 0x... # payout address
31
- promptai claim # USDC on Base Sepolia
56
+ promptai claim # USDC on Base
32
57
  promptai set ads off # opt out any time
33
58
  ```
34
59
 
@@ -43,6 +68,13 @@ Developing from the repo instead: `pnpm --filter @promptai.credit/cli build`, th
43
68
  - Credits only accrue for turns funded by a **verified** ad watch, enforced
44
69
  server-side (same rules as the Cursor extension: one session funds one prompt,
45
70
  capped at $5).
71
+ - Monthly earn caps: $75/device and $150 cumulative per linked email (UTC
72
+ calendar month). Claims require a linked email that an admin has activated.
73
+ - The account email is auto-detected (Claude Code login in `~/.claude.json`,
74
+ the Cursor account in its state DB, then `git config user.email`) and linked
75
+ on the next credited prompt. Auto-detected emails link without verification;
76
+ `promptai set email` (manual entry or changing a linked email) sends a
77
+ 6-digit code to the address, which the command prompts for.
46
78
  - Headless environments (CI, ssh without a display) never get browser tabs and
47
79
  simply skip crediting.
48
80
  - Uninstall with `promptai uninstall claude`; other hooks in your settings are
package/dist/index.js CHANGED
@@ -1,10 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // src/index.ts
4
+ import * as fs6 from "node:fs";
5
+ import * as path5 from "node:path";
6
+ import * as readline from "node:readline";
7
+
3
8
  // src/claude.ts
4
9
  import * as crypto3 from "node:crypto";
5
- import * as fs3 from "node:fs";
6
- import * as os2 from "node:os";
7
- import * as path2 from "node:path";
10
+ import * as fs4 from "node:fs";
11
+ import * as os3 from "node:os";
12
+ import * as path3 from "node:path";
8
13
 
9
14
  // src/api.ts
10
15
  import * as crypto from "node:crypto";
@@ -39,6 +44,12 @@ function redeemCredit(serverUrl, params) {
39
44
  function fetchBalance(serverUrl, deviceId) {
40
45
  return request(`${serverUrl}/balance/${deviceId}`);
41
46
  }
47
+ function linkEmail(serverUrl, params) {
48
+ return post(serverUrl, "/devices/link", params);
49
+ }
50
+ function verifyEmailLink(serverUrl, params) {
51
+ return post(serverUrl, "/devices/link/verify", params);
52
+ }
42
53
  function claim(serverUrl, params) {
43
54
  return post(serverUrl, "/claim", {
44
55
  ...params,
@@ -90,7 +101,8 @@ function loadConfig() {
90
101
  deviceId: "",
91
102
  serverUrl: "https://api.promptai.credit",
92
103
  wallet: "",
93
- adsOptIn: true
104
+ adsOptIn: true,
105
+ email: ""
94
106
  });
95
107
  if (!config.deviceId) {
96
108
  config.deviceId = crypto2.randomUUID();
@@ -135,6 +147,112 @@ function log(message) {
135
147
  }
136
148
  }
137
149
 
150
+ // src/email.ts
151
+ import { execFileSync } from "node:child_process";
152
+ import * as fs2 from "node:fs";
153
+ import * as os2 from "node:os";
154
+ import * as path2 from "node:path";
155
+ var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
156
+ function clean(value) {
157
+ if (typeof value !== "string") return null;
158
+ const email = value.trim().toLowerCase();
159
+ return EMAIL_RE.test(email) ? email : null;
160
+ }
161
+ function fromClaudeJson() {
162
+ try {
163
+ const raw = fs2.readFileSync(path2.join(os2.homedir(), ".claude.json"), "utf8");
164
+ const data = JSON.parse(raw);
165
+ return clean(data.oauthAccount?.emailAddress);
166
+ } catch {
167
+ return null;
168
+ }
169
+ }
170
+ function cursorStateDbPath() {
171
+ const home = os2.homedir();
172
+ switch (process.platform) {
173
+ case "darwin":
174
+ return path2.join(
175
+ home,
176
+ "Library",
177
+ "Application Support",
178
+ "Cursor",
179
+ "User",
180
+ "globalStorage",
181
+ "state.vscdb"
182
+ );
183
+ case "win32":
184
+ return path2.join(
185
+ process.env.APPDATA ?? path2.join(home, "AppData", "Roaming"),
186
+ "Cursor",
187
+ "User",
188
+ "globalStorage",
189
+ "state.vscdb"
190
+ );
191
+ default:
192
+ return path2.join(home, ".config", "Cursor", "User", "globalStorage", "state.vscdb");
193
+ }
194
+ }
195
+ function fromCursorStateDb() {
196
+ const src = cursorStateDbPath();
197
+ if (!fs2.existsSync(src)) return null;
198
+ let dir = null;
199
+ try {
200
+ dir = fs2.mkdtempSync(path2.join(os2.tmpdir(), "promptai-email-"));
201
+ const db = path2.join(dir, "state.vscdb");
202
+ fs2.copyFileSync(src, db);
203
+ for (const suffix of ["-wal", "-shm"]) {
204
+ if (fs2.existsSync(src + suffix)) fs2.copyFileSync(src + suffix, db + suffix);
205
+ }
206
+ const stdout = execFileSync(
207
+ "sqlite3",
208
+ [db, "SELECT value FROM ItemTable WHERE key='cursorAuth/cachedEmail'"],
209
+ { encoding: "utf8", timeout: 5e3 }
210
+ ).trim();
211
+ if (stdout.startsWith('"')) {
212
+ try {
213
+ return clean(JSON.parse(stdout));
214
+ } catch {
215
+ return null;
216
+ }
217
+ }
218
+ return clean(stdout);
219
+ } catch {
220
+ return null;
221
+ } finally {
222
+ if (dir) fs2.rmSync(dir, { recursive: true, force: true });
223
+ }
224
+ }
225
+ function fromGitConfig() {
226
+ try {
227
+ return clean(
228
+ execFileSync("git", ["config", "--get", "user.email"], {
229
+ encoding: "utf8",
230
+ timeout: 5e3
231
+ })
232
+ );
233
+ } catch {
234
+ return null;
235
+ }
236
+ }
237
+ function detectEmail() {
238
+ const claude = fromClaudeJson();
239
+ if (claude) return { email: claude, source: "claude-account" };
240
+ const cursor = fromCursorStateDb();
241
+ if (cursor) return { email: cursor, source: "cursor-account" };
242
+ const git = fromGitConfig();
243
+ if (git) return { email: git, source: "git-config" };
244
+ return null;
245
+ }
246
+ function resolveEmail(config) {
247
+ if (config.email) return config.email;
248
+ const detected = detectEmail();
249
+ if (!detected) return "";
250
+ config.email = detected.email;
251
+ saveConfig(config);
252
+ log(`auto-detected email ${detected.email} (${detected.source})`);
253
+ return detected.email;
254
+ }
255
+
138
256
  // src/pricing.ts
139
257
  var TABLE = [
140
258
  { match: ["fable"], inputPerMtok: 3, outputPerMtok: 15 },
@@ -165,9 +283,9 @@ function costUsd(model, inputTokens, outputTokens) {
165
283
  }
166
284
 
167
285
  // src/transcript.ts
168
- import * as fs2 from "node:fs";
286
+ import * as fs3 from "node:fs";
169
287
  function readTranscriptUsage(transcriptPath, watermark) {
170
- const raw = fs2.readFileSync(transcriptPath, "utf8");
288
+ const raw = fs3.readFileSync(transcriptPath, "utf8");
171
289
  const byMessageId = /* @__PURE__ */ new Map();
172
290
  let parsedCount = 0;
173
291
  for (const line of raw.split("\n")) {
@@ -208,6 +326,34 @@ function readTranscriptUsage(transcriptPath, watermark) {
208
326
  seenCount: entries.length
209
327
  };
210
328
  }
329
+ function readGlassTranscriptUsage(transcriptPath, watermark) {
330
+ const raw = fs3.readFileSync(transcriptPath, "utf8");
331
+ const lines = raw.split("\n").filter((l) => l.trim());
332
+ let inputChars = 0;
333
+ let outputChars = 0;
334
+ let newEntries = 0;
335
+ for (const line of lines.slice(watermark.seenCount)) {
336
+ let entry;
337
+ try {
338
+ entry = JSON.parse(line);
339
+ } catch {
340
+ continue;
341
+ }
342
+ const chars = JSON.stringify(entry.message?.content ?? "").length;
343
+ if (entry.role === "assistant") {
344
+ outputChars += chars;
345
+ newEntries += 1;
346
+ } else {
347
+ inputChars += chars;
348
+ }
349
+ }
350
+ return {
351
+ inputTokens: Math.round(inputChars / 4),
352
+ outputTokens: Math.round(outputChars / 4),
353
+ newEntries,
354
+ seenCount: lines.length
355
+ };
356
+ }
211
357
 
212
358
  // src/claude.ts
213
359
  var SOURCE = "claude-code";
@@ -216,13 +362,13 @@ var MAX_CREDIT_PER_AD_USD = 5;
216
362
  var AD_MIN_INTERVAL_MS = 9e4;
217
363
  var SETTLE_RETRIES = [400, 800, 1500];
218
364
  function claudeSettingsPath() {
219
- return path2.join(os2.homedir(), ".claude", "settings.json");
365
+ return path3.join(os3.homedir(), ".claude", "settings.json");
220
366
  }
221
367
  function hookCommand() {
222
- let script = path2.resolve(process.argv[1] ?? "");
368
+ let script = path3.resolve(process.argv[1] ?? "");
223
369
  if (script.endsWith(".ts")) {
224
- const dist = path2.resolve(path2.dirname(script), "..", "dist", "index.js");
225
- if (!fs3.existsSync(dist)) {
370
+ const dist = path3.resolve(path3.dirname(script), "..", "dist", "index.js");
371
+ if (!fs4.existsSync(dist)) {
226
372
  throw new Error(
227
373
  `Build the CLI first (pnpm --filter @promptai.credit/cli build); hooks cannot run ${script} directly.`
228
374
  );
@@ -233,13 +379,13 @@ function hookCommand() {
233
379
  }
234
380
  function installClaudeHooks() {
235
381
  const settingsPath = claudeSettingsPath();
236
- fs3.mkdirSync(path2.dirname(settingsPath), { recursive: true });
382
+ fs4.mkdirSync(path3.dirname(settingsPath), { recursive: true });
237
383
  let settings = {};
238
- if (fs3.existsSync(settingsPath)) {
384
+ if (fs4.existsSync(settingsPath)) {
239
385
  try {
240
- settings = JSON.parse(fs3.readFileSync(settingsPath, "utf8"));
386
+ settings = JSON.parse(fs4.readFileSync(settingsPath, "utf8"));
241
387
  } catch {
242
- fs3.copyFileSync(settingsPath, settingsPath + ".promptai-backup");
388
+ fs4.copyFileSync(settingsPath, settingsPath + ".promptai-backup");
243
389
  settings = {};
244
390
  }
245
391
  }
@@ -259,17 +405,17 @@ function installClaudeHooks() {
259
405
  changed = true;
260
406
  }
261
407
  }
262
- if (changed || !fs3.existsSync(settingsPath)) {
263
- fs3.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
408
+ if (changed || !fs4.existsSync(settingsPath)) {
409
+ fs4.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
264
410
  }
265
411
  return { changed, settingsPath };
266
412
  }
267
413
  function uninstallClaudeHooks() {
268
414
  const settingsPath = claudeSettingsPath();
269
- if (!fs3.existsSync(settingsPath)) return { changed: false };
415
+ if (!fs4.existsSync(settingsPath)) return { changed: false };
270
416
  let settings;
271
417
  try {
272
- settings = JSON.parse(fs3.readFileSync(settingsPath, "utf8"));
418
+ settings = JSON.parse(fs4.readFileSync(settingsPath, "utf8"));
273
419
  } catch {
274
420
  return { changed: false };
275
421
  }
@@ -289,7 +435,7 @@ function uninstallClaudeHooks() {
289
435
  }
290
436
  }
291
437
  if (changed) {
292
- fs3.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
438
+ fs4.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
293
439
  }
294
440
  return { changed };
295
441
  }
@@ -344,7 +490,13 @@ async function handleStop(payload) {
344
490
  const promptId = crypto3.randomUUID();
345
491
  let verified = false;
346
492
  if (config.adsOptIn && cost > 0) {
347
- verified = await redeemAgainstAd(config.serverUrl, config.deviceId, promptId, cost);
493
+ verified = await redeemAgainstAd(
494
+ config.serverUrl,
495
+ config.deviceId,
496
+ promptId,
497
+ cost,
498
+ resolveEmail(config)
499
+ );
348
500
  }
349
501
  state.prompts.unshift({
350
502
  id: promptId,
@@ -362,7 +514,7 @@ async function handleStop(payload) {
362
514
  `[claude] settled session=${sessionId} model=${usage.model} in=${usage.inputTokens} out=${usage.outputTokens} cost=$${cost} verified=${verified}`
363
515
  );
364
516
  }
365
- async function redeemAgainstAd(serverUrl, deviceId, promptId, cost) {
517
+ async function redeemAgainstAd(serverUrl, deviceId, promptId, cost, email) {
366
518
  try {
367
519
  const { sessions } = await listVerifiedSessions(serverUrl, deviceId);
368
520
  if (sessions.length === 0) {
@@ -373,7 +525,8 @@ async function redeemAgainstAd(serverUrl, deviceId, promptId, cost) {
373
525
  sessionId: sessions[0].sessionId,
374
526
  deviceId,
375
527
  promptId,
376
- amountUsd: Math.min(cost, MAX_CREDIT_PER_AD_USD)
528
+ amountUsd: Math.min(cost, MAX_CREDIT_PER_AD_USD),
529
+ email: email || void 0
377
530
  });
378
531
  return true;
379
532
  } catch (err) {
@@ -382,16 +535,257 @@ async function redeemAgainstAd(serverUrl, deviceId, promptId, cost) {
382
535
  }
383
536
  }
384
537
 
538
+ // src/cursor.ts
539
+ import * as crypto4 from "node:crypto";
540
+ import * as fs5 from "node:fs";
541
+ import * as os4 from "node:os";
542
+ import * as path4 from "node:path";
543
+ var CURSOR_SOURCE = "cursor-agents";
544
+ var HOOK_MARKER2 = "promptai";
545
+ var MAX_CREDIT_PER_AD_USD2 = 5;
546
+ var AD_MIN_INTERVAL_MS2 = 9e4;
547
+ var SETTLE_RETRIES2 = [400, 800, 1500];
548
+ function isCursorPayload(payload) {
549
+ return typeof payload.cursor_version === "string" || typeof payload.conversation_id === "string";
550
+ }
551
+ function cursorHooksJsonPath() {
552
+ return path4.join(os4.homedir(), ".cursor", "hooks.json");
553
+ }
554
+ function installCursorHooks() {
555
+ const hooksPath = cursorHooksJsonPath();
556
+ fs5.mkdirSync(path4.dirname(hooksPath), { recursive: true });
557
+ let config = {};
558
+ if (fs5.existsSync(hooksPath)) {
559
+ try {
560
+ config = JSON.parse(fs5.readFileSync(hooksPath, "utf8"));
561
+ } catch {
562
+ fs5.copyFileSync(hooksPath, hooksPath + ".promptai-backup");
563
+ config = {};
564
+ }
565
+ }
566
+ config.version = config.version ?? 1;
567
+ config.hooks = config.hooks ?? {};
568
+ const command2 = hookCommand();
569
+ let changed = false;
570
+ for (const event of ["beforeSubmitPrompt", "stop"]) {
571
+ const entries = Array.isArray(config.hooks[event]) ? config.hooks[event] : [];
572
+ const ours = entries.find(
573
+ (e) => typeof e?.command === "string" && e.command.includes(HOOK_MARKER2)
574
+ );
575
+ if (!ours) {
576
+ entries.push({ command: command2 });
577
+ config.hooks[event] = entries;
578
+ changed = true;
579
+ } else if (ours.command !== command2) {
580
+ ours.command = command2;
581
+ changed = true;
582
+ }
583
+ }
584
+ if (changed || !fs5.existsSync(hooksPath)) {
585
+ fs5.writeFileSync(hooksPath, JSON.stringify(config, null, 2) + "\n");
586
+ }
587
+ return { changed, hooksPath };
588
+ }
589
+ function uninstallCursorHooks() {
590
+ const hooksPath = cursorHooksJsonPath();
591
+ if (!fs5.existsSync(hooksPath)) return { changed: false };
592
+ let config;
593
+ try {
594
+ config = JSON.parse(fs5.readFileSync(hooksPath, "utf8"));
595
+ } catch {
596
+ return { changed: false };
597
+ }
598
+ if (!config.hooks) return { changed: false };
599
+ let changed = false;
600
+ for (const [event, entries] of Object.entries(config.hooks)) {
601
+ if (!Array.isArray(entries)) continue;
602
+ const kept = entries.filter(
603
+ (e) => !(typeof e?.command === "string" && e.command.includes(HOOK_MARKER2))
604
+ );
605
+ if (kept.length !== entries.length) {
606
+ config.hooks[event] = kept;
607
+ changed = true;
608
+ }
609
+ }
610
+ if (changed) {
611
+ fs5.writeFileSync(hooksPath, JSON.stringify(config, null, 2) + "\n");
612
+ }
613
+ return { changed };
614
+ }
615
+ function extensionIsActive() {
616
+ const infoPath = path4.join(os4.homedir(), ".cursor", "promptai-listener.json");
617
+ try {
618
+ const info = JSON.parse(fs5.readFileSync(infoPath, "utf8"));
619
+ if (typeof info.pid !== "number") return false;
620
+ process.kill(info.pid, 0);
621
+ return true;
622
+ } catch {
623
+ return false;
624
+ }
625
+ }
626
+ function acquireOnceLock(key) {
627
+ const dir = path4.join(promptaiDir(), "locks");
628
+ fs5.mkdirSync(dir, { recursive: true });
629
+ try {
630
+ const cutoff = Date.now() - 24 * 60 * 60 * 1e3;
631
+ for (const name of fs5.readdirSync(dir)) {
632
+ const p = path4.join(dir, name);
633
+ if (fs5.statSync(p).mtimeMs < cutoff) fs5.rmSync(p, { recursive: true, force: true });
634
+ }
635
+ } catch {
636
+ }
637
+ try {
638
+ fs5.mkdirSync(path4.join(dir, key.replace(/[^a-zA-Z0-9_-]/g, "_")));
639
+ return true;
640
+ } catch {
641
+ return false;
642
+ }
643
+ }
644
+ function watermarkKey(conversationId) {
645
+ return `glass:${conversationId}`;
646
+ }
647
+ function handleCursorPromptSubmitted(payload) {
648
+ const config = loadConfig();
649
+ const state = loadState();
650
+ const convId = payload.conversation_id ?? "unknown";
651
+ const key = watermarkKey(convId);
652
+ if (!(key in state.watermarks) && payload.transcript_path) {
653
+ try {
654
+ const usage = readGlassTranscriptUsage(payload.transcript_path, { seenCount: 0 });
655
+ state.watermarks[key] = { lastTs: 0, seenCount: usage.seenCount };
656
+ } catch {
657
+ state.watermarks[key] = { lastTs: 0, seenCount: 0 };
658
+ }
659
+ }
660
+ const remote = process.env.CURSOR_CODE_REMOTE === "true";
661
+ if (config.adsOptIn && !extensionIsActive() && Date.now() - state.lastAdOpenedAt >= AD_MIN_INTERVAL_MS2) {
662
+ const url = watchUrl(config.serverUrl, config.deviceId, CURSOR_SOURCE);
663
+ if (!remote && openInBrowser(url)) {
664
+ state.lastAdOpenedAt = Date.now();
665
+ log(`[cursor] opened ad tab for conv=${convId}`);
666
+ } else if (remote || !hasDisplay()) {
667
+ log(`[cursor] no display here - watch ads at ${url}`);
668
+ }
669
+ }
670
+ saveState(state);
671
+ }
672
+ async function handleCursorStop(payload) {
673
+ const config = loadConfig();
674
+ const convId = payload.conversation_id ?? "unknown";
675
+ if (extensionIsActive()) {
676
+ log(`[cursor] extension host active, deferring settle for conv=${convId}`);
677
+ return;
678
+ }
679
+ if (!payload.transcript_path) {
680
+ log(`[cursor] stop without transcript_path conv=${convId} (transcripts disabled?)`);
681
+ return;
682
+ }
683
+ if (!acquireOnceLock(`stop-${convId}-${payload.generation_id ?? "nogen"}`)) {
684
+ log(`[cursor] duplicate stop event for conv=${convId}, skipping`);
685
+ return;
686
+ }
687
+ const state = loadState();
688
+ const key = watermarkKey(convId);
689
+ const watermark = state.watermarks[key] ?? { lastTs: 0, seenCount: 0 };
690
+ const model = payload.model_id || payload.model || "unknown";
691
+ let inputTokens = 0;
692
+ let outputTokens = 0;
693
+ let estimated = false;
694
+ const reportedInput = Number(payload.input_tokens ?? 0);
695
+ const reportedOutput = Number(payload.output_tokens ?? 0);
696
+ if (reportedInput + reportedOutput > 0) {
697
+ const cacheRead = Number(payload.cache_read_tokens ?? 0);
698
+ const cacheWrite = Number(payload.cache_write_tokens ?? 0);
699
+ const uncached = Math.max(0, reportedInput - cacheRead - cacheWrite);
700
+ inputTokens = Math.round(uncached + cacheWrite * 1.25 + cacheRead * 0.1);
701
+ outputTokens = reportedOutput;
702
+ } else {
703
+ estimated = true;
704
+ let usage;
705
+ for (const delay of SETTLE_RETRIES2) {
706
+ try {
707
+ usage = readGlassTranscriptUsage(payload.transcript_path, watermark);
708
+ if (usage.newEntries > 0) break;
709
+ } catch (err) {
710
+ log(`[cursor] transcript read failed: ${String(err)}`);
711
+ }
712
+ await new Promise((r) => setTimeout(r, delay));
713
+ }
714
+ if (!usage || usage.newEntries === 0) {
715
+ log(`[cursor] no new usage for conv=${convId}, skipping`);
716
+ return;
717
+ }
718
+ inputTokens = usage.inputTokens;
719
+ outputTokens = usage.outputTokens;
720
+ }
721
+ try {
722
+ const snapshot = readGlassTranscriptUsage(payload.transcript_path, { seenCount: 0 });
723
+ state.watermarks[key] = { lastTs: 0, seenCount: snapshot.seenCount };
724
+ } catch {
725
+ state.watermarks[key] = watermark;
726
+ }
727
+ const cost = costUsd(model, inputTokens, outputTokens);
728
+ const promptId = crypto4.randomUUID();
729
+ let verified = false;
730
+ if (config.adsOptIn && cost > 0) {
731
+ verified = await redeemAgainstAd2(
732
+ config.serverUrl,
733
+ config.deviceId,
734
+ promptId,
735
+ cost,
736
+ resolveEmail(config)
737
+ );
738
+ }
739
+ state.prompts.unshift({
740
+ id: promptId,
741
+ ts: Date.now(),
742
+ agent: "cursor",
743
+ sessionId: convId,
744
+ model,
745
+ inputTokens,
746
+ outputTokens,
747
+ costUsd: cost,
748
+ verified,
749
+ estimated
750
+ });
751
+ saveState(state);
752
+ log(
753
+ `[cursor] settled conv=${convId} model=${model} in=${inputTokens} out=${outputTokens} cost=$${cost}${estimated ? " (estimated)" : ""} verified=${verified}`
754
+ );
755
+ }
756
+ async function redeemAgainstAd2(serverUrl, deviceId, promptId, cost, email) {
757
+ try {
758
+ const { sessions } = await listVerifiedSessions(serverUrl, deviceId);
759
+ if (sessions.length === 0) {
760
+ log(`[cursor] no verified ad session available for prompt ${promptId}`);
761
+ return false;
762
+ }
763
+ await redeemCredit(serverUrl, {
764
+ sessionId: sessions[0].sessionId,
765
+ deviceId,
766
+ promptId,
767
+ amountUsd: Math.min(cost, MAX_CREDIT_PER_AD_USD2),
768
+ email: email || void 0
769
+ });
770
+ return true;
771
+ } catch (err) {
772
+ log(`[cursor] credit redeem failed for prompt ${promptId}: ${String(err)}`);
773
+ return false;
774
+ }
775
+ }
776
+
385
777
  // src/index.ts
386
778
  var HELP = `promptai - ad-subsidized prompt credits for terminal agents
387
779
 
388
780
  Usage:
389
781
  promptai install claude Wire hooks into ~/.claude/settings.json
390
- promptai uninstall claude Remove our hooks (other hooks untouched)
782
+ promptai install cursor Wire hooks into ~/.cursor/hooks.json (Agents Window)
783
+ promptai uninstall <agent> Remove our hooks (other hooks untouched)
391
784
  promptai status Device, balance, banked ad credits, recent prompts
392
785
  promptai watch Open a rewarded ad in the browser now
393
- promptai claim [address] Claim your verified balance as USDC (Base Sepolia)
786
+ promptai claim [address] Claim your verified balance as USDC (Base)
394
787
  promptai set wallet 0x... Set the payout wallet
788
+ promptai set email <email> Link your email (required for USDC claims; groups devices)
395
789
  promptai set server <url> Point at a different API server
396
790
  promptai set ads on|off Toggle the rewarded-ads opt-in
397
791
  promptai hook (internal) invoked by agent hooks, JSON on stdin
@@ -404,15 +798,31 @@ function readStdin() {
404
798
  });
405
799
  }
406
800
  async function cmdHook() {
801
+ const raw = await readStdin();
407
802
  let payload = {};
408
803
  try {
409
- payload = JSON.parse(await readStdin());
804
+ payload = JSON.parse(raw);
410
805
  } catch {
411
806
  return;
412
807
  }
413
- const event = payload.hook_event_name ?? "";
808
+ const event = String(payload.hook_event_name ?? "");
809
+ if (process.env.PROMPTAI_DUMP === "1" || fs6.existsSync(path5.join(promptaiDir(), "debug"))) {
810
+ try {
811
+ const dir = path5.join(promptaiDir(), "dump");
812
+ fs6.mkdirSync(dir, { recursive: true });
813
+ fs6.writeFileSync(path5.join(dir, `${Date.now()}-${event || "unknown"}.json`), raw);
814
+ } catch {
815
+ }
816
+ }
414
817
  try {
415
- if (event === "UserPromptSubmit") {
818
+ if (isCursorPayload(payload)) {
819
+ const cursorPayload = payload;
820
+ if (event === "beforeSubmitPrompt" || event === "UserPromptSubmit") {
821
+ handleCursorPromptSubmitted(cursorPayload);
822
+ } else if (event === "stop" || event === "Stop") {
823
+ await handleCursorStop(cursorPayload);
824
+ }
825
+ } else if (event === "UserPromptSubmit") {
416
826
  handleUserPromptSubmit(payload);
417
827
  } else if (event === "Stop") {
418
828
  await handleStop(payload);
@@ -424,10 +834,13 @@ async function cmdHook() {
424
834
  async function cmdStatus() {
425
835
  const config = loadConfig();
426
836
  const state = loadState();
837
+ const email = resolveEmail(config);
427
838
  console.log(`device ${config.deviceId}`);
428
839
  console.log(`server ${config.serverUrl}`);
429
840
  console.log(`wallet ${config.wallet || "(not set - promptai set wallet 0x...)"}`);
841
+ console.log(`email ${email || "(not detected - promptai set email you@example.com)"}`);
430
842
  console.log(`ads ${config.adsOptIn ? "opted in" : "opted out"}`);
843
+ console.log(`watch ${watchUrl(config.serverUrl, config.deviceId, "manual")}`);
431
844
  try {
432
845
  const [balance, verified] = await Promise.all([
433
846
  fetchBalance(config.serverUrl, config.deviceId),
@@ -436,6 +849,12 @@ async function cmdStatus() {
436
849
  console.log(
437
850
  `balance $${balance.balanceUsd.toFixed(4)} (earned $${balance.earnedUsd.toFixed(4)}, claimed $${balance.claimedUsd.toFixed(4)})`
438
851
  );
852
+ console.log(
853
+ `monthly device $${balance.monthEarnedDeviceUsd.toFixed(4)} / $${balance.deviceCapUsd}` + (balance.email ? ` \xB7 email $${balance.monthEarnedEmailUsd.toFixed(4)} / $${balance.emailCapUsd}` : "")
854
+ );
855
+ console.log(
856
+ `claims ${!balance.email ? "blocked (link an email first)" : balance.claimActivated ? "activated" : "blocked (waiting for admin activation)"}`
857
+ );
439
858
  console.log(`banked ${verified.sessions.length} verified ad watch(es) ready to fund prompts`);
440
859
  } catch (err) {
441
860
  console.log(`balance unavailable (${String(err)})`);
@@ -472,6 +891,13 @@ async function cmdClaim(addressArg) {
472
891
  process.exitCode = 1;
473
892
  return;
474
893
  }
894
+ const email = resolveEmail(config);
895
+ if (email) {
896
+ try {
897
+ await linkEmail(config.serverUrl, { deviceId: config.deviceId, email, source: "auto" });
898
+ } catch {
899
+ }
900
+ }
475
901
  const balance = await fetchBalance(config.serverUrl, config.deviceId);
476
902
  if (balance.balanceUsd < 1e-6) {
477
903
  console.log("Nothing to claim yet - watch an ad (promptai watch) and run some agent prompts first.");
@@ -486,7 +912,7 @@ async function cmdClaim(addressArg) {
486
912
  console.log(`Sent: ${result.txHash}`);
487
913
  console.log(result.explorerUrl);
488
914
  }
489
- function cmdSet(key, value) {
915
+ async function cmdSet(key, value) {
490
916
  const config = loadConfig();
491
917
  if (key === "wallet" && value && /^0x[0-9a-fA-F]{40}$/.test(value)) {
492
918
  config.wallet = value;
@@ -494,34 +920,101 @@ function cmdSet(key, value) {
494
920
  config.serverUrl = value.replace(/\/$/, "");
495
921
  } else if (key === "ads" && (value === "on" || value === "off")) {
496
922
  config.adsOptIn = value === "on";
923
+ } else if (key === "email" && value && /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(value)) {
924
+ config.email = value.trim().toLowerCase();
497
925
  } else {
498
- console.error("Usage: promptai set wallet 0x... | set server <url> | set ads on|off");
926
+ console.error(
927
+ "Usage: promptai set wallet 0x... | set server <url> | set ads on|off | set email you@example.com"
928
+ );
499
929
  process.exitCode = 1;
500
930
  return;
501
931
  }
932
+ if (key === "email") {
933
+ const email = config.email;
934
+ try {
935
+ const result = await linkEmail(config.serverUrl, {
936
+ deviceId: config.deviceId,
937
+ email,
938
+ source: "manual"
939
+ });
940
+ if (result.otpRequired) {
941
+ console.log(result.message ?? `Verification code sent to ${email}.`);
942
+ const code = (await promptLine("Enter the 6-digit code: ")).trim();
943
+ if (!/^\d{6}$/.test(code)) {
944
+ console.error("That doesn't look like a 6-digit code. Run the command again.");
945
+ process.exitCode = 1;
946
+ return;
947
+ }
948
+ const verified = await verifyEmailLink(config.serverUrl, {
949
+ deviceId: config.deviceId,
950
+ email,
951
+ code
952
+ });
953
+ printLinkStatus(verified.claimActivated);
954
+ } else {
955
+ printLinkStatus(Boolean(result.claimActivated));
956
+ }
957
+ saveConfig(config);
958
+ console.log("email updated.");
959
+ } catch (err) {
960
+ console.error(`Email not linked: ${err instanceof Error ? err.message : String(err)}`);
961
+ process.exitCode = 1;
962
+ }
963
+ return;
964
+ }
502
965
  saveConfig(config);
503
966
  console.log(`${key} updated.`);
504
967
  }
968
+ function printLinkStatus(claimActivated) {
969
+ console.log(
970
+ claimActivated ? "Email linked. Account is activated for USDC claims." : "Email linked. USDC claims stay blocked until an admin activates this account."
971
+ );
972
+ }
973
+ function promptLine(question) {
974
+ return new Promise((resolve2) => {
975
+ process.stdout.write(question);
976
+ const rl = readline.createInterface({ input: process.stdin });
977
+ rl.once("line", (line) => {
978
+ rl.close();
979
+ resolve2(line);
980
+ });
981
+ });
982
+ }
505
983
  function cmdInstall(agent) {
506
- if (agent !== "claude" && agent !== "claude-code") {
507
- console.error("Supported agents: claude (Codex CLI coming next). Usage: promptai install claude");
508
- process.exitCode = 1;
984
+ if (agent === "claude" || agent === "claude-code") {
985
+ const { changed, settingsPath } = installClaudeHooks();
986
+ console.log(
987
+ changed ? `Hooks installed into ${settingsPath}.` : `Hooks already installed in ${settingsPath}.`
988
+ );
989
+ console.log("Claude Code picks them up on its next session. Try: promptai watch, then run a prompt.");
509
990
  return;
510
991
  }
511
- const { changed, settingsPath } = installClaudeHooks();
512
- console.log(
513
- changed ? `Hooks installed into ${settingsPath}.` : `Hooks already installed in ${settingsPath}.`
514
- );
515
- console.log("Claude Code picks them up on its next session. Try: promptai watch, then run a prompt.");
992
+ if (agent === "cursor" || agent === "cursor-agents") {
993
+ const { changed, hooksPath } = installCursorHooks();
994
+ console.log(
995
+ changed ? `Hooks installed into ${hooksPath}.` : `Hooks already installed in ${hooksPath}.`
996
+ );
997
+ console.log(
998
+ "Works in the Agents Window and the classic IDE (the CLI stands down when the promptai extension is running). Cursor hot-reloads hooks.json."
999
+ );
1000
+ return;
1001
+ }
1002
+ console.error("Supported agents: claude, cursor (Codex CLI coming next). Usage: promptai install <agent>");
1003
+ process.exitCode = 1;
516
1004
  }
517
1005
  function cmdUninstall(agent) {
518
- if (agent !== "claude" && agent !== "claude-code") {
519
- console.error("Usage: promptai uninstall claude");
520
- process.exitCode = 1;
1006
+ if (agent === "claude" || agent === "claude-code") {
1007
+ const { changed } = uninstallClaudeHooks();
1008
+ console.log(changed ? "Hooks removed." : "No promptai hooks found.");
521
1009
  return;
522
1010
  }
523
- const { changed } = uninstallClaudeHooks();
524
- console.log(changed ? "Hooks removed." : "No promptai hooks found.");
1011
+ if (agent === "cursor" || agent === "cursor-agents") {
1012
+ const { changed } = uninstallCursorHooks();
1013
+ console.log(changed ? "Hooks removed." : "No promptai hooks found.");
1014
+ return;
1015
+ }
1016
+ console.error("Usage: promptai uninstall claude|cursor");
1017
+ process.exitCode = 1;
525
1018
  }
526
1019
  var [, , command, ...args] = process.argv;
527
1020
  try {
@@ -545,7 +1038,7 @@ try {
545
1038
  await cmdClaim(args[0]);
546
1039
  break;
547
1040
  case "set":
548
- cmdSet(args[0], args[1]);
1041
+ await cmdSet(args[0], args[1]);
549
1042
  break;
550
1043
  default:
551
1044
  console.log(HELP);
package/package.json CHANGED
@@ -1,17 +1,12 @@
1
1
  {
2
2
  "name": "@promptai.credit/cli",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
7
  "description": "Earn ad-subsidized prompt credits from terminal AI agents (Claude Code). Watch a dev-tool ad while your agent works; verified watches pay your prompt's token cost in USDC.",
8
8
  "type": "module",
9
9
  "license": "MIT",
10
- "repository": {
11
- "type": "git",
12
- "url": "git+https://github.com/Vib-UX/promptai.git",
13
- "directory": "product/cli"
14
- },
15
10
  "homepage": "https://promptai.credit",
16
11
  "keywords": [
17
12
  "claude-code",