@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 +25 -6
- package/claude-plugin/.claude-plugin/plugin.json +11 -0
- package/claude-plugin/README.md +33 -0
- package/claude-plugin/hooks/ads.tsx +557 -0
- package/claude-plugin/hooks/hooks.json +4 -0
- package/claude-plugin/hooks/logo.ts +167 -0
- package/dist/index.js +225 -63
- package/package.json +4 -3
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
|
|
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
|
-
|
|
17
|
-
|
|
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,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
|
|
5
|
-
import * as
|
|
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
|
|
10
|
-
import * as
|
|
11
|
-
import * as
|
|
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=${
|
|
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
|
|
517
|
+
import * as fs5 from "node:fs";
|
|
406
518
|
function readTranscriptUsage(transcriptPath, watermark) {
|
|
407
|
-
const raw =
|
|
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 =
|
|
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
|
|
596
|
+
return path5.join(os4.homedir(), ".claude", "settings.json");
|
|
485
597
|
}
|
|
486
598
|
function hookCommand() {
|
|
487
|
-
let script =
|
|
599
|
+
let script = path5.resolve(process.argv[1] ?? "");
|
|
488
600
|
if (script.endsWith(".ts")) {
|
|
489
|
-
const dist =
|
|
490
|
-
if (!
|
|
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
|
-
|
|
618
|
+
fs6.mkdirSync(path5.dirname(settingsPath), { recursive: true });
|
|
502
619
|
let settings = {};
|
|
503
|
-
if (
|
|
620
|
+
if (fs6.existsSync(settingsPath)) {
|
|
504
621
|
try {
|
|
505
|
-
settings = JSON.parse(
|
|
622
|
+
settings = JSON.parse(fs6.readFileSync(settingsPath, "utf8"));
|
|
506
623
|
} catch {
|
|
507
|
-
|
|
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 || !
|
|
528
|
-
|
|
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 (!
|
|
659
|
+
if (!fs6.existsSync(settingsPath)) return { changed: false };
|
|
535
660
|
let settings;
|
|
536
661
|
try {
|
|
537
|
-
settings = JSON.parse(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
|
660
|
-
import * as
|
|
661
|
-
import * as
|
|
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
|
|
807
|
+
return path6.join(os5.homedir(), ".cursor", "hooks.json");
|
|
672
808
|
}
|
|
673
809
|
function installCursorHooks() {
|
|
674
810
|
const hooksPath = cursorHooksJsonPath();
|
|
675
|
-
|
|
811
|
+
fs7.mkdirSync(path6.dirname(hooksPath), { recursive: true });
|
|
676
812
|
let config = {};
|
|
677
|
-
if (
|
|
813
|
+
if (fs7.existsSync(hooksPath)) {
|
|
678
814
|
try {
|
|
679
|
-
config = JSON.parse(
|
|
815
|
+
config = JSON.parse(fs7.readFileSync(hooksPath, "utf8"));
|
|
680
816
|
} catch {
|
|
681
|
-
|
|
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 || !
|
|
704
|
-
|
|
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 (!
|
|
846
|
+
if (!fs7.existsSync(hooksPath)) return { changed: false };
|
|
711
847
|
let config;
|
|
712
848
|
try {
|
|
713
|
-
config = JSON.parse(
|
|
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
|
-
|
|
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 =
|
|
871
|
+
const infoPath = path6.join(os5.homedir(), ".cursor", "promptai-listener.json");
|
|
736
872
|
try {
|
|
737
|
-
const info = JSON.parse(
|
|
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 =
|
|
747
|
-
|
|
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
|
|
751
|
-
const p =
|
|
752
|
-
if (
|
|
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
|
-
|
|
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((
|
|
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
|
-
|
|
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
|
|
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((
|
|
1141
|
+
return new Promise((resolve3) => {
|
|
1006
1142
|
const chunks = [];
|
|
1007
1143
|
process.stdin.on("data", (c) => chunks.push(c));
|
|
1008
|
-
process.stdin.on("end", () =>
|
|
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" ||
|
|
1156
|
+
if (process.env.PROMPTAI_DUMP === "1" || fs8.existsSync(path7.join(promptaiDir(), "debug"))) {
|
|
1021
1157
|
try {
|
|
1022
|
-
const dir =
|
|
1023
|
-
|
|
1024
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
},
|