@brozarti/spincome 0.1.2 → 0.1.4

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
  }
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.animateWhileLoading = animateWhileLoading;
4
+ const HIDE_CURSOR = "\x1b[?25l";
5
+ const SHOW_CURSOR = "\x1b[?25h";
6
+ const CLEAR_LINE = "\x1b[2K\r";
7
+ const UP_ONE = "\x1b[1A";
8
+ const GREEN = "\x1b[32m";
9
+ const BRIGHT_GREEN = "\x1b[92m";
10
+ const DIM = "\x1b[2m";
11
+ const BOLD = "\x1b[1m";
12
+ const R = "\x1b[0m";
13
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
14
+ const COIN = ["◐", "◓", "◑", "◒"];
15
+ const LOGO = `${BOLD}${GREEN}$${R}${BOLD} spincome${R}`;
16
+ async function animateWhileLoading(work) {
17
+ process.stdout.write(HIDE_CURSOR);
18
+ let frame = 0;
19
+ let dots = 0;
20
+ let done = false;
21
+ let result;
22
+ const tick = setInterval(() => {
23
+ if (done)
24
+ return;
25
+ const spinner = BRIGHT_GREEN + SPINNER[frame % SPINNER.length] + R;
26
+ const coin = GREEN + COIN[frame % COIN.length] + R;
27
+ dots = (dots + 1) % 4;
28
+ const dotStr = DIM + ".".repeat(dots).padEnd(3) + R;
29
+ process.stdout.write(CLEAR_LINE +
30
+ ` ${spinner} ${LOGO} ${coin} ${DIM}earning${R}${dotStr}`);
31
+ frame++;
32
+ }, 80);
33
+ try {
34
+ result = await work;
35
+ }
36
+ finally {
37
+ done = true;
38
+ clearInterval(tick);
39
+ process.stdout.write(CLEAR_LINE);
40
+ process.stdout.write(UP_ONE + CLEAR_LINE);
41
+ process.stdout.write(SHOW_CURSOR);
42
+ }
43
+ return result;
44
+ }
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,17 @@ 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");
11
+ const animate_js_1 = require("./animate.js");
12
12
  const path_1 = __importDefault(require("path"));
13
13
  function extractContext(payload) {
14
14
  const toolName = payload.tool_name ?? undefined;
15
- // Extract file extension from any path-like field in the tool input
16
15
  const filePath = payload.tool_input?.file_path ??
17
16
  payload.tool_input?.path ??
18
17
  undefined;
19
- const fileExt = filePath ? path_1.default.extname(filePath).replace(".", "").toLowerCase() || undefined : undefined;
18
+ const fileExt = filePath
19
+ ? path_1.default.extname(filePath).replace(".", "").toLowerCase() || undefined
20
+ : undefined;
20
21
  return { toolName, fileExt };
21
22
  }
22
23
  async function main() {
@@ -31,13 +32,15 @@ async function main() {
31
32
  payload = JSON.parse(Buffer.concat(chunks).toString("utf8"));
32
33
  }
33
34
  catch {
34
- // Malformed or empty stdin -- proceed with empty context
35
+ // empty or malformed stdin
35
36
  }
36
37
  const context = extractContext(payload);
38
+ // Fetch ad and record impression concurrently, animate while waiting
37
39
  const ad = await (0, ad_js_1.fetchAd)(context);
38
40
  if (!ad)
39
41
  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);
42
+ const earnedCents = await (0, animate_js_1.animateWhileLoading)((0, ad_js_1.recordImpression)(ad.id, config.developerKey, ad.actualCpmCents, context));
43
+ const session = (0, session_js_1.addToSession)(earnedCents);
44
+ process.stdout.write((0, display_js_1.renderAd)(ad, earnedCents, session.totalCents, context));
42
45
  }
