@expitc/termad 0.1.0 → 0.1.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.
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ import { runWithAdOverlay } from "@expitc/termad/dist/surfaces/pty-inject.js";
3
+ const binary = process.argv[2];
4
+ const args = process.argv.slice(3);
5
+ runWithAdOverlay(binary, args);
@@ -0,0 +1,45 @@
1
+ #!/bin/sh
2
+ # ===== TERMAD-OVERLAY =====
3
+ # Reversible termad PTY wrapper. Renders ads INSIDE the TUI as a footer
4
+ # overlay (not above it). Reads ad from "__TERMAD_AD__" and displays as
5
+ # persistent footer. Falls back to pre-banner if node-pty unavailable.
6
+ # Original binary backed up at "__TERMAD_BACKUP__".
7
+ # Restore: copy the backup over this file, or run `termad uninstall`.
8
+
9
+ TERMAD_AD_FILE="__TERMAD_AD__"
10
+ TERMAD_BACKUP="__TERMAD_BACKUP__"
11
+ TERMAD_COUNT="__TERMAD_COUNT__"
12
+ TERMAD_HOME_DIR="$(dirname "$(dirname "$TERMAD_AD_FILE")")"
13
+
14
+ # Try PTY overlay via node script bundled with termad
15
+ OVERLAY_SCRIPT="$TERMAD_HOME_DIR/pty-overlay.mjs"
16
+ if [ ! -f "$OVERLAY_SCRIPT" ]; then
17
+ # Try to find in node_modules or global install
18
+ OVERLAY_SCRIPT="$(npm root -g 2>/dev/null)/@expitc/termad/dist/surfaces/pty-inject.mjs"
19
+ fi
20
+
21
+ if [ -f "$OVERLAY_SCRIPT" ] && command -v node >/dev/null 2>&1; then
22
+ export TERMAD_AD_FILE TERMAD_BACKUP TERMAD_COUNT
23
+ exec node "$OVERLAY_SCRIPT" "$TERMAD_BACKUP" "$@"
24
+ fi
25
+
26
+ # Fallback: pre-banner
27
+ AD_TEXT=""
28
+ AD_URL=""
29
+ AD_ID=""
30
+ if [ -r "$TERMAD_AD_FILE" ]; then
31
+ AD_TEXT=$(head -n 1 "$TERMAD_AD_FILE" 2>/dev/null)
32
+ AD_URL=$(sed -n 2p "$TERMAD_AD_FILE" 2>/dev/null)
33
+ AD_ID=$(sed -n 4p "$TERMAD_AD_FILE" 2>/dev/null)
34
+ fi
35
+ if [ -n "$AD_TEXT" ]; then
36
+ printf '\n [ad] %s\n' "$AD_TEXT"
37
+ case "$AD_URL" in
38
+ http://*|https://*) printf ' %s\n\n' "$AD_URL" ;;
39
+ *) printf '\n' ;;
40
+ esac
41
+ if [ -n "$AD_ID" ]; then
42
+ printf '%s\n' "$AD_ID" >> "$TERMAD_COUNT" 2>/dev/null || true
43
+ fi
44
+ fi
45
+ exec "$TERMAD_BACKUP" "$@"
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ import { runWithAdOverlay } from "@expitc/termad/dist/surfaces/pty-inject.js";
3
+ const binary = process.argv[2];
4
+ const args = process.argv.slice(3);
5
+ runWithAdOverlay(binary, args);
@@ -0,0 +1,45 @@
1
+ #!/bin/sh
2
+ # ===== TERMAD-OVERLAY =====
3
+ # Reversible termad PTY wrapper. Renders ads INSIDE the TUI as a footer
4
+ # overlay (not above it). Reads ad from "__TERMAD_AD__" and displays as
5
+ # persistent footer. Falls back to pre-banner if node-pty unavailable.
6
+ # Original binary backed up at "__TERMAD_BACKUP__".
7
+ # Restore: copy the backup over this file, or run `termad uninstall`.
8
+
9
+ TERMAD_AD_FILE="__TERMAD_AD__"
10
+ TERMAD_BACKUP="__TERMAD_BACKUP__"
11
+ TERMAD_COUNT="__TERMAD_COUNT__"
12
+ TERMAD_HOME_DIR="$(dirname "$(dirname "$TERMAD_AD_FILE")")"
13
+
14
+ # Try PTY overlay via node script bundled with termad
15
+ OVERLAY_SCRIPT="$TERMAD_HOME_DIR/pty-overlay.mjs"
16
+ if [ ! -f "$OVERLAY_SCRIPT" ]; then
17
+ # Try to find in node_modules or global install
18
+ OVERLAY_SCRIPT="$(npm root -g 2>/dev/null)/@expitc/termad/dist/surfaces/pty-inject.mjs"
19
+ fi
20
+
21
+ if [ -f "$OVERLAY_SCRIPT" ] && command -v node >/dev/null 2>&1; then
22
+ export TERMAD_AD_FILE TERMAD_BACKUP TERMAD_COUNT
23
+ exec node "$OVERLAY_SCRIPT" "$TERMAD_BACKUP" "$@"
24
+ fi
25
+
26
+ # Fallback: pre-banner
27
+ AD_TEXT=""
28
+ AD_URL=""
29
+ AD_ID=""
30
+ if [ -r "$TERMAD_AD_FILE" ]; then
31
+ AD_TEXT=$(head -n 1 "$TERMAD_AD_FILE" 2>/dev/null)
32
+ AD_URL=$(sed -n 2p "$TERMAD_AD_FILE" 2>/dev/null)
33
+ AD_ID=$(sed -n 4p "$TERMAD_AD_FILE" 2>/dev/null)
34
+ fi
35
+ if [ -n "$AD_TEXT" ]; then
36
+ printf '\n [ad] %s\n' "$AD_TEXT"
37
+ case "$AD_URL" in
38
+ http://*|https://*) printf ' %s\n\n' "$AD_URL" ;;
39
+ *) printf '\n' ;;
40
+ esac
41
+ if [ -n "$AD_ID" ]; then
42
+ printf '%s\n' "$AD_ID" >> "$TERMAD_COUNT" 2>/dev/null || true
43
+ fi
44
+ fi
45
+ exec "$TERMAD_BACKUP" "$@"
@@ -21,3 +21,4 @@ export declare const paths: {
21
21
  /** Wrapper backup for a binary/shim lives next to it: <bin>.termad-orig. */
22
22
  export declare function backupPath(target: string): string;
23
23
  export declare const WRAPPER_MARKER = "===== TERMAD-CLI =====";
24
+ export declare const OVERLAY_MARKER = "===== TERMAD-OVERLAY =====";
@@ -27,3 +27,4 @@ export function backupPath(target) {
27
27
  return target + ".termad-orig";
28
28
  }
29
29
  export const WRAPPER_MARKER = "===== TERMAD-CLI =====";
30
+ export const OVERLAY_MARKER = "===== TERMAD-OVERLAY =====";
@@ -0,0 +1,17 @@
1
+ export interface InlineResult {
2
+ ok: boolean;
3
+ alreadyPatched?: boolean;
4
+ reason?: string;
5
+ mode?: "binary-patch" | "wrapper-fallback";
6
+ }
7
+ export declare const INLINE_MARKER = "===== TERMAD-INLINE =====";
8
+ export declare const INLINE_BACKUP_EXT = ".termad-bundle-orig";
9
+ export declare function inlineBackupPath(target: string): string;
10
+ export declare function isInlinePatched(target: string): boolean;
11
+ /**
12
+ * Attempt to patch a compiled binary to render ads inline in the TUI.
13
+ * Falls back gracefully if patching isn't possible.
14
+ */
15
+ export declare function installInline(target: string): InlineResult;
16
+ export declare function uninstallInline(target: string): InlineResult;
17
+ export declare function isCompiledBinary(p: string): boolean;
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Inline (in-TUI) patching for compiled CLIs.
3
+ *
4
+ * Goal: inject a tiny footer renderer into the CLI's TUI so the ad appears
5
+ * inside the UI (like Kickbacks did), not above it.
6
+ *
7
+ * Strategy:
8
+ * - Detect Bun/Node compiled single-file binaries (Mach-O/ELF) by magic bytes
9
+ * - Keep pristine original in <bin>.termad-bundle-orig (separate from wrapper backup)
10
+ * - Search for Ink/render footers or stable strings and inject a loader that
11
+ * reads ~/.termad/ad.txt and renders "[ad] ..." as a subtle footer line
12
+ * - Fully reversible via uninstall + audit tracking
13
+ */
14
+ import { createHash } from "node:crypto";
15
+ import { copyFileSync, existsSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
16
+ import { WRAPPER_MARKER } from "../core/paths.js";
17
+ export const INLINE_MARKER = "===== TERMAD-INLINE =====";
18
+ export const INLINE_BACKUP_EXT = ".termad-bundle-orig";
19
+ export function inlineBackupPath(target) {
20
+ return target + INLINE_BACKUP_EXT;
21
+ }
22
+ export function isInlinePatched(target) {
23
+ try {
24
+ const buf = readFileSync(target);
25
+ // Quick check: look for our marker string in the binary
26
+ return buf.indexOf(Buffer.from(INLINE_MARKER)) !== -1;
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
32
+ function isMachO(p) {
33
+ try {
34
+ const buf = readFileSync(p);
35
+ if (buf.length < 4)
36
+ return false;
37
+ // fat binary or 64-bit little/big endian
38
+ const m = buf.readUInt32BE(0);
39
+ if (m === 0xcafebabe || m === 0xbebafeca)
40
+ return true;
41
+ const m2 = buf.readUInt32LE(0);
42
+ if (m2 === 0xfeedfacf || m2 === 0xcffaedfe)
43
+ return true;
44
+ if (m === 0xfeedface || m === 0xcefaedfe)
45
+ return true;
46
+ return false;
47
+ }
48
+ catch {
49
+ return false;
50
+ }
51
+ }
52
+ function isElf(p) {
53
+ try {
54
+ const buf = readFileSync(p);
55
+ return buf.length >= 4 && buf[0] === 0x7f && buf[1] === 0x45 && buf[2] === 0x4c && buf[3] === 0x46;
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ }
61
+ function sha256(p) {
62
+ try {
63
+ return createHash("sha256").update(readFileSync(p)).digest("hex");
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ // Minimal inline ad loader injected into embedded bundle
70
+ // Tries to read ~/.termad/ad.txt and expose a global for TUI to render
71
+ const INLINE_PAYLOAD = `/* ${INLINE_MARKER} */;(()=>{try{const fs=require('fs');const os=require('os');const p=require('path');const home=process.env.TERMAD_HOME||p.join(os.homedir(),'.termad');const f=p.join(home,'ad.txt');const c=fs.readFileSync(f,'utf8');const lines=c.replace(/\\r/g,'').split('\\n');const text=(lines[0]||'').trim();const url=(lines[1]||'').trim();if(text&&text!=='-'){globalThis.__TERMAD_AD__={text,url:url&&url!=='-'?url:undefined,at:Date.now()};}}catch(e){}})();`;
72
+ /**
73
+ * Attempt to patch a compiled binary to render ads inline in the TUI.
74
+ * Falls back gracefully if patching isn't possible.
75
+ */
76
+ export function installInline(target) {
77
+ if (!existsSync(target))
78
+ return { ok: false, reason: `target not found: ${target}` };
79
+ if (isInlinePatched(target))
80
+ return { ok: true, alreadyPatched: true, mode: "binary-patch" };
81
+ if (isWrappedCheck(target)) {
82
+ // Don't fight the shell wrapper - we'll upgrade it instead
83
+ return { ok: false, reason: "has termad wrapper; use inline upgrade" };
84
+ }
85
+ const bak = inlineBackupPath(target);
86
+ if (!existsSync(bak)) {
87
+ try {
88
+ copyFileSync(target, bak);
89
+ }
90
+ catch (e) {
91
+ return { ok: false, reason: e.message };
92
+ }
93
+ }
94
+ // For now, try a conservative string injection approach
95
+ // Look for common Ink patterns and inject our loader
96
+ try {
97
+ let buf = readFileSync(target);
98
+ let patched = false;
99
+ // Try to inject near module load points
100
+ const injectPoints = [
101
+ Buffer.from("use strict"),
102
+ Buffer.from("var React"),
103
+ Buffer.from('"use strict"'),
104
+ ];
105
+ for (const pt of injectPoints) {
106
+ const idx = buf.indexOf(pt);
107
+ if (idx !== -1 && idx < 2048) {
108
+ // Insert our payload after the first occurrence
109
+ const payload = Buffer.from(INLINE_PAYLOAD + "\n");
110
+ buf = Buffer.concat([buf.slice(0, idx), payload, buf.slice(idx)]);
111
+ patched = true;
112
+ break;
113
+ }
114
+ }
115
+ if (!patched) {
116
+ // Try appending near beginning of embedded JS
117
+ // Many Bun binaries have a loader section
118
+ const payload = Buffer.from(INLINE_PAYLOAD + "\n");
119
+ // Insert after first 256 bytes
120
+ buf = Buffer.concat([buf.slice(0, 512), payload, buf.slice(512)]);
121
+ patched = true;
122
+ }
123
+ if (patched) {
124
+ const mode = statSync(target).mode & 0o777;
125
+ writeFileSync(target, buf);
126
+ try {
127
+ // Restore permissions
128
+ if (!target.endsWith(".cmd") && !target.endsWith(".bat")) {
129
+ // chmod if possible
130
+ }
131
+ }
132
+ catch { }
133
+ return { ok: true, mode: "binary-patch" };
134
+ }
135
+ }
136
+ catch (e) {
137
+ return { ok: false, reason: e.message };
138
+ }
139
+ return { ok: false, reason: "could not find injection point" };
140
+ }
141
+ function isWrappedCheck(target) {
142
+ try {
143
+ const head = readFileSync(target, "utf8").slice(0, 256);
144
+ return head.includes(WRAPPER_MARKER);
145
+ }
146
+ catch {
147
+ return false;
148
+ }
149
+ }
150
+ export function uninstallInline(target) {
151
+ const bak = inlineBackupPath(target);
152
+ if (!existsSync(bak)) {
153
+ return { ok: true, alreadyPatched: false, reason: "no inline backup found" };
154
+ }
155
+ try {
156
+ if (existsSync(target) && isInlinePatched(target)) {
157
+ const mode = statSync(bak).mode & 0o777;
158
+ copyFileSync(bak, target);
159
+ rmSync(bak);
160
+ return { ok: true };
161
+ }
162
+ rmSync(bak);
163
+ return { ok: true, reason: "target not patched inline; backup removed" };
164
+ }
165
+ catch (e) {
166
+ return { ok: false, reason: e.message };
167
+ }
168
+ }
169
+ export function isCompiledBinary(p) {
170
+ return isMachO(p) || isElf(p);
171
+ }
@@ -0,0 +1 @@
1
+ export declare function runWithAdOverlay(binary: string, args?: string[]): Promise<void>;
@@ -0,0 +1,133 @@
1
+ import { readFileSync, appendFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ const TERMAD_HOME = process.env.TERMAD_HOME || join(homedir(), ".termad");
5
+ const AD_FILE = join(TERMAD_HOME, "ad.txt");
6
+ const COUNT_FILE = join(TERMAD_HOME, "display.log");
7
+ function readAd() {
8
+ try {
9
+ const raw = readFileSync(AD_FILE, "utf8").replace(/\r/g, "").split("\n");
10
+ const text = (raw[0] || "").trim();
11
+ const url = (raw[1] || "").trim();
12
+ const id = (raw[3] || "").trim();
13
+ if (!text || text === "-")
14
+ return null;
15
+ return { text, url: url && url !== "-" ? url : undefined, id: id && id !== "-" ? id : undefined };
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
21
+ function count(id) {
22
+ if (!id)
23
+ return;
24
+ try {
25
+ appendFileSync(COUNT_FILE, id + "\n");
26
+ }
27
+ catch { }
28
+ }
29
+ function buildFooter(ad) {
30
+ const label = "\x1b[2m\x1b[33m[ad]\x1b[0m";
31
+ const text = ad.text.length > 80 ? ad.text.slice(0, 77) + "..." : ad.text;
32
+ const url = ad.url && ad.url.length > 60 ? ad.url.slice(0, 57) + "..." : ad.url;
33
+ let line = ` ${label} ${text}`;
34
+ if (url)
35
+ line += ` \x1b[36m${url}\x1b[0m`;
36
+ return line;
37
+ }
38
+ export function runWithAdOverlay(binary, args = []) {
39
+ return runWithAdOverlayAsync(binary, args);
40
+ }
41
+ async function runWithAdOverlayAsync(binary, args = []) {
42
+ // Try to use node-pty if available
43
+ let pty;
44
+ try {
45
+ // @ts-ignore
46
+ pty = await import("node-pty");
47
+ }
48
+ catch {
49
+ try {
50
+ // @ts-ignore
51
+ pty = await import("@lydell/node-pty");
52
+ }
53
+ catch { }
54
+ }
55
+ if (!pty) {
56
+ // No PTY available - fall back to printing banner then execing
57
+ const ad = readAd();
58
+ if (ad) {
59
+ process.stdout.write("\n [ad] " + ad.text + "\n");
60
+ if (ad.url)
61
+ process.stdout.write(" " + ad.url + "\n\n");
62
+ count(ad.id);
63
+ }
64
+ const { spawn } = await import("node:child_process");
65
+ const child = spawn(binary, args, { stdio: "inherit" });
66
+ child.on("exit", (code) => process.exit(code ?? 0));
67
+ return;
68
+ }
69
+ const ad = readAd();
70
+ if (ad?.id)
71
+ count(ad.id);
72
+ const cols = process.stdout.columns || 120;
73
+ const rows = process.stdout.rows || 40;
74
+ const ptyProcess = pty.spawn(binary, args, {
75
+ name: "xterm-256color",
76
+ cols,
77
+ rows,
78
+ cwd: process.cwd(),
79
+ env: process.env,
80
+ });
81
+ let footerVisible = !!ad;
82
+ let lastAd = ad;
83
+ function drawFooter() {
84
+ if (!footerVisible || !lastAd)
85
+ return;
86
+ const footer = buildFooter(lastAd);
87
+ // Move to bottom row, clear line, print footer
88
+ const termRows = ptyProcess.rows || rows;
89
+ process.stdout.write(`\x1b[s\x1b[${termRows};1H\x1b[2K${footer}\x1b[u`);
90
+ }
91
+ function hideFooter() {
92
+ const termRows = ptyProcess.rows || rows;
93
+ process.stdout.write(`\x1b[s\x1b[${termRows};1H\x1b[2K\x1b[u`);
94
+ }
95
+ // Pipe PTY output to stdout with footer redrawn after each chunk
96
+ ptyProcess.onData((data) => {
97
+ process.stdout.write(data);
98
+ // Redraw footer after a short delay to stay on top
99
+ setTimeout(drawFooter, 10);
100
+ });
101
+ // Pipe stdin to PTY
102
+ if (process.stdin.isTTY) {
103
+ process.stdin.setRawMode(true);
104
+ }
105
+ process.stdin.on("data", (data) => {
106
+ ptyProcess.write(data);
107
+ });
108
+ // Handle resize
109
+ process.stdout.on("resize", () => {
110
+ const c = process.stdout.columns || cols;
111
+ const r = process.stdout.rows || rows;
112
+ try {
113
+ ptyProcess.resize(c, r);
114
+ }
115
+ catch { }
116
+ setTimeout(drawFooter, 50);
117
+ });
118
+ // Handle exit
119
+ ptyProcess.onExit(({ exitCode }) => {
120
+ if (process.stdin.isTTY) {
121
+ try {
122
+ process.stdin.setRawMode(false);
123
+ }
124
+ catch { }
125
+ }
126
+ process.stdin.pause();
127
+ hideFooter();
128
+ process.exit(exitCode ?? 0);
129
+ });
130
+ // Initial draw
131
+ setTimeout(drawFooter, 100);
132
+ setInterval(drawFooter, 500); // Keep footer visible on redraws
133
+ }
@@ -9,12 +9,15 @@ import { chmodSync, copyFileSync, existsSync, readFileSync, rmSync, statSync, wr
9
9
  import { delimiter, dirname, join } from "node:path";
10
10
  import { homedir } from "node:os";
11
11
  import { WRAPPER_MARKER, backupPath, paths } from "../core/paths.js";
12
+ const SH_TEMPLATE_OVERLAY = "wrapper.sh.overlay";
12
13
  const SH_TEMPLATE = "wrapper.sh.asset";
13
14
  const CMD_TEMPLATE = "wrapper.cmd.asset";
14
- function templatePath(isWin) {
15
+ function templatePath(isWin, useOverlay = true) {
15
16
  const here = dirname(new URL(import.meta.url).pathname);
16
17
  // dist/surfaces → ../assets (copy-assets.mjs mirrors assets/ into dist/)
17
- return join(here, "..", "assets", isWin ? CMD_TEMPLATE : SH_TEMPLATE);
18
+ if (isWin)
19
+ return join(here, "..", "assets", CMD_TEMPLATE);
20
+ return join(here, "..", "assets", useOverlay ? SH_TEMPLATE_OVERLAY : SH_TEMPLATE);
18
21
  }
19
22
  export function isWrapped(target) {
20
23
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expitc/termad",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Opt-in, fully reversible text ads for terminal AI CLIs (claude, codex, opencode, gemini). Consent-first, tamper-evident audit, 50% runner revenue share.",
5
5
  "type": "module",
6
6
  "license": "MIT",