@promptai.credit/cli 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.
Files changed (3) hide show
  1. package/README.md +28 -4
  2. package/dist/index.js +315 -18
  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,14 @@ 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)
30
54
  promptai set wallet 0x... # payout address
31
- promptai claim # USDC on Base Sepolia
55
+ promptai claim # USDC on Base
32
56
  promptai set ads off # opt out any time
33
57
  ```
34
58
 
package/dist/index.js CHANGED
@@ -1,5 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // src/index.ts
4
+ import * as fs5 from "node:fs";
5
+ import * as path4 from "node:path";
6
+
3
7
  // src/claude.ts
4
8
  import * as crypto3 from "node:crypto";
5
9
  import * as fs3 from "node:fs";
@@ -208,6 +212,34 @@ function readTranscriptUsage(transcriptPath, watermark) {
208
212
  seenCount: entries.length
209
213
  };
210
214
  }
215
+ function readGlassTranscriptUsage(transcriptPath, watermark) {
216
+ const raw = fs2.readFileSync(transcriptPath, "utf8");
217
+ const lines = raw.split("\n").filter((l) => l.trim());
218
+ let inputChars = 0;
219
+ let outputChars = 0;
220
+ let newEntries = 0;
221
+ for (const line of lines.slice(watermark.seenCount)) {
222
+ let entry;
223
+ try {
224
+ entry = JSON.parse(line);
225
+ } catch {
226
+ continue;
227
+ }
228
+ const chars = JSON.stringify(entry.message?.content ?? "").length;
229
+ if (entry.role === "assistant") {
230
+ outputChars += chars;
231
+ newEntries += 1;
232
+ } else {
233
+ inputChars += chars;
234
+ }
235
+ }
236
+ return {
237
+ inputTokens: Math.round(inputChars / 4),
238
+ outputTokens: Math.round(outputChars / 4),
239
+ newEntries,
240
+ seenCount: lines.length
241
+ };
242
+ }
211
243
 
212
244
  // src/claude.ts
213
245
  var SOURCE = "claude-code";
@@ -382,15 +414,248 @@ async function redeemAgainstAd(serverUrl, deviceId, promptId, cost) {
382
414
  }
383
415
  }
384
416
 
417
+ // src/cursor.ts
418
+ import * as crypto4 from "node:crypto";
419
+ import * as fs4 from "node:fs";
420
+ import * as os3 from "node:os";
421
+ import * as path3 from "node:path";
422
+ var CURSOR_SOURCE = "cursor-agents";
423
+ var HOOK_MARKER2 = "promptai";
424
+ var MAX_CREDIT_PER_AD_USD2 = 5;
425
+ var AD_MIN_INTERVAL_MS2 = 9e4;
426
+ var SETTLE_RETRIES2 = [400, 800, 1500];
427
+ function isCursorPayload(payload) {
428
+ return typeof payload.cursor_version === "string" || typeof payload.conversation_id === "string";
429
+ }
430
+ function cursorHooksJsonPath() {
431
+ return path3.join(os3.homedir(), ".cursor", "hooks.json");
432
+ }
433
+ function installCursorHooks() {
434
+ const hooksPath = cursorHooksJsonPath();
435
+ fs4.mkdirSync(path3.dirname(hooksPath), { recursive: true });
436
+ let config = {};
437
+ if (fs4.existsSync(hooksPath)) {
438
+ try {
439
+ config = JSON.parse(fs4.readFileSync(hooksPath, "utf8"));
440
+ } catch {
441
+ fs4.copyFileSync(hooksPath, hooksPath + ".promptai-backup");
442
+ config = {};
443
+ }
444
+ }
445
+ config.version = config.version ?? 1;
446
+ config.hooks = config.hooks ?? {};
447
+ const command2 = hookCommand();
448
+ let changed = false;
449
+ for (const event of ["beforeSubmitPrompt", "stop"]) {
450
+ const entries = Array.isArray(config.hooks[event]) ? config.hooks[event] : [];
451
+ const ours = entries.find(
452
+ (e) => typeof e?.command === "string" && e.command.includes(HOOK_MARKER2)
453
+ );
454
+ if (!ours) {
455
+ entries.push({ command: command2 });
456
+ config.hooks[event] = entries;
457
+ changed = true;
458
+ } else if (ours.command !== command2) {
459
+ ours.command = command2;
460
+ changed = true;
461
+ }
462
+ }
463
+ if (changed || !fs4.existsSync(hooksPath)) {
464
+ fs4.writeFileSync(hooksPath, JSON.stringify(config, null, 2) + "\n");
465
+ }
466
+ return { changed, hooksPath };
467
+ }
468
+ function uninstallCursorHooks() {
469
+ const hooksPath = cursorHooksJsonPath();
470
+ if (!fs4.existsSync(hooksPath)) return { changed: false };
471
+ let config;
472
+ try {
473
+ config = JSON.parse(fs4.readFileSync(hooksPath, "utf8"));
474
+ } catch {
475
+ return { changed: false };
476
+ }
477
+ if (!config.hooks) return { changed: false };
478
+ let changed = false;
479
+ for (const [event, entries] of Object.entries(config.hooks)) {
480
+ if (!Array.isArray(entries)) continue;
481
+ const kept = entries.filter(
482
+ (e) => !(typeof e?.command === "string" && e.command.includes(HOOK_MARKER2))
483
+ );
484
+ if (kept.length !== entries.length) {
485
+ config.hooks[event] = kept;
486
+ changed = true;
487
+ }
488
+ }
489
+ if (changed) {
490
+ fs4.writeFileSync(hooksPath, JSON.stringify(config, null, 2) + "\n");
491
+ }
492
+ return { changed };
493
+ }
494
+ function extensionIsActive() {
495
+ const infoPath = path3.join(os3.homedir(), ".cursor", "promptai-listener.json");
496
+ try {
497
+ const info = JSON.parse(fs4.readFileSync(infoPath, "utf8"));
498
+ if (typeof info.pid !== "number") return false;
499
+ process.kill(info.pid, 0);
500
+ return true;
501
+ } catch {
502
+ return false;
503
+ }
504
+ }
505
+ function acquireOnceLock(key) {
506
+ const dir = path3.join(promptaiDir(), "locks");
507
+ fs4.mkdirSync(dir, { recursive: true });
508
+ try {
509
+ const cutoff = Date.now() - 24 * 60 * 60 * 1e3;
510
+ for (const name of fs4.readdirSync(dir)) {
511
+ const p = path3.join(dir, name);
512
+ if (fs4.statSync(p).mtimeMs < cutoff) fs4.rmSync(p, { recursive: true, force: true });
513
+ }
514
+ } catch {
515
+ }
516
+ try {
517
+ fs4.mkdirSync(path3.join(dir, key.replace(/[^a-zA-Z0-9_-]/g, "_")));
518
+ return true;
519
+ } catch {
520
+ return false;
521
+ }
522
+ }
523
+ function watermarkKey(conversationId) {
524
+ return `glass:${conversationId}`;
525
+ }
526
+ function handleCursorPromptSubmitted(payload) {
527
+ const config = loadConfig();
528
+ const state = loadState();
529
+ const convId = payload.conversation_id ?? "unknown";
530
+ const key = watermarkKey(convId);
531
+ if (!(key in state.watermarks) && payload.transcript_path) {
532
+ try {
533
+ const usage = readGlassTranscriptUsage(payload.transcript_path, { seenCount: 0 });
534
+ state.watermarks[key] = { lastTs: 0, seenCount: usage.seenCount };
535
+ } catch {
536
+ state.watermarks[key] = { lastTs: 0, seenCount: 0 };
537
+ }
538
+ }
539
+ const remote = process.env.CURSOR_CODE_REMOTE === "true";
540
+ if (config.adsOptIn && !extensionIsActive() && Date.now() - state.lastAdOpenedAt >= AD_MIN_INTERVAL_MS2) {
541
+ const url = watchUrl(config.serverUrl, config.deviceId, CURSOR_SOURCE);
542
+ if (!remote && openInBrowser(url)) {
543
+ state.lastAdOpenedAt = Date.now();
544
+ log(`[cursor] opened ad tab for conv=${convId}`);
545
+ } else if (remote || !hasDisplay()) {
546
+ log(`[cursor] no display here - watch ads at ${url}`);
547
+ }
548
+ }
549
+ saveState(state);
550
+ }
551
+ async function handleCursorStop(payload) {
552
+ const config = loadConfig();
553
+ const convId = payload.conversation_id ?? "unknown";
554
+ if (extensionIsActive()) {
555
+ log(`[cursor] extension host active, deferring settle for conv=${convId}`);
556
+ return;
557
+ }
558
+ if (!payload.transcript_path) {
559
+ log(`[cursor] stop without transcript_path conv=${convId} (transcripts disabled?)`);
560
+ return;
561
+ }
562
+ if (!acquireOnceLock(`stop-${convId}-${payload.generation_id ?? "nogen"}`)) {
563
+ log(`[cursor] duplicate stop event for conv=${convId}, skipping`);
564
+ return;
565
+ }
566
+ const state = loadState();
567
+ const key = watermarkKey(convId);
568
+ const watermark = state.watermarks[key] ?? { lastTs: 0, seenCount: 0 };
569
+ const model = payload.model_id || payload.model || "unknown";
570
+ let inputTokens = 0;
571
+ let outputTokens = 0;
572
+ let estimated = false;
573
+ const reportedInput = Number(payload.input_tokens ?? 0);
574
+ const reportedOutput = Number(payload.output_tokens ?? 0);
575
+ if (reportedInput + reportedOutput > 0) {
576
+ const cacheRead = Number(payload.cache_read_tokens ?? 0);
577
+ const cacheWrite = Number(payload.cache_write_tokens ?? 0);
578
+ const uncached = Math.max(0, reportedInput - cacheRead - cacheWrite);
579
+ inputTokens = Math.round(uncached + cacheWrite * 1.25 + cacheRead * 0.1);
580
+ outputTokens = reportedOutput;
581
+ } else {
582
+ estimated = true;
583
+ let usage;
584
+ for (const delay of SETTLE_RETRIES2) {
585
+ try {
586
+ usage = readGlassTranscriptUsage(payload.transcript_path, watermark);
587
+ if (usage.newEntries > 0) break;
588
+ } catch (err) {
589
+ log(`[cursor] transcript read failed: ${String(err)}`);
590
+ }
591
+ await new Promise((r) => setTimeout(r, delay));
592
+ }
593
+ if (!usage || usage.newEntries === 0) {
594
+ log(`[cursor] no new usage for conv=${convId}, skipping`);
595
+ return;
596
+ }
597
+ inputTokens = usage.inputTokens;
598
+ outputTokens = usage.outputTokens;
599
+ }
600
+ try {
601
+ const snapshot = readGlassTranscriptUsage(payload.transcript_path, { seenCount: 0 });
602
+ state.watermarks[key] = { lastTs: 0, seenCount: snapshot.seenCount };
603
+ } catch {
604
+ state.watermarks[key] = watermark;
605
+ }
606
+ const cost = costUsd(model, inputTokens, outputTokens);
607
+ const promptId = crypto4.randomUUID();
608
+ let verified = false;
609
+ if (config.adsOptIn && cost > 0) {
610
+ verified = await redeemAgainstAd2(config.serverUrl, config.deviceId, promptId, cost);
611
+ }
612
+ state.prompts.unshift({
613
+ id: promptId,
614
+ ts: Date.now(),
615
+ agent: "cursor",
616
+ sessionId: convId,
617
+ model,
618
+ inputTokens,
619
+ outputTokens,
620
+ costUsd: cost,
621
+ verified,
622
+ estimated
623
+ });
624
+ saveState(state);
625
+ log(
626
+ `[cursor] settled conv=${convId} model=${model} in=${inputTokens} out=${outputTokens} cost=$${cost}${estimated ? " (estimated)" : ""} verified=${verified}`
627
+ );
628
+ }
629
+ async function redeemAgainstAd2(serverUrl, deviceId, promptId, cost) {
630
+ try {
631
+ const { sessions } = await listVerifiedSessions(serverUrl, deviceId);
632
+ if (sessions.length === 0) {
633
+ log(`[cursor] no verified ad session available for prompt ${promptId}`);
634
+ return false;
635
+ }
636
+ await redeemCredit(serverUrl, {
637
+ sessionId: sessions[0].sessionId,
638
+ deviceId,
639
+ promptId,
640
+ amountUsd: Math.min(cost, MAX_CREDIT_PER_AD_USD2)
641
+ });
642
+ return true;
643
+ } catch (err) {
644
+ log(`[cursor] credit redeem failed for prompt ${promptId}: ${String(err)}`);
645
+ return false;
646
+ }
647
+ }
648
+
385
649
  // src/index.ts
386
650
  var HELP = `promptai - ad-subsidized prompt credits for terminal agents
