@promptai.credit/cli 0.4.1 → 0.4.2

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/README.md CHANGED
@@ -6,17 +6,36 @@ Cursor Agents Window are supported today; Codex CLI is next.
6
6
 
7
7
  ## How it works
8
8
 
9
- 1. `promptai install claude` merges two hooks into `~/.claude/settings.json`:
9
+ 1. `promptai install claude` merges two hooks into `~/.claude/settings.json` and
10
+ installs the Claude Mods plugin into `~/.claude/skills/promptai/`:
10
11
  - **UserPromptSubmit**: baselines the session's transcript watermark and (if opted
11
12
  in, at most every 90s, never headless) opens the hosted rewarded-ad page
12
- (`/watch`) in your browser. The ad plays while your agent works.
13
+ (`/watch`) in your browser — **unless** the Mods plugin is live (heartbeat in
14
+ `~/.promptai/native-plugin.json`), in which case the native AbovePrompt card
15
+ shows the ad instead.
13
16
  - **Stop** (async): reads the session transcript JSONL since the watermark, prices
14
17
  the turn with the shared model price table, and redeems one banked
15
18
  server-verified ad session against it via the API.
16
- 2. The `/watch` page creates the ad session server-side on load, so SSV wall-clock
17
- timing is enforced by the server; the browser countdown is cosmetic.
19
+ - **Mods plugin** (`product/claude-plugin`): on `prompt.submit` fetches an ad
20
+ session and renders a compact sponsored card via `ui.render` / `AbovePrompt`.
21
+ 2. The `/watch` page (and the native card) create the ad session server-side, so SSV
22
+ wall-clock timing is enforced by the server; countdowns are cosmetic.
18
23
  3. `promptai claim` pays out your verified balance as USDC on Base.
19
24
 
25
+ ### Claude Mods (native card)
26
+
27
+ Native AbovePrompt ads need Claude Code function hooks (early access until GA).
28
+ `promptai install claude` sets `env.CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1` in
29
+ `~/.claude/settings.json` (an explicit value you already set is left alone, and
30
+ `promptai uninstall claude` removes only what it added). This also enables any other
31
+ function-hook plugins you use. Where Mods are unavailable, command hooks open `/watch`
32
+ in the browser.
33
+ Dev-load the plugin without installing:
34
+
35
+ ```bash
36
+ CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir product/claude-plugin
37
+ ```
38
+
20
39
  ## Cursor Agents Window (Glass)
21
40
 
22
41
  The Agents Window doesn't run VS Code extensions, so the promptai extension can't
@@ -45,7 +64,7 @@ State lives in `~/.promptai/` (`config.json`, `state.json`, `cli.log`).
45
64
 
46
65
  ```bash
47
66
  npm install -g @promptai.credit/cli
48
- promptai install claude # Claude Code hooks
67
+ promptai install claude # Claude Code hooks + Mods plugin
49
68
  promptai install cursor # Cursor Agents Window / classic IDE hooks
50
69
  ```
51
70
 
@@ -82,4 +101,4 @@ Developing from the repo instead: `pnpm --filter @promptai.credit/cli build`, th
82
101
  - Headless environments (CI, ssh without a display) never get browser tabs and
83
102
  simply skip crediting.
84
103
  - Uninstall with `promptai uninstall claude`; other hooks in your settings are
