@brozarti/spincome 0.1.2 → 0.1.3

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/dist/ad.js CHANGED
@@ -27,7 +27,7 @@ async function fetchAd(context) {
27
27
  }
28
28
  async function recordImpression(adId, developerKey, actualCpmCents, context) {
29
29
  try {
30
- await fetch(`${config_js_1.API_BASE}/ads/impression`, {
30
+ const res = await fetch(`${config_js_1.API_BASE}/ads/impression`, {
31
31
  method: "POST",
32
32
  headers: {
33
33
  "Content-Type": "application/json",
@@ -36,8 +36,13 @@ async function recordImpression(adId, developerKey, actualCpmCents, context) {
36
36
  body: JSON.stringify({ adId, actualCpmCents, ...context }),
37
37
  signal: AbortSignal.timeout(3000),
38
38
  });
39
+ if (res.ok) {
40
+ const data = await res.json();
41
+ return data.earnedCents ?? 0;
42
+ }
39
43
  }
40
44
  catch {
41
- // fire-and-forget
45
+ // ignore
42
46
  }
47
+ return 0;
43
48
  }
package/dist/display.js CHANGED
@@ -1,28 +1,58 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.renderAd = renderAd;
4
- const RESET = "\x1b[0m";
5
- const BOLD = "\x1b[1m";
4
+ const R = "\x1b[0m";
5
+ const B = "\x1b[1m";
6
6
  const DIM = "\x1b[2m";
7
+ const GREEN = "\x1b[32m";
8
+ const BRIGHT_GREEN = "\x1b[92m";
7
9
  const CYAN = "\x1b[36m";
8
10
  const YELLOW = "\x1b[33m";
9
- const BG_DARK = "\x1b[48;5;235m";
10
- function line(content = "", width = 60) {
11
- const padded = ` ${content}`.padEnd(width);
12
- return `${BG_DARK}${padded}${RESET}`;
11
+ const WHITE = "\x1b[97m";
12
+ const BG = "\x1b[48;5;234m";
13
+ const W = 62;
14
+ function pad(text, visibleLen) {
15
+ const spaces = Math.max(0, W - 2 - visibleLen);
16
+ return ` ${text}${" ".repeat(spaces)} `;
13
17
  }
14
- function renderAd(ad) {
15
- const W = 60;
16
- const divider = `${DIM}${"─".repeat(W)}${RESET}`;
18
+ function box(content, visibleLen) {
19
+ return `${BG}${pad(content, visibleLen)}${R}`;
20
+ }
21
+ function ruler() {
22
+ return `${DIM}${"─".repeat(W)}${R}`;
23
+ }
24
+ function renderAd(ad, earnedCents, sessionCents, context) {
25
+ const earnedDollars = (earnedCents / 100).toFixed(4);
26
+ const sessionDollars = (sessionCents / 100).toFixed(4);
27
+ const contextTag = context?.fileExt
28
+ ? ` · ${context.fileExt.toUpperCase()} dev`
29
+ : context?.toolName
30
+ ? ` · ${context.toolName}`
31
+ : "";
32
+ const sponsorLine = `${DIM}Sponsored · ${ad.advertiser}${contextTag}${R}`;
33
+ const headlineLine = `${B}${WHITE}${ad.headline}${R}`;
34
+ const bodyLine = `${DIM}${ad.body}${R}`;
35
+ const ctaLine = `${CYAN}${ad.cta}${R} ${DIM}${ad.clickUrl}${R}`;
36
+ const earnLine = `${BRIGHT_GREEN}+$${earnedDollars} earned${R} ${DIM}session total: ${GREEN}$${sessionDollars}${R}`;
37
+ const footerLine = `${DIM}spincome · /disable to opt out${R}`;
38
+ const sponsorLen = `Sponsored · ${ad.advertiser}${contextTag}`.length;
39
+ const headlineLen = ad.headline.length;
40
+ const bodyLen = ad.body.length;
41
+ const ctaLen = `${ad.cta} ${ad.clickUrl}`.length;
42
+ const earnLen = `+$${earnedDollars} earned session total: $${sessionDollars}`.length;
43
+ const footerLen = `spincome · /disable to opt out`.length;
17
44
  return [
18
45
  "",
19
- divider,
20
- line(`${DIM}Sponsored by ${ad.advertiser}${RESET}`, W),
21
- line(`${BOLD}${CYAN}${ad.headline}${RESET}`, W),
22
- line(`${ad.body}`, W),
23
- line(`${YELLOW}${ad.cta} → ${ad.clickUrl}${RESET}`, W),
24
- line(`${DIM}Earning with spincome.io | /disable to opt out${RESET}`, W),
25
- divider,
46
+ ruler(),
47
+ box(sponsorLine, sponsorLen),
48
+ box(headlineLine, headlineLen),
49
+ box(bodyLine, bodyLen),
50
+ box("", 0),
51
+ box(ctaLine, ctaLen),
52
+ box("", 0),
53
+ box(earnLine, earnLen),
54
+ box(footerLine, footerLen),
55
+ ruler(),
26
56
  "",
27
57
  ].join("\n");
28
58
  }
package/dist/hook.js CHANGED
@@ -1,7 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
- // Entry point for the Claude Code PostToolUse hook.
4
- // Claude Code pipes the hook payload as JSON on stdin.
5
3
  var __importDefault = (this && this.__importDefault) || function (mod) {
6
4
  return (mod && mod.__esModule) ? mod : { "default": mod };
7
5
  };
@@ -9,14 +7,16 @@ Object.defineProperty(exports, "__esModule", { value: true });
9
7
  const ad_js_1 = require("./ad.js");
10
8
  const display_js_1 = require("./display.js");
11
9
  const config_js_1 = require("./config.js");
10
+ const session_js_1 = require("./session.js");
12
11
  const path_1 = __importDefault(require("path"));
13
12
  function extractContext(payload) {
14
13
  const toolName = payload.tool_name ?? undefined;
15
- // Extract file extension from any path-like field in the tool input
16
14
  const filePath = payload.tool_input?.file_path ??
17
15
  payload.tool_input?.path ??
18
16
  undefined;
19
- const fileExt = filePath ? path_1.default.extname(filePath).replace(".", "").toLowerCase() || undefined : undefined;
17
+ const fileExt = filePath
18
+ ? path_1.default.extname(filePath).replace(".", "").toLowerCase() || undefined
19
+ : undefined;
20
20
  return { toolName, fileExt };
21
21
  }
22
22
  async function main() {
@@ -31,13 +31,15 @@ async function main() {
31
31
  payload = JSON.parse(Buffer.concat(chunks).toString("utf8"));
32
32
  }
33
33
  catch {
34
- // Malformed or empty stdin -- proceed with empty context
34
+ // empty or malformed stdin
35
35
  }
36
36
  const context = extractContext(payload);
37
37
  const ad = await (0, ad_js_1.fetchAd)(context);
38
38
  if (!ad)
39
39
  process.exit(0);
40
- process.stdout.write((0, display_js_1.renderAd)(ad));
41
- (0, ad_js_1.recordImpression)(ad.id, config.developerKey, ad.actualCpmCents, context);
40
+ // Record impression first to get earnedCents
41
+ const earnedCents = await (0, ad_js_1.recordImpression)(ad.id, config.developerKey, ad.actualCpmCents, context);
42
+ const session = (0, session_js_1.addToSession)(earnedCents);
43
+ process.stdout.write((0, display_js_1.renderAd)(ad, earnedCents, session.totalCents, context));
42
44
  }
43
45
  main().catch(() => process.exit(0));
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.addToSession = addToSession;
7
+ exports.getSessionTotal = getSessionTotal;
8
+ const fs_1 = __importDefault(require("fs"));
9
+ const path_1 = __importDefault(require("path"));
10
+ const os_1 = __importDefault(require("os"));
11
+ const SESSION_PATH = path_1.default.join(os_1.default.homedir(), ".spincome", "session.json");
12
+ const SESSION_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours = one coding session
13
+ function readSession() {
14
+ try {
15
+ const raw = fs_1.default.readFileSync(SESSION_PATH, "utf8");
16
+ const s = JSON.parse(raw);
17
+ if (Date.now() - s.startedAt > SESSION_TTL_MS)
18
+ return null;
19
+ return s;
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ function writeSession(s) {
26
+ const dir = path_1.default.dirname(SESSION_PATH);
27
+ if (!fs_1.default.existsSync(dir))
28
+ fs_1.default.mkdirSync(dir, { recursive: true });
29
+ fs_1.default.writeFileSync(SESSION_PATH, JSON.stringify(s));
30
+ }
31
+ function addToSession(earnedCents) {
32
+ const existing = readSession();
33
+ const session = existing
34
+ ? { ...existing, totalCents: existing.totalCents + earnedCents, impressions: existing.impressions + 1 }
35
+ : { startedAt: Date.now(), totalCents: earnedCents, impressions: 1 };
36
+ writeSession(session);
37
+ return session;
38
+ }
39
+ function getSessionTotal() {
40
+ return readSession()?.totalCents ?? 0;
41
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brozarti/spincome",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Earn passive income from your Claude Code sessions",
5
5
  "bin": {
6
6
  "spincome": "./dist/cli.js"
package/src/ad.ts CHANGED
@@ -41,9 +41,9 @@ export async function recordImpression(
41
41
  developerKey: string,
42
42
  actualCpmCents: number,
43
43
  context: AdContext
44
- ): Promise<void> {
44
+ ): Promise<number> {
45
45
  try {
46
- await fetch(`${API_BASE}/ads/impression`, {
46
+ const res = await fetch(`${API_BASE}/ads/impression`, {
47
47
  method: "POST",
48
48
  headers: {
49
49
  "Content-Type": "application/json",
@@ -52,7 +52,12 @@ export async function recordImpression(
52
52
  body: JSON.stringify({ adId, actualCpmCents, ...context }),
53
53
  signal: AbortSignal.timeout(3000),
54
54
  });
55
+ if (res.ok) {
56
+ const data = await res.json() as { earnedCents?: number };
57
+ return data.earnedCents ?? 0;
58
+ }
55
59
  } catch {
56
- // fire-and-forget
60
+ // ignore
57
61
  }
62
+ return 0;
58
63
  }
package/src/display.ts CHANGED
@@ -1,30 +1,66 @@
1
1
  import type { Ad } from "./ad.js";
2
2
 
3
- const RESET = "\x1b[0m";
4
- const BOLD = "\x1b[1m";
3
+ const R = "\x1b[0m";
4
+ const B = "\x1b[1m";
5
5
  const DIM = "\x1b[2m";
6
- const CYAN = "\x1b[36m";
6
+ const GREEN = "\x1b[32m";
7
+ const BRIGHT_GREEN = "\x1b[92m";
8
+ const CYAN = "\x1b[36m";
7
9
  const YELLOW = "\x1b[33m";
8
- const BG_DARK = "\x1b[48;5;235m";
10
+ const WHITE = "\x1b[97m";
11
+ const BG = "\x1b[48;5;234m";
9
12
 
10
- function line(content = "", width = 60): string {
11
- const padded = ` ${content}`.padEnd(width);
12
- return `${BG_DARK}${padded}${RESET}`;
13
+ const W = 62;
14
+
15
+ function pad(text: string, visibleLen: number): string {
16
+ const spaces = Math.max(0, W - 2 - visibleLen);
17
+ return ` ${text}${" ".repeat(spaces)} `;
18
+ }
19
+
20
+ function box(content: string, visibleLen: number): string {
21
+ return `${BG}${pad(content, visibleLen)}${R}`;
22
+ }
23
+
24
+ function ruler(): string {
25
+ return `${DIM}${"─".repeat(W)}${R}`;
13
26
  }
14
27
 
15
- export function renderAd(ad: Ad): string {
16
- const W = 60;
17
- const divider = `${DIM}${"─".repeat(W)}${RESET}`;
28
+ export function renderAd(ad: Ad, earnedCents: number, sessionCents: number, context?: { toolName?: string; fileExt?: string }): string {
29
+ const earnedDollars = (earnedCents / 100).toFixed(4);
30
+ const sessionDollars = (sessionCents / 100).toFixed(4);
31
+
32
+ const contextTag = context?.fileExt
33
+ ? ` · ${context.fileExt.toUpperCase()} dev`
34
+ : context?.toolName
35
+ ? ` · ${context.toolName}`
36
+ : "";
37
+
38
+ const sponsorLine = `${DIM}Sponsored · ${ad.advertiser}${contextTag}${R}`;
39
+ const headlineLine = `${B}${WHITE}${ad.headline}${R}`;
40
+ const bodyLine = `${DIM}${ad.body}${R}`;
41
+ const ctaLine = `${CYAN}${ad.cta}${R} ${DIM}${ad.clickUrl}${R}`;
42
+ const earnLine = `${BRIGHT_GREEN}+$${earnedDollars} earned${R} ${DIM}session total: ${GREEN}$${sessionDollars}${R}`;
43
+ const footerLine = `${DIM}spincome · /disable to opt out${R}`;
44
+
45
+ const sponsorLen = `Sponsored · ${ad.advertiser}${contextTag}`.length;
46
+ const headlineLen = ad.headline.length;
47
+ const bodyLen = ad.body.length;
48
+ const ctaLen = `${ad.cta} ${ad.clickUrl}`.length;
49
+ const earnLen = `+$${earnedDollars} earned session total: $${sessionDollars}`.length;
50
+ const footerLen = `spincome · /disable to opt out`.length;
18
51
 
19
52
  return [
20
53
  "",
21
- divider,
22
- line(`${DIM}Sponsored by ${ad.advertiser}${RESET}`, W),
23
- line(`${BOLD}${CYAN}${ad.headline}${RESET}`, W),
24
- line(`${ad.body}`, W),
25
- line(`${YELLOW}${ad.cta} → ${ad.clickUrl}${RESET}`, W),
26
- line(`${DIM}Earning with spincome.io | /disable to opt out${RESET}`, W),
27
- divider,
54
+ ruler(),
55
+ box(sponsorLine, sponsorLen),
56
+ box(headlineLine, headlineLen),
57
+ box(bodyLine, bodyLen),
58
+ box("", 0),
59
+ box(ctaLine, ctaLen),
60
+ box("", 0),
61
+ box(earnLine, earnLen),
62
+ box(footerLine, footerLen),
63
+ ruler(),
28
64
  "",
29
65
  ].join("\n");
30
66
  }
package/src/hook.ts CHANGED
@@ -1,10 +1,9 @@
1
1
  #!/usr/bin/env node
2
- // Entry point for the Claude Code PostToolUse hook.
3
- // Claude Code pipes the hook payload as JSON on stdin.
4
2
 
5
3
  import { fetchAd, recordImpression, type AdContext } from "./ad.js";
6
4
  import { renderAd } from "./display.js";
7
5
  import { readConfig } from "./config.js";
6
+ import { addToSession } from "./session.js";
8
7
  import path from "path";
9
8
 
10
9
  interface HookPayload {
@@ -18,15 +17,13 @@ interface HookPayload {
18
17
 
19
18
  function extractContext(payload: HookPayload): AdContext {
20
19
  const toolName = payload.tool_name ?? undefined;
21
-
22
- // Extract file extension from any path-like field in the tool input
23
20
  const filePath =
24
21
  payload.tool_input?.file_path ??
25
22
  payload.tool_input?.path ??
26
23
  undefined;
27
-
28
- const fileExt = filePath ? path.extname(filePath).replace(".", "").toLowerCase() || undefined : undefined;
29
-
24
+ const fileExt = filePath
25
+ ? path.extname(filePath).replace(".", "").toLowerCase() || undefined
26
+ : undefined;
30
27
  return { toolName, fileExt };
31
28
  }
32
29
 
@@ -41,15 +38,18 @@ async function main() {
41
38
  try {
42
39
  payload = JSON.parse(Buffer.concat(chunks).toString("utf8"));
43
40
  } catch {
44
- // Malformed or empty stdin -- proceed with empty context
41
+ // empty or malformed stdin
45
42
  }
46
43
 
47
44
  const context = extractContext(payload);
48
45
  const ad = await fetchAd(context);
49
46
  if (!ad) process.exit(0);
50
47
 
51
- process.stdout.write(renderAd(ad));
52
- recordImpression(ad.id, config.developerKey, ad.actualCpmCents, context);
48
+ // Record impression first to get earnedCents
49
+ const earnedCents = await recordImpression(ad.id, config.developerKey, ad.actualCpmCents, context);
50
+ const session = addToSession(earnedCents);
51
+
52
+ process.stdout.write(renderAd(ad, earnedCents, session.totalCents, context));
53
53
  }
54
54
 
55
55
  main().catch(() => process.exit(0));
package/src/session.ts ADDED
@@ -0,0 +1,42 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import os from "os";
4
+
5
+ const SESSION_PATH = path.join(os.homedir(), ".spincome", "session.json");
6
+ const SESSION_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours = one coding session
7
+
8
+ interface Session {
9
+ startedAt: number;
10
+ totalCents: number;
11
+ impressions: number;
12
+ }
13
+
14
+ function readSession(): Session | null {
15
+ try {
16
+ const raw = fs.readFileSync(SESSION_PATH, "utf8");
17
+ const s = JSON.parse(raw) as Session;
18
+ if (Date.now() - s.startedAt > SESSION_TTL_MS) return null;
19
+ return s;
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ function writeSession(s: Session): void {
26
+ const dir = path.dirname(SESSION_PATH);
27
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
28
+ fs.writeFileSync(SESSION_PATH, JSON.stringify(s));
29
+ }
30
+
31
+ export function addToSession(earnedCents: number): Session {
32
+ const existing = readSession();
33
+ const session: Session = existing
34
+ ? { ...existing, totalCents: existing.totalCents + earnedCents, impressions: existing.impressions + 1 }
35
+ : { startedAt: Date.now(), totalCents: earnedCents, impressions: 1 };
36
+ writeSession(session);
37
+ return session;
38
+ }
39
+
40
+ export function getSessionTotal(): number {
41
+ return readSession()?.totalCents ?? 0;
42
+ }