387
651
 
388
652
  Usage:
389
653
  promptai install claude Wire hooks into ~/.claude/settings.json
390
- promptai uninstall claude Remove our hooks (other hooks untouched)
654
+ promptai install cursor Wire hooks into ~/.cursor/hooks.json (Agents Window)
655
+ promptai uninstall <agent> Remove our hooks (other hooks untouched)
391
656
  promptai status Device, balance, banked ad credits, recent prompts
392
657
  promptai watch Open a rewarded ad in the browser now
393
- promptai claim [address] Claim your verified balance as USDC (Base Sepolia)
658
+ promptai claim [address] Claim your verified balance as USDC (Base)
394
659
  promptai set wallet 0x... Set the payout wallet
395
660
  promptai set server <url> Point at a different API server
396
661
  promptai set ads on|off Toggle the rewarded-ads opt-in
@@ -404,15 +669,31 @@ function readStdin() {
404
669
  });
405
670
  }
406
671
  async function cmdHook() {
672
+ const raw = await readStdin();
407
673
  let payload = {};
408
674
  try {
409
- payload = JSON.parse(await readStdin());
675
+ payload = JSON.parse(raw);
410
676
  } catch {
411
677
  return;
412
678
  }
413
- const event = payload.hook_event_name ?? "";
679
+ const event = String(payload.hook_event_name ?? "");
680
+ if (process.env.PROMPTAI_DUMP === "1" || fs5.existsSync(path4.join(promptaiDir(), "debug"))) {
681
+ try {
682
+ const dir = path4.join(promptaiDir(), "dump");
683
+ fs5.mkdirSync(dir, { recursive: true });
684
+ fs5.writeFileSync(path4.join(dir, `${Date.now()}-${event || "unknown"}.json`), raw);
685
+ } catch {
686
+ }
687
+ }
414
688
  try {
415
- if (event === "UserPromptSubmit") {
689
+ if (isCursorPayload(payload)) {
690
+ const cursorPayload = payload;
691
+ if (event === "beforeSubmitPrompt" || event === "UserPromptSubmit") {
692
+ handleCursorPromptSubmitted(cursorPayload);
693
+ } else if (event === "stop" || event === "Stop") {
694
+ await handleCursorStop(cursorPayload);
695
+ }
696
+ } else if (event === "UserPromptSubmit") {
416
697
  handleUserPromptSubmit(payload);
417
698
  } else if (event === "Stop") {
418
699
  await handleStop(payload);
@@ -428,6 +709,7 @@ async function cmdStatus() {
428
709
  console.log(`server ${config.serverUrl}`);
429
710
  console.log(`wallet ${config.wallet || "(not set - promptai set wallet 0x...)"}`);
430
711
  console.log(`ads ${config.adsOptIn ? "opted in" : "opted out"}`);
712
+ console.log(`watch ${watchUrl(config.serverUrl, config.deviceId, "manual")}`);
431
713
  try {
432
714
  const [balance, verified] = await Promise.all([
433
715
  fetchBalance(config.serverUrl, config.deviceId),
@@ -503,25 +785,40 @@ function cmdSet(key, value) {
503
785
  console.log(`${key} updated.`);
504
786
  }
505
787
  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;
788
+ if (agent === "claude" || agent === "claude-code") {
789
+ const { changed, settingsPath } = installClaudeHooks();
790
+ console.log(
791
+ changed ? `Hooks installed into ${settingsPath}.` : `Hooks already installed in ${settingsPath}.`
792
+ );
793
+ console.log("Claude Code picks them up on its next session. Try: promptai watch, then run a prompt.");
509
794
  return;
510
795
  }
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.");
796
+ if (agent === "cursor" || agent === "cursor-agents") {
797
+ const { changed, hooksPath } = installCursorHooks();
798
+ console.log(
799
+ changed ? `Hooks installed into ${hooksPath}.` : `Hooks already installed in ${hooksPath}.`
800
+ );
801
+ console.log(
802
+ "Works in the Agents Window and the classic IDE (the CLI stands down when the promptai extension is running). Cursor hot-reloads hooks.json."
803
+ );
804
+ return;
805
+ }
806
+ console.error("Supported agents: claude, cursor (Codex CLI coming next). Usage: promptai install <agent>");
807
+ process.exitCode = 1;
516
808
  }
517
809
  function cmdUninstall(agent) {
518
- if (agent !== "claude" && agent !== "claude-code") {
519
- console.error("Usage: promptai uninstall claude");
520
- process.exitCode = 1;
810
+ if (agent === "claude" || agent === "claude-code") {
811
+ const { changed } = uninstallClaudeHooks();
812
+ console.log(changed ? "Hooks removed." : "No promptai hooks found.");
813
+ return;
814
+ }
815
+ if (agent === "cursor" || agent === "cursor-agents") {
816
+ const { changed } = uninstallCursorHooks();
817
+ console.log(changed ? "Hooks removed." : "No promptai hooks found.");
521
818
  return;
522
819
  }
523
- const { changed } = uninstallClaudeHooks();
524
- console.log(changed ? "Hooks removed." : "No promptai hooks found.");
820
+ console.error("Usage: promptai uninstall claude|cursor");
821
+ process.exitCode = 1;
525
822
  }
526
823
  var [, , command, ...args] = process.argv;
527
824
  try {
package/package.json CHANGED
@@ -1,17 +1,12 @@
1
1
  {
2
2
  "name": "@promptai.credit/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.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",