85
- preserved.
104
+ preserved, and the Mods plugin under `~/.claude/skills/promptai/` is removed.
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "promptai",
3
+ "version": "0.1.0",
4
+ "description": "Opt-in rewarded ads above the Claude Code prompt. Watch a short sponsored card while the agent works; verified watches fund prompt credits via promptai.credit.",
5
+ "author": {
6
+ "name": "PromptAI",
7
+ "url": "https://promptai.credit"
8
+ },
9
+ "homepage": "https://promptai.credit",
10
+ "keywords": ["ads", "credits", "promptai", "rewarded"]
11
+ }
@@ -0,0 +1,33 @@
1
+ # PromptAI Claude Code plugin
2
+
3
+ Native rewarded-ad card for Claude Code via **Claude Mods** (function hooks).
4
+
5
+ While the agent works, a compact sponsored row renders above the prompt
6
+ (`AbovePrompt`). After the server-verified watch window, the credit is banked
7
+ the same way as the browser `/watch` path. Transcript pricing and redeem still
8
+ run through the `promptai` CLI `Stop` command hook.
9
+
10
+ ## Requirements
11
+
12
+ - Claude Code with function hooks enabled (early access). `promptai install claude`
13
+ sets `env.CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1` in `~/.claude/settings.json`; to enable
14
+ it by hand, use `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude`.
15
+
16
+ - `promptai install claude` (wires command hooks, enables the flag, copies this plugin)
17
+
18
+ ## Dev load (without install)
19
+
20
+ ```bash
21
+ CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir /path/to/product/claude-plugin
22
+ ```
23
+
24
+ ## Notes
25
+
26
+ - If Mods are off or the module fails to load, the CLI falls back to opening
27
+ `/watch` in the browser (fail open).
28
+ - The plugin writes `~/.promptai/native-plugin.json` on `session.start` so the
29
+ CLI can skip the browser tab while the native card is live.
30
+ - The brand logo (PNG, favicon or SVG) is drawn as half-block cells, so it works in any
31
+ terminal. It is fetched with `curl` and converted with `sips` (macOS) or ImageMagick;
32
+ SVGs need `qlmanage` (macOS) or `rsvg-convert`. If any step is missing the card simply
33
+ draws without a logo. Results are cached under `~/.promptai/logos/`.
@@ -0,0 +1,557 @@
1
+ /**
2
+ * PromptAI native rewarded-ad card for Claude Code (Mods / function hooks).
3
+ *
4
+ * Lifecycle:
5
+ * 1. session.start — load ~/.promptai config; heartbeat so CLI skips /watch
6
+ * 2. prompt.submit — fire-and-forget POST /ads/session (never await before next)
7
+ * 3. ui.render AbovePrompt — compact sponsored card + countdown
8
+ * 4. clock tick — when minWatchMs elapsed, POST /ads/complete
9
+ * 5. CTA press — beacon click, open destination in OS browser
10
+ *
11
+ * Stop / transcript pricing / credit redeem stay on the command-hook CLI path.
12
+ *
13
+ * Requires CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 until Mods are generally available.
14
+ */
15
+ import type { EngineInterface as Engine, Register, Timer } from "claude-code";
16
+ import { WORK_PX, bitmapToCells, decodeBase64, hashKey, parseBmp, type LogoCells } from "./logo.ts";
17
+
18
+ const SOURCE = "claude-mods";
19
+ /** Logo cell grid: half-blocks, so 12 columns × 6 rows is a square of 12×12 pixels. */
20
+ const LOGO_COLS = 12;
21
+ const LOGO_ROWS = 6;
22
+ const HEARTBEAT_FILE = "native-plugin.json";
23
+ /** How long the CLI treats a heartbeat as "plugin is live" before falling back to /watch. */
24
+ const HEARTBEAT_FRESH_MS = 2 * 60 * 60 * 1000;
25
+
26
+ interface AdCreative {
27
+ id: string;
28
+ brand: string;
29
+ tag: string;
30
+ tagline: string;
31
+ lines: Array<{ cmd: boolean; text: string }>;
32
+ ctaLabel: string;
33
+ ctaUrl: string;
34
+ destinationUrl?: string;
35
+ logoUrl?: string;
36
+ minWatchMs: number;
37
+ }
38
+
39
+ interface PromptaiConfig {
40
+ deviceId: string;
41
+ serverUrl: string;
42
+ adsOptIn: boolean;
43
+ }
44
+
45
+ type AdPhase = "idle" | "loading" | "showing" | "verified" | "failed";
46
+
47
+ interface ActiveAd {
48
+ phase: AdPhase;
49
+ sessionId?: string;
50
+ ad?: AdCreative;
51
+ shownAt?: number;
52
+ error?: string;
53
+ tick?: Timer;
54
+ logo?: LogoCells;
55
+ }
56
+
57
+ const active: ActiveAd = { phase: "idle" };
58
+ let config: PromptaiConfig | null = null;
59
+ let homeDir = "";
60
+ let startingSession = false;
61
+
62
+ function configPath(): string {
63
+ return `${homeDir}/.promptai/config.json`;
64
+ }
65
+
66
+ function statePath(): string {
67
+ return `${homeDir}/.promptai/state.json`;
68
+ }
69
+
70
+ function heartbeatPath(): string {
71
+ return `${homeDir}/.promptai/${HEARTBEAT_FILE}`;
72
+ }
73
+
74
+ async function resolveHome($: Engine): Promise<string> {
75
+ try {
76
+ return ((await $.env.get("HOME")) ?? "").trim();
77
+ } catch {
78
+ return "";
79
+ }
80
+ }
81
+
82
+ async function readJson<T>($: Engine, path: string, fallback: T): Promise<T> {
83
+ try {
84
+ if (!(await $.fs.exists(path))) return fallback;
85
+ const raw = await $.fs.read(path);
86
+ return { ...fallback, ...(JSON.parse(raw) as T) };
87
+ } catch {
88
+ return fallback;
89
+ }
90
+ }
91
+
92
+ async function writeJson($: Engine, path: string, value: unknown): Promise<void> {
93
+ try {
94
+ await $.fs.write(path, `${JSON.stringify(value, null, 2)}\n`);
95
+ } catch {
96
+ // never break the session for local state
97
+ }
98
+ }
99
+
100
+ async function loadConfig($: Engine): Promise<PromptaiConfig | null> {
101
+ if (!homeDir) return null;
102
+ const cfg = await readJson<PromptaiConfig>($, configPath(), {
103
+ deviceId: "",
104
+ serverUrl: "https://api.promptai.credit",
105
+ adsOptIn: true,
106
+ });
107
+ if (!cfg.deviceId) return null;
108
+ if (!cfg.serverUrl) cfg.serverUrl = "https://api.promptai.credit";
109
+ return cfg;
110
+ }
111
+
112
+ async function bumpLastAdOpenedAt($: Engine, at: number): Promise<void> {
113
+ const state = await readJson<Record<string, unknown>>($, statePath(), {});
114
+ state.lastAdOpenedAt = at;
115
+ await writeJson($, statePath(), state);
116
+ }
117
+
118
+ async function writeHeartbeat($: Engine): Promise<void> {
119
+ await writeJson($, heartbeatPath(), {
120
+ active: true,
121
+ at: Date.now(),
122
+ source: SOURCE,
123
+ freshMs: HEARTBEAT_FRESH_MS,
124
+ });
125
+ }
126
+
127
+ // Same palette as the /watch page so the terminal card reads as one brand.
128
+ const C = {
129
+ fg: "#fafafa",
130
+ soft: "#b9b9b9",
131
+ dim: "#8f8f8f",
132
+ faint: "#525252",
133
+ green: "#4ade80",
134
+ red: "#f87171",
135
+ amber: "#fbbf24",
136
+ };
137
+
138
+ function progressBar(elapsed: number, total: number, width = 20): { filled: string; empty: string } {
139
+ const ratio = Math.min(1, Math.max(0, elapsed / Math.max(1, total)));
140
+ const n = Math.round(ratio * width);
141
+ return { filled: "█".repeat(n), empty: "░".repeat(width - n) };
142
+ }
143
+
144
+ function clearTick(): void {
145
+ const tick = active.tick;
146
+ active.tick = undefined;
147
+ try {
148
+ tick?.cancel();
149
+ } catch {
150
+ // ignore
151
+ }
152
+ }
153
+
154
+ function resetAd(phase: AdPhase = "idle"): void {
155
+ clearTick();
156
+ active.phase = phase;
157
+ active.sessionId = undefined;
158
+ active.ad = undefined;
159
+ active.shownAt = undefined;
160
+ active.error = undefined;
161
+ active.logo = undefined;
162
+ }
163
+
164
+ function parseBody<T>(text: string): T {
165
+ try {
166
+ return JSON.parse(text) as T;
167
+ } catch {
168
+ return {} as T;
169
+ }
170
+ }
171
+
172
+ /** True while /ads/complete is in flight, so a slow response is never asked twice. */
173
+ let completing = false;
174
+
175
+ async function completeAd($: Engine): Promise<void> {
176
+ if (completing || !config || !active.sessionId || active.phase !== "showing") return;
177
+ completing = true;
178
+ // Stop the countdown first: with the request in flight the phase is still
179
+ // "showing", and another tick would send a second complete ("already verified").
180
+ clearTick();
181
+ const sessionId = active.sessionId;
182
+ try {
183
+ const res = await $.http.fetch(`${config.serverUrl}/ads/complete`, {
184
+ method: "POST",
185
+ headers: { "Content-Type": "application/json" },
186
+ body: JSON.stringify({ sessionId, deviceId: config.deviceId }),
187
+ });
188
+ if (!res.ok) {
189
+ const body = parseBody<{ error?: string }>(res.text);
190
+ active.phase = "failed";
191
+ active.error = body.error ?? `complete ${res.status}`;
192
+ clearTick();
193
+ $.ui.invalidate("ui.render");
194
+ return;
195
+ }
196
+ active.phase = "verified";
197
+ clearTick();
198
+ $.ui.invalidate("ui.render");
199
+ // Auto-collapse the verified strip after a short beat.
200
+ $.clock.after(8_000, () => {
201
+ if (active.phase === "verified") {
202
+ resetAd("idle");
203
+ $.ui.invalidate("ui.render");
204
+ }
205
+ });
206
+ } catch (err) {
207
+ active.phase = "failed";
208
+ active.error = String(err);
209
+ clearTick();
210
+ $.ui.invalidate("ui.render");
211
+ } finally {
212
+ completing = false;
213
+ }
214
+ }
215
+
216
+ function startCountdown($: Engine): void {
217
+ clearTick();
218
+ active.tick = $.clock.every(1_000, () => {
219
+ if (active.phase !== "showing" || !active.ad || active.shownAt == null) return;
220
+ const elapsed = Date.now() - active.shownAt;
221
+ if (elapsed >= active.ad.minWatchMs) {
222
+ void completeAd($);
223
+ return;
224
+ }
225
+ $.ui.invalidate("ui.render");
226
+ });
227
+ }
228
+
229
+ async function openDestination($: Engine, url: string): Promise<void> {
230
+ const candidates: string[][] = [
231
+ ["xdg-open", url],
232
+ ["open", url],
233
+ ["cmd", "/c", "start", "", url],
234
+ ];
235
+ for (const argv of candidates) {
236
+ try {
237
+ const result = await $.process.run(argv, { timeoutMs: 5_000 });
238
+ if (result.exitCode === 0) return;
239
+ } catch {
240
+ // try next opener
241
+ }
242
+ }
243
+ $.ui.toast(`Open in browser: ${url}`);
244
+ }
245
+
246
+ async function onCtaPress($: Engine): Promise<void> {
247
+ if (!config || !active.sessionId || !active.ad) return;
248
+ const ad = active.ad;
249
+ const clickUrl = ad.ctaUrl.includes("/ads/click/")
250
+ ? ad.ctaUrl
251
+ : `${config.serverUrl}/ads/click/${active.sessionId}`;
252
+ try {
253
+ void $.http.fetch(clickUrl, { method: "GET" });
254
+ } catch {
255
+ // click is best-effort
256
+ }
257
+ const target =
258
+ ad.destinationUrl ||
259
+ (ad.ctaUrl.includes("/ads/click/") ? undefined : ad.ctaUrl);
260
+ if (target) await openDestination($, target);
261
+ }
262
+
263
+ // --- logo host pipeline (curl → svg→png → bmp; pure image code is in logo.ts) ---
264
+
265
+ async function tryRun($: Engine, argv: string[], timeoutMs = 8_000): Promise<{ ok: boolean; stdout: string }> {
266
+ try {
267
+ const res = await $.process.run(argv, { timeoutMs });
268
+ return { ok: res.exitCode === 0, stdout: res.stdout };
269
+ } catch {
270
+ return { ok: false, stdout: "" };
271
+ }
272
+ }
273
+
274
+ async function exists($: Engine, path: string): Promise<boolean> {
275
+ try {
276
+ return await $.fs.exists(path);
277
+ } catch {
278
+ return false;
279
+ }
280
+ }
281
+
282
+ async function svgToPng($: Engine, svg: string, dir: string, png: string): Promise<boolean> {
283
+ // macOS Quick Look thumbnailer: writes <dir>/<file>.png
284
+ const ql = await tryRun($, ["qlmanage", "-t", "-s", String(WORK_PX * 2), "-o", dir, svg]);
285
+ if (ql.ok && (await exists($, `${svg}.png`))) {
286
+ await tryRun($, ["mv", "-f", `${svg}.png`, png]);
287
+ if (await exists($, png)) return true;
288
+ }
289
+ const rsvg = await tryRun($, ["rsvg-convert", "-w", String(WORK_PX), "-h", String(WORK_PX), "-f", "png", "-o", png, svg]);
290
+ return rsvg.ok && (await exists($, png));
291
+ }
292
+
293
+ async function toBmp($: Engine, input: string, bmp: string): Promise<boolean> {
294
+ const attempts: string[][] = [
295
+ ["sips", "-s", "format", "bmp", "-Z", String(WORK_PX), input, "--out", bmp],
296
+ ["magick", input, "-background", "none", "-resize", `${WORK_PX}x${WORK_PX}`, `BMP3:${bmp}`],
297
+ ["convert", input, "-background", "none", "-resize", `${WORK_PX}x${WORK_PX}`, `BMP3:${bmp}`],
298
+ ];
299
+ for (const argv of attempts) {
300
+ const res = await tryRun($, argv);
301
+ if (res.ok && (await exists($, bmp))) return true;
302
+ }
303
+ return false;
304
+ }
305
+
306
+ const failedLogos = new Set<string>();
307
+
308
+ /**
309
+ * Load `url` as terminal cells, caching the result under
310
+ * `<home>/.promptai/logos/`. Resolves null when anything is unavailable.
311
+ */
312
+ async function loadLogoCells(
313
+ $: Engine,
314
+ opts: { url: string; home: string; columns: number; rows: number },
315
+ ): Promise<LogoCells | null> {
316
+ const { url, home, columns, rows } = opts;
317
+ if (!home || !/^https?:\/\//i.test(url)) return null;
318
+
319
+ const key = `${hashKey(url)}-${columns}x${rows}`;
320
+ if (failedLogos.has(key)) return null;
321
+
322
+ const dir = `${home}/.promptai/logos`;
323
+ const cacheFile = `${dir}/${key}.cells.json`;
324
+ try {
325
+ if (await exists($, cacheFile)) {
326
+ const cached = JSON.parse(await $.fs.read(cacheFile)) as LogoCells;
327
+ if (cached.cells && cached.columns === columns && cached.rows === rows) return cached;
328
+ }
329
+ } catch {
330
+ // rebuild below
331
+ }
332
+
333
+ const src = `${dir}/${key}.src`;
334
+ const png = `${dir}/${key}.png`;
335
+ const bmp = `${dir}/${key}.bmp`;
336
+ try {
337
+ const dl = await tryRun($, [
338
+ "curl", "-sSL", "--max-time", "5", "--max-filesize", "2000000", "--create-dirs",
339
+ "-o", src, "-w", "%{content_type}", url,
340
+ ]);
341
+ if (!dl.ok || !(await exists($, src))) throw new Error("download");
342
+
343
+ const isSvg = /svg/i.test(dl.stdout) || /\.svg(\?|$)/i.test(url);
344
+ let input = src;
345
+ if (isSvg) {
346
+ const svgFile = `${src}.svg`;
347
+ await tryRun($, ["mv", "-f", src, svgFile]);
348
+ if (!(await svgToPng($, svgFile, dir, png))) throw new Error("svg");
349
+ input = png;
350
+ }
351
+ if (!(await toBmp($, input, bmp))) throw new Error("bmp");
352
+
353
+ const { base64 } = await $.fs.read(bmp, { as: "bytes" });
354
+ const bitmap = parseBmp(decodeBase64(base64));
355
+ if (!bitmap) throw new Error("parse");
356
+
357
+ const logo = bitmapToCells(bitmap, columns, rows);
358
+ await $.fs.write(cacheFile, JSON.stringify(logo));
359
+ return logo;
360
+ } catch {
361
+ failedLogos.add(key);
362
+ return null;
363
+ } finally {
364
+ await tryRun($, ["rm", "-f", src, `${src}.svg`, png, bmp]);
365
+ }
366
+ }
367
+
368
+ /** Fire-and-forget: the card draws at once, the logo joins it when ready. */
369
+ async function loadLogo($: Engine, ad: AdCreative): Promise<void> {
370
+ if (!ad.logoUrl) return;
371
+ const logo = await loadLogoCells($, { url: ad.logoUrl, home: homeDir, columns: LOGO_COLS, rows: LOGO_ROWS });
372
+ if (logo && active.ad?.id === ad.id) {
373
+ active.logo = logo;
374
+ $.ui.invalidate("ui.render");
375
+ }
376
+ }
377
+
378
+ async function beginAdSession($: Engine): Promise<void> {
379
+ if (!config || !config.adsOptIn || startingSession) return;
380
+ if (active.phase === "loading" || active.phase === "showing") return;
381
+
382
+ startingSession = true;
383
+ active.phase = "loading";
384
+ $.ui.invalidate("ui.render");
385
+
386
+ try {
387
+ const res = await $.http.fetch(`${config.serverUrl}/ads/session`, {
388
+ method: "POST",
389
+ headers: { "Content-Type": "application/json" },
390
+ body: JSON.stringify({ deviceId: config.deviceId, source: SOURCE }),
391
+ });
392
+ const body = parseBody<{
393
+ sessionId?: string;
394
+ ad?: AdCreative;
395
+ error?: string;
396
+ }>(res.text);
397
+ if (!res.ok || !body.sessionId || !body.ad) {
398
+ active.phase = "failed";
399
+ active.error = body.error ?? `session ${res.status}`;
400
+ $.ui.invalidate("ui.render");
401
+ $.clock.after(5_000, () => {
402
+ if (active.phase === "failed") {
403
+ resetAd("idle");
404
+ $.ui.invalidate("ui.render");
405
+ }
406
+ });
407
+ return;
408
+ }
409
+
410
+ const now = Date.now();
411
+ active.phase = "showing";
412
+ active.sessionId = body.sessionId;
413
+ active.ad = body.ad;
414
+ active.shownAt = now;
415
+ active.error = undefined;
416
+ await bumpLastAdOpenedAt($, now);
417
+ startCountdown($);
418
+ $.ui.invalidate("ui.render");
419
+ void loadLogo($, body.ad);
420
+ } catch (err) {
421
+ active.phase = "failed";
422
+ active.error = String(err);
423
+ $.ui.invalidate("ui.render");
424
+ $.clock.after(5_000, () => {
425
+ if (active.phase === "failed") {
426
+ resetAd("idle");
427
+ $.ui.invalidate("ui.render");
428
+ }
429
+ });
430
+ } finally {
431
+ startingSession = false;
432
+ }
433
+ }
434
+
435
+ function remainingSeconds($: Engine): number {
436
+ if (!active.ad || active.shownAt == null) return 0;
437
+ const left = active.ad.minWatchMs - (Date.now() - active.shownAt);
438
+ return Math.max(0, Math.ceil(left / 1000));
439
+ }
440
+
441
+ export const register: Register = (on) => {
442
+ on("session.start", async ($, e, next) => {
443
+ homeDir = await resolveHome($);
444
+ config = await loadConfig($);
445
+ if (config?.adsOptIn) {
446
+ await writeHeartbeat($);
447
+ }
448
+ return next(e);
449
+ });
450
+
451
+ // Fire-and-forget: never await the session fetch before next(e).
452
+ on("prompt.submit", ($, e, next) => {
453
+ if (config?.adsOptIn) {
454
+ void writeHeartbeat($);
455
+ void beginAdSession($);
456
+ }
457
+ return next(e);
458
+ });
459
+
460
+ on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
461
+ if (!config?.adsOptIn || active.phase === "idle") {
462
+ return next(e);
463
+ }
464
+
465
+ const t = await $.ui.resolve(e);
466
+
467
+ if (active.phase === "loading") {
468
+ return (
469
+ <t.Box borderStyle="round" borderColor={C.faint} paddingX={1} gap={1}>
470
+ <t.Text color={C.amber}>●</t.Text>
471
+ <t.Text color={C.dim}>promptai · loading sponsored card…</t.Text>
472
+ </t.Box>
473
+ );
474
+ }
475
+
476
+ if (active.phase === "failed") {
477
+ return (
478
+ <t.Box borderStyle="round" borderColor={C.red} paddingX={1} gap={1}>
479
+ <t.Text color={C.red}>✕</t.Text>
480
+ <t.Text color={C.dim}>{`promptai · ad unavailable${active.error ? ` (${active.error})` : ""}`}</t.Text>
481
+ </t.Box>
482
+ );
483
+ }
484
+
485
+ if (active.phase === "verified") {
486
+ return (
487
+ <t.Box borderStyle="round" borderColor={C.green} paddingX={1} gap={1}>
488
+ <t.Text color={C.green} bold>✓ verified</t.Text>
489
+ <t.Text color={C.soft}>credit banked</t.Text>
490
+ <t.Text color={C.faint}>·</t.Text>
491
+ <t.Button
492
+ key="hide"
493
+ label="Hide"
494
+ plain
495
+ onPress={() => {
496
+ resetAd("idle");
497
+ $.ui.invalidate("ui.render");
498
+ }}
499
+ />
500
+ </t.Box>
501
+ );
502
+ }
503
+
504
+ if (active.phase !== "showing" || !active.ad || active.shownAt == null) {
505
+ return next(e);
506
+ }
507
+
508
+ const ad = active.ad;
509
+ const elapsed = Date.now() - active.shownAt;
510
+ const bar = progressBar(elapsed, ad.minWatchMs);
511
+ const secs = remainingSeconds($);
512
+ const shownLines = (ad.lines ?? []).slice(0, 4);
513
+
514
+ const Raster = "Raster" in t ? t.Raster : null;
515
+ const logo = active.logo;
516
+
517
+ const details = (
518
+ <t.Box flexDirection="column" flexGrow={1}>
519
+ <t.Box gap={1}>
520
+ <t.Text color={C.green}>◆</t.Text>
521
+ <t.Text color={C.fg} bold>{ad.brand}</t.Text>
522
+ {ad.tag ? <t.Text color={C.dim}>{`· ${ad.tag}`}</t.Text> : null}
523
+ <t.Text color={C.faint}>· sponsored</t.Text>
524
+ </t.Box>
525
+ <t.Text color={C.soft} wrap="truncate-end">{ad.tagline}</t.Text>
526
+ {shownLines.map((line, i) => (
527
+ <t.Box key={`line-${i}`} gap={1}>
528
+ {line.cmd ? <t.Text color={C.green}>$</t.Text> : <t.Text color={C.faint}>│</t.Text>}
529
+ <t.Text color={line.cmd ? C.fg : C.dim} wrap="truncate-end">{line.text}</t.Text>
530
+ </t.Box>
531
+ ))}
532
+ </t.Box>
533
+ );
534
+
535
+ return (
536
+ <t.Box flexDirection="column" borderStyle="round" borderColor={C.faint} paddingX={1}>
537
+ {Raster && logo ? (
538
+ <t.Box gap={2}>
539
+ <Raster key="logo" columns={logo.columns} rows={logo.rows} cells={logo.cells} />
540
+ {details}
541
+ </t.Box>
542
+ ) : (
543
+ details
544
+ )}
545
+ <t.Box gap={1}>
546
+ <t.Box>
547
+ <t.Text color={C.green}>{bar.filled}</t.Text>
548
+ <t.Text color={C.faint}>{bar.empty}</t.Text>
549
+ </t.Box>
550
+ <t.Text color={C.fg} bold>{`${secs}s`}</t.Text>
551
+ <t.Text color={C.faint}>opt-in rewarded ad</t.Text>
552
+ </t.Box>
553
+ <t.Button key="cta" label={`${ad.ctaLabel || "Learn more"} →`} onPress={() => void onCtaPress($)} />
554
+ </t.Box>
555
+ );
556
+ });
557
+ };
@@ -0,0 +1,4 @@
1
+ {
2
+ "description": "PromptAI native rewarded-ad card (Claude Mods function hooks).",
3
+ "modules": ["./ads.tsx"]
4
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Brand logo → terminal cells for the AbovePrompt card.
3
+ *
4
+ * Works in any terminal (Apple Terminal included): the logo becomes a
5
+ * `Raster` of half-block glyphs, two pixels per cell. Everything here fails
6
+ * open — a missing converter, a slow CDN or an odd format just means the card
7
+ * draws without a logo.
8
+ *
9
+ * Pure image code only: the host pipeline (curl → svg→png → bmp) lives in
10
+ * ads.tsx, because every `$.noun.method()` call has to sit in the hooks module.
11
+ */
12
+ export interface LogoCells {
13
+ columns: number;
14
+ rows: number;
15
+ /** Raster `cells`: base64 of little-endian u32 [codePoint, fg, bg] triplets. */
16
+ cells: string;
17
+ }
18
+
19
+ /** Working size handed to the converters; downscaled to the cell grid here. */
20
+ export const WORK_PX = 64;
21
+ const DEFAULT_COLOR = 0x01000000;
22
+ const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
23
+
24
+ export function encodeBase64(bytes: Uint8Array): string {
25
+ let out = "";
26
+ for (let i = 0; i < bytes.length; i += 3) {
27
+ const a = bytes[i];
28
+ const b = i + 1 < bytes.length ? bytes[i + 1] : 0;
29
+ const c = i + 2 < bytes.length ? bytes[i + 2] : 0;
30
+ out += B64[a >> 2] + B64[((a & 3) << 4) | (b >> 4)];
31
+ out += i + 1 < bytes.length ? B64[((b & 15) << 2) | (c >> 6)] : "=";
32
+ out += i + 2 < bytes.length ? B64[c & 63] : "=";
33
+ }
34
+ return out;
35
+ }
36
+
37
+ export function decodeBase64(text: string): Uint8Array {
38
+ const bin = atob(text);
39
+ const out = new Uint8Array(bin.length);
40
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
41
+ return out;
42
+ }
43
+
44
+ export interface Bitmap {
45
+ width: number;
46
+ height: number;
47
+ /** RGBA, row-major, top-down. */
48
+ rgba: Uint8Array;
49
+ }
50
+
51
+ /** Uncompressed 24/32-bit BMP (what sips and ImageMagick's BMP3 write). */
52
+ export function parseBmp(bytes: Uint8Array): Bitmap | null {
53
+ if (bytes.length < 54 || bytes[0] !== 0x42 || bytes[1] !== 0x4d) return null;
54
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
55
+ const dataOffset = view.getUint32(10, true);
56
+ const width = view.getInt32(18, true);
57
+ const rawHeight = view.getInt32(22, true);
58
+ const bpp = view.getUint16(28, true);
59
+ const compression = view.getUint32(30, true);
60
+ const height = Math.abs(rawHeight);
61
+ const topDown = rawHeight < 0;
62
+ if (width <= 0 || height <= 0 || width > 4096 || height > 4096) return null;
63
+ if ((bpp !== 24 && bpp !== 32) || (compression !== 0 && compression !== 3)) return null;
64
+
65
+ const bytesPerPx = bpp / 8;
66
+ const stride = ((width * bpp + 31) >> 5) << 2;
67
+ if (dataOffset + stride * height > bytes.length) return null;
68
+
69
+ const rgba = new Uint8Array(width * height * 4);
70
+ let anyAlpha = false;
71
+ for (let y = 0; y < height; y++) {
72
+ const srcRow = dataOffset + (topDown ? y : height - 1 - y) * stride;
73
+ for (let x = 0; x < width; x++) {
74
+ const s = srcRow + x * bytesPerPx;
75
+ const d = (y * width + x) * 4;
76
+ rgba[d] = bytes[s + 2];
77
+ rgba[d + 1] = bytes[s + 1];
78
+ rgba[d + 2] = bytes[s];
79
+ const a = bpp === 32 ? bytes[s + 3] : 255;
80
+ rgba[d + 3] = a;
81
+ if (a !== 0) anyAlpha = true;
82
+ }
83
+ }
84
+ // A 32-bit BMP whose alpha byte is all zero is XRGB padding, not "invisible".
85
+ if (!anyAlpha) for (let i = 3; i < rgba.length; i += 4) rgba[i] = 255;
86
+ return { width, height, rgba };
87
+ }
88
+
89
+ /**
90
+ * Fit `bmp` into columns × (rows*2) pixels (aspect kept, centred, area
91
+ * averaged) and pack half-block cells. Transparent pixels stay the terminal's
92
+ * own background, so the logo sits on the card, not on a colored square.
93
+ */
94
+ export function bitmapToCells(bmp: Bitmap, columns: number, rows: number): LogoCells {
95
+ const W = columns;
96
+ const H = rows * 2;
97
+ const scale = Math.min(W / bmp.width, H / bmp.height);
98
+ const fitW = Math.max(1, Math.round(bmp.width * scale));
99
+ const fitH = Math.max(1, Math.round(bmp.height * scale));
100
+ const offX = Math.floor((W - fitW) / 2);
101
+ const offY = Math.floor((H - fitH) / 2);
102
+
103
+ // One RGB (or null for transparent) per target pixel.
104
+ const px: Array<number | null> = new Array(W * H).fill(null);
105
+ for (let ty = 0; ty < fitH; ty++) {
106
+ const y0 = Math.floor((ty * bmp.height) / fitH);
107
+ const y1 = Math.max(y0 + 1, Math.floor(((ty + 1) * bmp.height) / fitH));
108
+ for (let tx = 0; tx < fitW; tx++) {
109
+ const x0 = Math.floor((tx * bmp.width) / fitW);
110
+ const x1 = Math.max(x0 + 1, Math.floor(((tx + 1) * bmp.width) / fitW));
111
+ let r = 0;
112
+ let g = 0;
113
+ let b = 0;
114
+ let a = 0;
115
+ let n = 0;
116
+ for (let y = y0; y < y1; y++) {
117
+ for (let x = x0; x < x1; x++) {
118
+ const i = (y * bmp.width + x) * 4;
119
+ const alpha = bmp.rgba[i + 3];
120
+ r += bmp.rgba[i] * alpha;
121
+ g += bmp.rgba[i + 1] * alpha;
122
+ b += bmp.rgba[i + 2] * alpha;
123
+ a += alpha;
124
+ n++;
125
+ }
126
+ }
127
+ if (a / n < 90) continue; // mostly transparent
128
+ const R = Math.round(r / a);
129
+ const G = Math.round(g / a);
130
+ const B = Math.round(b / a);
131
+ px[(offY + ty) * W + (offX + tx)] = (R << 16) | (G << 8) | B;
132
+ }
133
+ }
134
+
135
+ const words = new Uint32Array(columns * rows * 3);
136
+ for (let row = 0; row < rows; row++) {
137
+ for (let col = 0; col < columns; col++) {
138
+ const top = px[row * 2 * W + col];
139
+ const bottom = px[(row * 2 + 1) * W + col];
140
+ let glyph = 0x20;
141
+ let fg = DEFAULT_COLOR;
142
+ let bg = DEFAULT_COLOR;
143
+ if (top !== null && bottom !== null) {
144
+ glyph = 0x2580; // ▀
145
+ fg = top;
146
+ bg = bottom;
147
+ } else if (top !== null) {
148
+ glyph = 0x2580;
149
+ fg = top;
150
+ } else if (bottom !== null) {
151
+ glyph = 0x2584; // ▄
152
+ fg = bottom;
153
+ }
154
+ const at = (row * columns + col) * 3;
155
+ words[at] = glyph;
156
+ words[at + 1] = fg;
157
+ words[at + 2] = bg;
158
+ }
159
+ }
160
+ return { columns, rows, cells: encodeBase64(new Uint8Array(words.buffer)) };
161
+ }
162
+
163
+ export function hashKey(text: string): string {
164
+ let h = 5381;
165
+ for (let i = 0; i < text.length; i++) h = ((h * 33) ^ text.charCodeAt(i)) >>> 0;
166
+ return h.toString(36);
167
+ }
package/dist/index.js CHANGED
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import * as fs7 from "node:fs";
5
- import * as path6 from "node:path";
4
+ import * as fs8 from "node:fs";
5
+ import * as path7 from "node:path";
6
6
 
7
7
  // src/claude.ts
8
8
  import * as crypto3 from "node:crypto";
9
- import * as fs5 from "node:fs";
10
- import * as os3 from "node:os";
11
- import * as path4 from "node:path";
9
+ import * as fs6 from "node:fs";
10
+ import * as os4 from "node:os";
11
+ import * as path5 from "node:path";
12
12
 
13
13
  // src/api.ts
14
14
  import * as crypto from "node:crypto";
@@ -246,15 +246,20 @@ function openInBrowser(url, side = "right") {
246
246
  const chrome = chromeBin();
247
247
  if (chrome) {
248
248
  const profile = path2.join(promptaiDir(), "ad-window");
249
+ const appUrl = `${url}${url.includes("?") ? "&" : "?"}_=${Date.now()}`;
249
250
  try {
250
251
  spawn(
251
252
  chrome,
252
253
  [
253
254
  `--user-data-dir=${profile}`,
254
- `--app=${url}`,
255
+ `--app=${appUrl}`,
255
256
  `--window-position=${rect.x},${rect.y}`,
256
257
  `--window-size=${rect.w},${rect.h}`,
257
- "--new-window"
258
+ "--new-window",
259
+ "--no-first-run",
260
+ "--no-default-browser-check",
261
+ "--disable-session-crashed-bubble",
262
+ "--hide-crash-restore-bubble"
258
263
  ],
259
264
  { detached: true, stdio: "ignore" }
260
265
  ).unref();
@@ -372,6 +377,113 @@ function resolveEmail(config) {
372
377
  return detected.email;
373
378
  }
374
379
 
380
+ // src/plugin.ts
381
+ import * as fs4 from "node:fs";
382
+ import * as os3 from "node:os";
383
+ import * as path4 from "node:path";
384
+ import { fileURLToPath } from "node:url";
385
+ var PLUGIN_NAME = "promptai";
386
+ var HEARTBEAT_FILE = "native-plugin.json";
387
+ var HEARTBEAT_FRESH_MS = 2 * 60 * 60 * 1e3;
388
+ function claudePluginInstallDir() {
389
+ return path4.join(os3.homedir(), ".claude", "skills", PLUGIN_NAME);
390
+ }
391
+ function heartbeatPath() {
392
+ return path4.join(promptaiDir(), HEARTBEAT_FILE);
393
+ }
394
+ function pluginSourceDir() {
395
+ const here = path4.dirname(fileURLToPath(import.meta.url));
396
+ const candidates = [
397
+ // npm package / after build: claude-plugin/ next to dist/
398
+ path4.resolve(here, "..", "claude-plugin"),
399
+ // monorepo: product/cli/dist|src → product/claude-plugin
400
+ path4.resolve(here, "..", "..", "claude-plugin")
401
+ ];
402
+ for (const dir of candidates) {
403
+ if (fs4.existsSync(path4.join(dir, ".claude-plugin", "plugin.json"))) {
404
+ return dir;
405
+ }
406
+ if (fs4.existsSync(path4.join(dir, "hooks", "ads.tsx"))) {
407
+ return dir;
408
+ }
409
+ }
410
+ return null;
411
+ }
412
+ function copyDirSync(src, dest) {
413
+ fs4.mkdirSync(dest, { recursive: true });
414
+ for (const entry of fs4.readdirSync(src, { withFileTypes: true })) {
415
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
416
+ const from = path4.join(src, entry.name);
417
+ const to = path4.join(dest, entry.name);
418
+ if (entry.isDirectory()) {
419
+ copyDirSync(from, to);
420
+ } else if (entry.isFile()) {
421
+ fs4.copyFileSync(from, to);
422
+ }
423
+ }
424
+ }
425
+ function isClaudePluginInstalled() {
426
+ const dir = claudePluginInstallDir();
427
+ return fs4.existsSync(path4.join(dir, ".claude-plugin", "plugin.json")) || fs4.existsSync(path4.join(dir, "hooks", "ads.tsx"));
428
+ }
429
+ function isNativePluginActive(now = Date.now()) {
430
+ try {
431
+ const raw = fs4.readFileSync(heartbeatPath(), "utf8");
432
+ const data = JSON.parse(raw);
433
+ if (data.active !== true) return false;
434
+ const at = Number(data.at ?? 0);
435
+ const freshMs = Number(data.freshMs ?? HEARTBEAT_FRESH_MS);
436
+ if (!Number.isFinite(at) || at <= 0) return false;
437
+ return now - at < freshMs;
438
+ } catch {
439
+ return false;
440
+ }
441
+ }
442
+ function clearNativeHeartbeat() {
443
+ try {
444
+ fs4.unlinkSync(heartbeatPath());
445
+ } catch {
446
+ }
447
+ }
448
+ function installClaudePlugin() {
449
+ const source = pluginSourceDir();
450
+ const installPath = claudePluginInstallDir();
451
+ if (!source) {
452
+ return {
453
+ changed: false,
454
+ installPath,
455
+ sourcePath: null,
456
+ error: "Claude Mods plugin sources not found next to the CLI. Reinstall @promptai.credit/cli."
457
+ };
458
+ }
459
+ fs4.mkdirSync(path4.dirname(installPath), { recursive: true });
460
+ fs4.rmSync(installPath, { recursive: true, force: true });
461
+ copyDirSync(source, installPath);
462
+ fs4.writeFileSync(
463
+ heartbeatPath(),
464
+ JSON.stringify(
465
+ {
466
+ active: false,
467
+ installed: true,
468
+ installedAt: Date.now(),
469
+ installPath,
470
+ source,
471
+ note: "Becomes active when Claude Code loads the plugin (Mods / function hooks)."
472
+ },
473
+ null,
474
+ 2
475
+ ) + "\n"
476
+ );
477
+ return { changed: true, installPath, sourcePath: source };
478
+ }
479
+ function uninstallClaudePlugin() {
480
+ const installPath = claudePluginInstallDir();
481
+ const existed = fs4.existsSync(installPath);
482
+ fs4.rmSync(installPath, { recursive: true, force: true });
483
+ clearNativeHeartbeat();
484
+ return { changed: existed, installPath };
485
+ }
486
+
375
487
  // src/pricing.ts
376
488
  var TABLE = [
377
489
  { match: ["fable"], inputPerMtok: 3, outputPerMtok: 15 },
@@ -402,9 +514,9 @@ function costUsd(model, inputTokens, outputTokens) {
402
514
  }
403
515
 
404
516
  // src/transcript.ts
405
- import * as fs4 from "node:fs";
517
+ import * as fs5 from "node:fs";
406
518
  function readTranscriptUsage(transcriptPath, watermark) {
407
- const raw = fs4.readFileSync(transcriptPath, "utf8");
519
+ const raw = fs5.readFileSync(transcriptPath, "utf8");
408
520
  const byMessageId = /* @__PURE__ */ new Map();
409
521
  let parsedCount = 0;
410
522
  for (const line of raw.split("\n")) {
@@ -446,7 +558,7 @@ function readTranscriptUsage(transcriptPath, watermark) {
446
558
  };
447
559
  }
448
560
  function readGlassTranscriptUsage(transcriptPath, watermark) {
449
- const raw = fs4.readFileSync(transcriptPath, "utf8");
561
+ const raw = fs5.readFileSync(transcriptPath, "utf8");
450
562
  const lines = raw.split("\n").filter((l) => l.trim());
451
563
  let inputChars = 0;
452
564
  let outputChars = 0;
@@ -481,13 +593,13 @@ var MAX_CREDIT_PER_AD_USD = 5;
481
593
  var AD_MIN_INTERVAL_MS = 9e4;
482
594
  var SETTLE_RETRIES = [400, 800, 1500];
483
595
  function claudeSettingsPath() {
484
- return path4.join(os3.homedir(), ".claude", "settings.json");
596
+ return path5.join(os4.homedir(), ".claude", "settings.json");
485
597
  }
486
598
  function hookCommand() {
487
- let script = path4.resolve(process.argv[1] ?? "");
599
+ let script = path5.resolve(process.argv[1] ?? "");
488
600
  if (script.endsWith(".ts")) {
489
- const dist = path4.resolve(path4.dirname(script), "..", "dist", "index.js");
490
- if (!fs5.existsSync(dist)) {
601
+ const dist = path5.resolve(path5.dirname(script), "..", "dist", "index.js");
602
+ if (!fs6.existsSync(dist)) {
491
603
  throw new Error(
492
604
  `Build the CLI first (pnpm --filter @promptai.credit/cli build); hooks cannot run ${script} directly.`
493
605
  );
@@ -496,21 +608,34 @@ function hookCommand() {
496
608
  }
497
609
  return `"${process.execPath}" "${script}" hook`;
498
610
  }
611
+ var MODS_FLAG = "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS";
612
+ var MODS_FLAG_MARKER = "mods-flag-added";
613
+ function modsFlagMarkerPath() {
614
+ return path5.join(promptaiDir(), MODS_FLAG_MARKER);
615
+ }
499
616
  function installClaudeHooks() {
500
617
  const settingsPath = claudeSettingsPath();
501
- fs5.mkdirSync(path4.dirname(settingsPath), { recursive: true });
618
+ fs6.mkdirSync(path5.dirname(settingsPath), { recursive: true });
502
619
  let settings = {};
503
- if (fs5.existsSync(settingsPath)) {
620
+ if (fs6.existsSync(settingsPath)) {
504
621
  try {
505
- settings = JSON.parse(fs5.readFileSync(settingsPath, "utf8"));
622
+ settings = JSON.parse(fs6.readFileSync(settingsPath, "utf8"));
506
623
  } catch {
507
- fs5.copyFileSync(settingsPath, settingsPath + ".promptai-backup");
624
+ fs6.copyFileSync(settingsPath, settingsPath + ".promptai-backup");
508
625
  settings = {};
509
626
  }
510
627
  }
511
628
  settings.hooks = settings.hooks ?? {};
512
629
  const command2 = hookCommand();
513
630
  let changed = false;
631
+ settings.env = settings.env && typeof settings.env === "object" ? settings.env : {};
632
+ if (settings.env[MODS_FLAG] === void 0) {
633
+ settings.env[MODS_FLAG] = "1";
634
+ fs6.mkdirSync(promptaiDir(), { recursive: true });
635
+ fs6.writeFileSync(modsFlagMarkerPath(), `${(/* @__PURE__ */ new Date()).toISOString()}
636
+ `);
637
+ changed = true;
638
+ }
514
639
  for (const event of ["UserPromptSubmit", "Stop"]) {
515
640
  const groups = Array.isArray(settings.hooks[event]) ? settings.hooks[event] : [];
516
641
  let ours = groups.flatMap((g) => g.hooks ?? []).find((h) => typeof h.command === "string" && h.command.includes(HOOK_MARKER));
@@ -524,23 +649,30 @@ function installClaudeHooks() {
524
649
  changed = true;
525
650
  }
526
651
  }
527
- if (changed || !fs5.existsSync(settingsPath)) {
528
- fs5.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
652
+ if (changed || !fs6.existsSync(settingsPath)) {
653
+ fs6.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
529
654
  }
530
655
  return { changed, settingsPath };
531
656
  }
532
657
  function uninstallClaudeHooks() {
533
658
  const settingsPath = claudeSettingsPath();
534
- if (!fs5.existsSync(settingsPath)) return { changed: false };
659
+ if (!fs6.existsSync(settingsPath)) return { changed: false };
535
660
  let settings;
536
661
  try {
537
- settings = JSON.parse(fs5.readFileSync(settingsPath, "utf8"));
662
+ settings = JSON.parse(fs6.readFileSync(settingsPath, "utf8"));
538
663
  } catch {
539
664
  return { changed: false };
540
665
  }
541
- if (!settings.hooks) return { changed: false };
542
666
  let changed = false;
543
- for (const [event, groups] of Object.entries(settings.hooks)) {
667
+ if (fs6.existsSync(modsFlagMarkerPath())) {
668
+ if (settings.env?.[MODS_FLAG] === "1") {
669
+ delete settings.env[MODS_FLAG];
670
+ if (Object.keys(settings.env).length === 0) delete settings.env;
671
+ changed = true;
672
+ }
673
+ fs6.rmSync(modsFlagMarkerPath(), { force: true });
674
+ }
675
+ for (const [event, groups] of Object.entries(settings.hooks ?? {})) {
544
676
  if (!Array.isArray(groups)) continue;
545
677
  const kept = groups.map((g) => ({
546
678
  ...g,
@@ -554,7 +686,7 @@ function uninstallClaudeHooks() {
554
686
  }
555
687
  }
556
688
  if (changed) {
557
- fs5.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
689
+ fs6.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
558
690
  }
559
691
  return { changed };
560
692
  }
@@ -571,12 +703,16 @@ function handleUserPromptSubmit(payload) {
571
703
  }
572
704
  }
573
705
  if (config.adsOptIn && Date.now() - state.lastAdOpenedAt >= AD_MIN_INTERVAL_MS) {
574
- const url = watchUrl(config.serverUrl, config.deviceId, SOURCE);
575
- if (openInBrowser(url, config.adSide)) {
576
- state.lastAdOpenedAt = Date.now();
577
- log(`[claude] opened ad tab for session=${sessionId}`);
578
- } else if (!hasDisplay()) {
579
- log(`[claude] headless environment, skipped ad tab`);
706
+ if (isNativePluginActive()) {
707
+ log(`[claude] native Mods plugin active, skipped browser ad tab session=${sessionId}`);
708
+ } else {
709
+ const url = watchUrl(config.serverUrl, config.deviceId, SOURCE);
710
+ if (openInBrowser(url, config.adSide)) {
711
+ state.lastAdOpenedAt = Date.now();
712
+ log(`[claude] opened ad tab for session=${sessionId}`);
713
+ } else if (!hasDisplay()) {
714
+ log(`[claude] headless environment, skipped ad tab`);
715
+ }
580
716
  }
581
717
  }
582
718
  saveState(state);
@@ -656,9 +792,9 @@ async function redeemAgainstAd(serverUrl, deviceId, promptId, cost, email) {
656
792
 
657
793
  // src/cursor.ts
658
794
  import * as crypto4 from "node:crypto";
659
- import * as fs6 from "node:fs";
660
- import * as os4 from "node:os";
661
- import * as path5 from "node:path";
795
+ import * as fs7 from "node:fs";
796
+ import * as os5 from "node:os";
797
+ import * as path6 from "node:path";
662
798
  var CURSOR_SOURCE = "cursor-agents";
663
799
  var HOOK_MARKER2 = "promptai";
664
800
  var MAX_CREDIT_PER_AD_USD2 = 5;
@@ -668,17 +804,17 @@ function isCursorPayload(payload) {
668
804
  return typeof payload.cursor_version === "string" || typeof payload.conversation_id === "string";
669
805
  }
670
806
  function cursorHooksJsonPath() {
671
- return path5.join(os4.homedir(), ".cursor", "hooks.json");
807
+ return path6.join(os5.homedir(), ".cursor", "hooks.json");
672
808
  }
673
809
  function installCursorHooks() {
674
810
  const hooksPath = cursorHooksJsonPath();
675
- fs6.mkdirSync(path5.dirname(hooksPath), { recursive: true });
811
+ fs7.mkdirSync(path6.dirname(hooksPath), { recursive: true });
676
812
  let config = {};
677
- if (fs6.existsSync(hooksPath)) {
813
+ if (fs7.existsSync(hooksPath)) {
678
814
  try {
679
- config = JSON.parse(fs6.readFileSync(hooksPath, "utf8"));
815
+ config = JSON.parse(fs7.readFileSync(hooksPath, "utf8"));
680
816
  } catch {
681
- fs6.copyFileSync(hooksPath, hooksPath + ".promptai-backup");
817
+ fs7.copyFileSync(hooksPath, hooksPath + ".promptai-backup");
682
818
  config = {};
683
819
  }
684
820
  }
@@ -700,17 +836,17 @@ function installCursorHooks() {
700
836
  changed = true;
701
837
  }
702
838
  }
703
- if (changed || !fs6.existsSync(hooksPath)) {
704
- fs6.writeFileSync(hooksPath, JSON.stringify(config, null, 2) + "\n");
839
+ if (changed || !fs7.existsSync(hooksPath)) {
840
+ fs7.writeFileSync(hooksPath, JSON.stringify(config, null, 2) + "\n");
705
841
  }
706
842
  return { changed, hooksPath };
707
843
  }
708
844
  function uninstallCursorHooks() {
709
845
  const hooksPath = cursorHooksJsonPath();
710
- if (!fs6.existsSync(hooksPath)) return { changed: false };
846
+ if (!fs7.existsSync(hooksPath)) return { changed: false };
711
847
  let config;
712
848
  try {
713
- config = JSON.parse(fs6.readFileSync(hooksPath, "utf8"));
849
+ config = JSON.parse(fs7.readFileSync(hooksPath, "utf8"));
714
850
  } catch {
715
851
  return { changed: false };
716
852
  }
@@ -727,14 +863,14 @@ function uninstallCursorHooks() {
727
863
  }
728
864
  }
729
865
  if (changed) {
730
- fs6.writeFileSync(hooksPath, JSON.stringify(config, null, 2) + "\n");
866
+ fs7.writeFileSync(hooksPath, JSON.stringify(config, null, 2) + "\n");
731
867
  }
732
868
  return { changed };
733
869
  }
734
870
  function extensionIsActive() {
735
- const infoPath = path5.join(os4.homedir(), ".cursor", "promptai-listener.json");
871
+ const infoPath = path6.join(os5.homedir(), ".cursor", "promptai-listener.json");
736
872
  try {
737
- const info = JSON.parse(fs6.readFileSync(infoPath, "utf8"));
873
+ const info = JSON.parse(fs7.readFileSync(infoPath, "utf8"));
738
874
  if (typeof info.pid !== "number") return false;
739
875
  process.kill(info.pid, 0);
740
876
  return true;
@@ -743,18 +879,18 @@ function extensionIsActive() {
743
879
  }
744
880
  }
745
881
  function acquireOnceLock(key) {
746
- const dir = path5.join(promptaiDir(), "locks");
747
- fs6.mkdirSync(dir, { recursive: true });
882
+ const dir = path6.join(promptaiDir(), "locks");
883
+ fs7.mkdirSync(dir, { recursive: true });
748
884
  try {
749
885
  const cutoff = Date.now() - 24 * 60 * 60 * 1e3;
750
- for (const name of fs6.readdirSync(dir)) {
751
- const p = path5.join(dir, name);
752
- if (fs6.statSync(p).mtimeMs < cutoff) fs6.rmSync(p, { recursive: true, force: true });
886
+ for (const name of fs7.readdirSync(dir)) {
887
+ const p = path6.join(dir, name);
888
+ if (fs7.statSync(p).mtimeMs < cutoff) fs7.rmSync(p, { recursive: true, force: true });
753
889
  }
754
890
  } catch {
755
891
  }
756
892
  try {
757
- fs6.mkdirSync(path5.join(dir, key.replace(/[^a-zA-Z0-9_-]/g, "_")));
893
+ fs7.mkdirSync(path6.join(dir, key.replace(/[^a-zA-Z0-9_-]/g, "_")));
758
894
  return true;
759
895
  } catch {
760
896
  return false;
@@ -898,12 +1034,12 @@ import * as readline from "node:readline";
898
1034
  var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
899
1035
  var WALLET_RE = /^0x[0-9a-fA-F]{40}$/;
900
1036
  function promptLine(question) {
901
- return new Promise((resolve2) => {
1037
+ return new Promise((resolve3) => {
902
1038
  process.stdout.write(question);
903
1039
  const rl = readline.createInterface({ input: process.stdin });
904
1040
  rl.once("line", (line) => {
905
1041
  rl.close();
906
- resolve2(line);
1042
+ resolve3(line);
907
1043
  });
908
1044
  });
909
1045
  }
@@ -988,9 +1124,9 @@ async function onboard(config) {
988
1124
  var HELP = `promptai - ad-subsidized prompt credits for terminal agents
989
1125
 
990
1126
  Usage:
991
- promptai install claude Wire hooks into ~/.claude/settings.json
1127
+ promptai install claude Wire hooks + Claude Mods native ad plugin
992
1128
  promptai install cursor Wire hooks into ~/.cursor/hooks.json (Agents Window)
993
- promptai uninstall <agent> Remove our hooks (other hooks untouched)
1129
+ promptai uninstall <agent> Remove our hooks/plugin (other hooks untouched)
994
1130
  promptai status Device, balance, banked ad credits, recent prompts
995
1131
  promptai watch Open a rewarded ad in the browser now
996
1132
  promptai claim [address] Claim your verified balance as USDC (Base)
@@ -1002,10 +1138,10 @@ Usage:
1002
1138
  promptai hook (internal) invoked by agent hooks, JSON on stdin
1003
1139
  `;
1004
1140
  function readStdin() {
1005
- return new Promise((resolve2) => {
1141
+ return new Promise((resolve3) => {
1006
1142
  const chunks = [];
1007
1143
  process.stdin.on("data", (c) => chunks.push(c));
1008
- process.stdin.on("end", () => resolve2(Buffer.concat(chunks).toString("utf8")));
1144
+ process.stdin.on("end", () => resolve3(Buffer.concat(chunks).toString("utf8")));
1009
1145
  });
1010
1146
  }
1011
1147
  async function cmdHook() {
@@ -1017,11 +1153,11 @@ async function cmdHook() {
1017
1153
  return;
1018
1154
  }
1019
1155
  const event = String(payload.hook_event_name ?? "");
1020
- if (process.env.PROMPTAI_DUMP === "1" || fs7.existsSync(path6.join(promptaiDir(), "debug"))) {
1156
+ if (process.env.PROMPTAI_DUMP === "1" || fs8.existsSync(path7.join(promptaiDir(), "debug"))) {
1021
1157
  try {
1022
- const dir = path6.join(promptaiDir(), "dump");
1023
- fs7.mkdirSync(dir, { recursive: true });
1024
- fs7.writeFileSync(path6.join(dir, `${Date.now()}-${event || "unknown"}.json`), raw);
1158
+ const dir = path7.join(promptaiDir(), "dump");
1159
+ fs8.mkdirSync(dir, { recursive: true });
1160
+ fs8.writeFileSync(path7.join(dir, `${Date.now()}-${event || "unknown"}.json`), raw);
1025
1161
  } catch {
1026
1162
  }
1027
1163
  }
@@ -1052,6 +1188,11 @@ async function cmdStatus() {
1052
1188
  console.log(`email ${email || "(not detected - promptai set email you@example.com)"}`);
1053
1189
  console.log(`ads ${config.adsOptIn ? "opted in" : "opted out"}`);
1054
1190
  console.log(`side ${config.adSide}`);
1191
+ const nativeInstalled = isClaudePluginInstalled();
1192
+ const nativeActive = isNativePluginActive();
1193
+ console.log(
1194
+ `native ${nativeActive ? "active (AbovePrompt)" : nativeInstalled ? "installed (waiting for Mods session)" : "not installed"}`
1195
+ );
1055
1196
  console.log(`watch ${watchUrl(config.serverUrl, config.deviceId, "manual")}`);
1056
1197
  try {
1057
1198
  const [balance, verified] = await Promise.all([
@@ -1165,13 +1306,30 @@ function printInstallNext() {
1165
1306
  console.log("You can toggle ads with promptai on / promptai off.");
1166
1307
  console.log("Try: promptai watch, then run a prompt.");
1167
1308
  }
1309
+ function printClaudeModsHint(installPath) {
1310
+ console.log("");
1311
+ console.log("Native AbovePrompt ads use Claude Mods (function hooks, early access).");
1312
+ console.log("Enabled via env.CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 in your Claude settings;");
1313
+ console.log("this also turns on any other function-hook plugins you have.");
1314
+ console.log(`Plugin path: ${installPath}`);
1315
+ console.log("Where Mods are unavailable, ads still open in the browser (/watch).");
1316
+ }
1168
1317
  async function cmdInstall(agent) {
1169
1318
  if (agent === "claude" || agent === "claude-code") {
1170
1319
  const { changed, settingsPath } = installClaudeHooks();
1171
1320
  console.log(
1172
1321
  changed ? `Hooks installed into ${settingsPath}.` : `Hooks already installed in ${settingsPath}.`
1173
1322
  );
1174
- console.log("Claude Code picks them up on its next session.");
1323
+ const plugin = installClaudePlugin();
1324
+ if (plugin.error) {
1325
+ console.log(`Mods plugin: ${plugin.error}`);
1326
+ } else {
1327
+ console.log(
1328
+ plugin.changed ? `Mods plugin installed into ${plugin.installPath}.` : `Mods plugin already at ${plugin.installPath}.`
1329
+ );
1330
+ printClaudeModsHint(plugin.installPath);
1331
+ }
1332
+ console.log("Claude Code picks hooks up on its next session.");
1175
1333
  await onboard(loadConfig());
1176
1334
  printInstallNext();
1177
1335
  return;
@@ -1195,6 +1353,10 @@ function cmdUninstall(agent) {
1195
1353
  if (agent === "claude" || agent === "claude-code") {
1196
1354
  const { changed } = uninstallClaudeHooks();
1197
1355
  console.log(changed ? "Hooks removed." : "No promptai hooks found.");
1356
+ const plugin = uninstallClaudePlugin();
1357
+ console.log(
1358
+ plugin.changed ? `Mods plugin removed from ${plugin.installPath}.` : "No Mods plugin install found."
1359
+ );
1198
1360
  return;
1199
1361
  }
1200
1362
  if (agent === "cursor" || agent === "cursor-agents") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptai.credit/cli",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -22,11 +22,12 @@
22
22
  },
23
23
  "files": [
24
24
  "dist",
25
- "README.md"
25
+ "README.md",
26
+ "claude-plugin"
26
27
  ],
27
28
  "scripts": {
28
29
  "dev": "tsx src/index.ts",
29
- "build": "esbuild src/index.ts --bundle --platform=node --target=node20 --format=esm --outfile=dist/index.js --banner:js=\"#!/usr/bin/env node\"",
30
+ "build": "esbuild src/index.ts --bundle --platform=node --target=node20 --format=esm --outfile=dist/index.js --banner:js=\"#!/usr/bin/env node\" && node scripts/copy-plugin.mjs",
30
31
  "prepublishOnly": "pnpm build",
31
32
  "typecheck": "tsc --noEmit"
32
33
  },