43
46
  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.4",
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/animate.ts ADDED
@@ -0,0 +1,49 @@
1
+ const HIDE_CURSOR = "\x1b[?25l";
2
+ const SHOW_CURSOR = "\x1b[?25h";
3
+ const CLEAR_LINE = "\x1b[2K\r";
4
+ const UP_ONE = "\x1b[1A";
5
+
6
+ const GREEN = "\x1b[32m";
7
+ const BRIGHT_GREEN = "\x1b[92m";
8
+ const DIM = "\x1b[2m";
9
+ const BOLD = "\x1b[1m";
10
+ const R = "\x1b[0m";
11
+
12
+ const SPINNER = ["⠋","⠙","⠹","⠸","⠼","⠴","⠦","⠧","⠇","⠏"];
13
+ const COIN = ["◐","◓","◑","◒"];
14
+
15
+ const LOGO = `${BOLD}${GREEN}$${R}${BOLD} spincome${R}`;
16
+
17
+ export async function animateWhileLoading<T>(work: Promise<T>): Promise<T> {
18
+ process.stdout.write(HIDE_CURSOR);
19
+
20
+ let frame = 0;
21
+ let dots = 0;
22
+ let done = false;
23
+ let result: T;
24
+
25
+ const tick = setInterval(() => {
26
+ if (done) return;
27
+ const spinner = BRIGHT_GREEN + SPINNER[frame % SPINNER.length] + R;
28
+ const coin = GREEN + COIN[frame % COIN.length] + R;
29
+ dots = (dots + 1) % 4;
30
+ const dotStr = DIM + ".".repeat(dots).padEnd(3) + R;
31
+ process.stdout.write(
32
+ CLEAR_LINE +
33
+ ` ${spinner} ${LOGO} ${coin} ${DIM}earning${R}${dotStr}`
34
+ );
35
+ frame++;
36
+ }, 80);
37
+
38
+ try {
39
+ result = await work;
40
+ } finally {
41
+ done = true;
42
+ clearInterval(tick);
43
+ process.stdout.write(CLEAR_LINE);
44
+ process.stdout.write(UP_ONE + CLEAR_LINE);
45
+ process.stdout.write(SHOW_CURSOR);
46
+ }
47
+
48
+ return result!;
49
+ }
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,10 @@
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";
7
+ import { animateWhileLoading } from "./animate.js";
8
8
  import path from "path";
9
9
 
10
10
  interface HookPayload {
@@ -18,15 +18,13 @@ interface HookPayload {
18
18
 
19
19
  function extractContext(payload: HookPayload): AdContext {
20
20
  const toolName = payload.tool_name ?? undefined;
21
-
22
- // Extract file extension from any path-like field in the tool input
23
21
  const filePath =
24
22
  payload.tool_input?.file_path ??
25
23
  payload.tool_input?.path ??
26
24
  undefined;
27
-
28
- const fileExt = filePath ? path.extname(filePath).replace(".", "").toLowerCase() || undefined : undefined;
29
-
25
+ const fileExt = filePath
26
+ ? path.extname(filePath).replace(".", "").toLowerCase() || undefined
27
+ : undefined;
30
28
  return { toolName, fileExt };
31
29
  }
32
30
 
@@ -41,15 +39,21 @@ async function main() {
41
39
  try {
42
40
  payload = JSON.parse(Buffer.concat(chunks).toString("utf8"));
43
41
  } catch {
44
- // Malformed or empty stdin -- proceed with empty context
42
+ // empty or malformed stdin
45
43
  }
46
44
 
47
45
  const context = extractContext(payload);
46
+
47
+ // Fetch ad and record impression concurrently, animate while waiting
48
48
  const ad = await fetchAd(context);
49
49
  if (!ad) process.exit(0);
50
50
 
51
- process.stdout.write(renderAd(ad));
52
- recordImpression(ad.id, config.developerKey, ad.actualCpmCents, context);
51
+ const earnedCents = await animateWhileLoading(
52
+ recordImpression(ad.id, config.developerKey, ad.actualCpmCents, context)
53
+ );
54
+
55
+ const session = addToSession(earnedCents);
56
+ process.stdout.write(renderAd(ad, earnedCents, session.totalCents, context));
53
57
  }
54
58
 
55
59
  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
+ }