@expitc/termad 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/assets/pty-overlay.mjs +53 -3
- package/assets/wrapper.sh.overlay +3 -3
- package/dist/assets/pty-overlay.mjs +53 -3
- package/dist/assets/wrapper.sh.overlay +3 -3
- package/dist/core/paths.d.ts +9 -0
- package/dist/core/paths.js +11 -0
- package/dist/install.js +39 -2
- package/dist/surfaces/inline.js +2 -2
- package/dist/surfaces/pty-inject.d.ts +2 -1
- package/dist/surfaces/pty-inject.js +181 -29
- package/dist/surfaces/wrap.d.ts +1 -1
- package/dist/surfaces/wrap.js +8 -4
- package/package.json +9 -1
- package/scripts/ensure-pty-helper.mjs +44 -0
package/assets/pty-overlay.mjs
CHANGED
|
@@ -1,5 +1,55 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Launcher for the PTY overlay.
|
|
4
|
+
*
|
|
5
|
+
* Must work from any install layout:
|
|
6
|
+
* <pkg>/dist/assets/pty-overlay.mjs (published package, global install)
|
|
7
|
+
* <pkg>/src/assets/pty-overlay.mjs (running from source)
|
|
8
|
+
* ~/.termad/pty-overlay.mjs (written by `termad install`)
|
|
9
|
+
*
|
|
10
|
+
* Resolves pty-inject.js relative to itself first, then by package name.
|
|
11
|
+
* If nothing resolves, exit non-zero WITHOUT touching the CLI — the
|
|
12
|
+
* wrapper's own pre-banner fallback takes over and the CLI still runs.
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync } from "node:fs";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
17
|
+
|
|
18
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
|
|
20
|
+
const candidates = [
|
|
21
|
+
join(here, "..", "surfaces", "pty-inject.js"), // dist/assets → dist/surfaces
|
|
22
|
+
join(here, "surfaces", "pty-inject.js"), // src/assets → src/surfaces
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
let runWithAdOverlay = null;
|
|
26
|
+
|
|
27
|
+
for (const c of candidates) {
|
|
28
|
+
if (!existsSync(c)) continue;
|
|
29
|
+
try {
|
|
30
|
+
({ runWithAdOverlay } = await import(pathToFileURL(c).href));
|
|
31
|
+
break;
|
|
32
|
+
} catch {}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!runWithAdOverlay) {
|
|
36
|
+
try {
|
|
37
|
+
({ runWithAdOverlay } = await import("@expitc/termad/dist/surfaces/pty-inject.js"));
|
|
38
|
+
} catch {}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (typeof runWithAdOverlay !== "function") {
|
|
42
|
+
console.error("termad: pty-inject.js not found — falling back to banner mode");
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
|
|
3
46
|
const binary = process.argv[2];
|
|
4
|
-
const
|
|
5
|
-
|
|
47
|
+
const surface = process.argv[3];
|
|
48
|
+
const args = process.argv.slice(4);
|
|
49
|
+
|
|
50
|
+
if (!binary) {
|
|
51
|
+
console.error("termad: usage: pty-overlay.mjs <binary> [args...]");
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
await runWithAdOverlay(binary, args, surface);
|
|
@@ -9,18 +9,18 @@
|
|
|
9
9
|
TERMAD_AD_FILE="__TERMAD_AD__"
|
|
10
10
|
TERMAD_BACKUP="__TERMAD_BACKUP__"
|
|
11
11
|
TERMAD_COUNT="__TERMAD_COUNT__"
|
|
12
|
-
TERMAD_HOME_DIR="$(dirname "$
|
|
12
|
+
TERMAD_HOME_DIR="$(dirname "$TERMAD_AD_FILE")"
|
|
13
13
|
|
|
14
14
|
# Try PTY overlay via node script bundled with termad
|
|
15
15
|
OVERLAY_SCRIPT="$TERMAD_HOME_DIR/pty-overlay.mjs"
|
|
16
16
|
if [ ! -f "$OVERLAY_SCRIPT" ]; then
|
|
17
17
|
# Try to find in node_modules or global install
|
|
18
|
-
OVERLAY_SCRIPT="$(npm root -g 2>/dev/null)/@expitc/termad/dist/
|
|
18
|
+
OVERLAY_SCRIPT="$(npm root -g 2>/dev/null)/@expitc/termad/dist/assets/pty-overlay.mjs"
|
|
19
19
|
fi
|
|
20
20
|
|
|
21
21
|
if [ -f "$OVERLAY_SCRIPT" ] && command -v node >/dev/null 2>&1; then
|
|
22
22
|
export TERMAD_AD_FILE TERMAD_BACKUP TERMAD_COUNT
|
|
23
|
-
exec node "$OVERLAY_SCRIPT" "$TERMAD_BACKUP" "$@"
|
|
23
|
+
exec node "$OVERLAY_SCRIPT" "$TERMAD_BACKUP" "__TERMAD_SURFACE__" "$@"
|
|
24
24
|
fi
|
|
25
25
|
|
|
26
26
|
# Fallback: pre-banner
|
|
@@ -1,5 +1,55 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Launcher for the PTY overlay.
|
|
4
|
+
*
|
|
5
|
+
* Must work from any install layout:
|
|
6
|
+
* <pkg>/dist/assets/pty-overlay.mjs (published package, global install)
|
|
7
|
+
* <pkg>/src/assets/pty-overlay.mjs (running from source)
|
|
8
|
+
* ~/.termad/pty-overlay.mjs (written by `termad install`)
|
|
9
|
+
*
|
|
10
|
+
* Resolves pty-inject.js relative to itself first, then by package name.
|
|
11
|
+
* If nothing resolves, exit non-zero WITHOUT touching the CLI — the
|
|
12
|
+
* wrapper's own pre-banner fallback takes over and the CLI still runs.
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync } from "node:fs";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
17
|
+
|
|
18
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
|
|
20
|
+
const candidates = [
|
|
21
|
+
join(here, "..", "surfaces", "pty-inject.js"), // dist/assets → dist/surfaces
|
|
22
|
+
join(here, "surfaces", "pty-inject.js"), // src/assets → src/surfaces
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
let runWithAdOverlay = null;
|
|
26
|
+
|
|
27
|
+
for (const c of candidates) {
|
|
28
|
+
if (!existsSync(c)) continue;
|
|
29
|
+
try {
|
|
30
|
+
({ runWithAdOverlay } = await import(pathToFileURL(c).href));
|
|
31
|
+
break;
|
|
32
|
+
} catch {}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!runWithAdOverlay) {
|
|
36
|
+
try {
|
|
37
|
+
({ runWithAdOverlay } = await import("@expitc/termad/dist/surfaces/pty-inject.js"));
|
|
38
|
+
} catch {}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (typeof runWithAdOverlay !== "function") {
|
|
42
|
+
console.error("termad: pty-inject.js not found — falling back to banner mode");
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
|
|
3
46
|
const binary = process.argv[2];
|
|
4
|
-
const
|
|
5
|
-
|
|
47
|
+
const surface = process.argv[3];
|
|
48
|
+
const args = process.argv.slice(4);
|
|
49
|
+
|
|
50
|
+
if (!binary) {
|
|
51
|
+
console.error("termad: usage: pty-overlay.mjs <binary> [args...]");
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
await runWithAdOverlay(binary, args, surface);
|
|
@@ -9,18 +9,18 @@
|
|
|
9
9
|
TERMAD_AD_FILE="__TERMAD_AD__"
|
|
10
10
|
TERMAD_BACKUP="__TERMAD_BACKUP__"
|
|
11
11
|
TERMAD_COUNT="__TERMAD_COUNT__"
|
|
12
|
-
TERMAD_HOME_DIR="$(dirname "$
|
|
12
|
+
TERMAD_HOME_DIR="$(dirname "$TERMAD_AD_FILE")"
|
|
13
13
|
|
|
14
14
|
# Try PTY overlay via node script bundled with termad
|
|
15
15
|
OVERLAY_SCRIPT="$TERMAD_HOME_DIR/pty-overlay.mjs"
|
|
16
16
|
if [ ! -f "$OVERLAY_SCRIPT" ]; then
|
|
17
17
|
# Try to find in node_modules or global install
|
|
18
|
-
OVERLAY_SCRIPT="$(npm root -g 2>/dev/null)/@expitc/termad/dist/
|
|
18
|
+
OVERLAY_SCRIPT="$(npm root -g 2>/dev/null)/@expitc/termad/dist/assets/pty-overlay.mjs"
|
|
19
19
|
fi
|
|
20
20
|
|
|
21
21
|
if [ -f "$OVERLAY_SCRIPT" ] && command -v node >/dev/null 2>&1; then
|
|
22
22
|
export TERMAD_AD_FILE TERMAD_BACKUP TERMAD_COUNT
|
|
23
|
-
exec node "$OVERLAY_SCRIPT" "$TERMAD_BACKUP" "$@"
|
|
23
|
+
exec node "$OVERLAY_SCRIPT" "$TERMAD_BACKUP" "__TERMAD_SURFACE__" "$@"
|
|
24
24
|
fi
|
|
25
25
|
|
|
26
26
|
# Fallback: pre-banner
|
package/dist/core/paths.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export declare const paths: {
|
|
|
6
6
|
adCache: () => string;
|
|
7
7
|
/** Claude statusline launcher installed into ~/.claude/settings.json. */
|
|
8
8
|
statuslineScript: () => string;
|
|
9
|
+
/** PTY overlay launcher the shell wrappers exec. Written by `termad install`. */
|
|
10
|
+
ptyOverlay: () => string;
|
|
9
11
|
/** Captured pre-install statusLine (chain capture, like claude does natively). */
|
|
10
12
|
prevStatusline: () => string;
|
|
11
13
|
log: () => string;
|
|
@@ -22,3 +24,10 @@ export declare const paths: {
|
|
|
22
24
|
export declare function backupPath(target: string): string;
|
|
23
25
|
export declare const WRAPPER_MARKER = "===== TERMAD-CLI =====";
|
|
24
26
|
export declare const OVERLAY_MARKER = "===== TERMAD-OVERLAY =====";
|
|
27
|
+
/** Every marker termad can write into a wrapped target.
|
|
28
|
+
* A target is termad's own if it carries ANY of them — checking only one
|
|
29
|
+
* makes overlay-wrapped binaries look untouched, which breaks uninstall
|
|
30
|
+
* (the original backup gets discarded) and misreports audit status. */
|
|
31
|
+
export declare const WRAPPER_MARKERS: readonly ["===== TERMAD-CLI =====", "===== TERMAD-OVERLAY ====="];
|
|
32
|
+
/** True if the content carries any termad wrapper marker. */
|
|
33
|
+
export declare function hasWrapperMarker(content: string): boolean;
|
package/dist/core/paths.js
CHANGED
|
@@ -10,6 +10,8 @@ export const paths = {
|
|
|
10
10
|
adCache: () => join(termadHome(), "ad.txt"),
|
|
11
11
|
/** Claude statusline launcher installed into ~/.claude/settings.json. */
|
|
12
12
|
statuslineScript: () => join(termadHome(), "statusline.mjs"),
|
|
13
|
+
/** PTY overlay launcher the shell wrappers exec. Written by `termad install`. */
|
|
14
|
+
ptyOverlay: () => join(termadHome(), "pty-overlay.mjs"),
|
|
13
15
|
/** Captured pre-install statusLine (chain capture, like claude does natively). */
|
|
14
16
|
prevStatusline: () => join(termadHome(), "prev-statusline.json"),
|
|
15
17
|
log: () => join(termadHome(), "termad.log"),
|
|
@@ -28,3 +30,12 @@ export function backupPath(target) {
|
|
|
28
30
|
}
|
|
29
31
|
export const WRAPPER_MARKER = "===== TERMAD-CLI =====";
|
|
30
32
|
export const OVERLAY_MARKER = "===== TERMAD-OVERLAY =====";
|
|
33
|
+
/** Every marker termad can write into a wrapped target.
|
|
34
|
+
* A target is termad's own if it carries ANY of them — checking only one
|
|
35
|
+
* makes overlay-wrapped binaries look untouched, which breaks uninstall
|
|
36
|
+
* (the original backup gets discarded) and misreports audit status. */
|
|
37
|
+
export const WRAPPER_MARKERS = [WRAPPER_MARKER, OVERLAY_MARKER];
|
|
38
|
+
/** True if the content carries any termad wrapper marker. */
|
|
39
|
+
export function hasWrapperMarker(content) {
|
|
40
|
+
return WRAPPER_MARKERS.some((m) => content.includes(m));
|
|
41
|
+
}
|
package/dist/install.js
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
import { loadConfig, saveConfig, writeFileAtomic } from "./core/config.js";
|
|
8
8
|
import { paths, termadHome, backupPath } from "./core/paths.js";
|
|
9
9
|
import { fetchAd, writeAdCache } from "./core/source.js";
|
|
10
|
-
import { existsSync, mkdirSync } from "node:fs";
|
|
10
|
+
import { existsSync, mkdirSync, writeFileSync, chmodSync } from "node:fs";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
11
12
|
import { getSurface } from "./surfaces/surfaces.js";
|
|
12
13
|
import { installWrapper, uninstallWrapper, isWrapped } from "./surfaces/wrap.js";
|
|
13
14
|
import { installStatusline, uninstallStatusline, settingsPath, isStatuslineInstalled } from "./surfaces/statusline.js";
|
|
@@ -46,11 +47,47 @@ export function planInstall(surfaces, force = false) {
|
|
|
46
47
|
}
|
|
47
48
|
return items;
|
|
48
49
|
}
|
|
50
|
+
/** Absolute path to the overlay implementation shipped with THIS install.
|
|
51
|
+
* dist/surfaces/pty-inject.js in a published package, src/… when run from
|
|
52
|
+
* source. */
|
|
53
|
+
function ptyInjectPath() {
|
|
54
|
+
const here = dirname(new URL(import.meta.url).pathname);
|
|
55
|
+
const js = join(here, "surfaces", "pty-inject.js");
|
|
56
|
+
const ts = join(here, "surfaces", "pty-inject.ts");
|
|
57
|
+
return existsSync(js) ? js : ts;
|
|
58
|
+
}
|
|
59
|
+
/** Write the overlay launcher into termad home so the wrapper can always
|
|
60
|
+
* find it. The absolute path to pty-inject is baked in, which keeps the
|
|
61
|
+
* launcher working regardless of how termad itself was installed. */
|
|
62
|
+
function installOverlayLauncher() {
|
|
63
|
+
const target = paths.ptyOverlay();
|
|
64
|
+
try {
|
|
65
|
+
const mod = ptyInjectPath();
|
|
66
|
+
const body = `#!/usr/bin/env node
|
|
67
|
+
// Written by \`termad install\`. Launches the PTY ad overlay for a CLI binary.
|
|
68
|
+
// Regenerated on every install — safe to edit/delete; termad rewrites it.
|
|
69
|
+
import { pathToFileURL } from "node:url";
|
|
70
|
+
const mod = await import(pathToFileURL(${JSON.stringify(mod)}).href);
|
|
71
|
+
if (typeof mod.runWithAdOverlay !== "function") {
|
|
72
|
+
console.error("termad: overlay unavailable");
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
await mod.runWithAdOverlay(process.argv[2], process.argv.slice(4), process.argv[3]);
|
|
76
|
+
`;
|
|
77
|
+
writeFileSync(target, body);
|
|
78
|
+
chmodSync(target, 0o755);
|
|
79
|
+
return target;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
49
85
|
/** Fetch the ad, write the cache, then install the opted-in surfaces. */
|
|
50
86
|
export async function install(surfaces, opts = {}) {
|
|
51
87
|
const force = opts.force ?? false;
|
|
52
88
|
const cfg = loadConfig();
|
|
53
89
|
mkdirSync(termadHome(), { recursive: true });
|
|
90
|
+
installOverlayLauncher();
|
|
54
91
|
const ad = await fetchAd(surfaces?.[0] ?? "claude");
|
|
55
92
|
writeAdCache(ad);
|
|
56
93
|
const wanted = surfaces ?? Object.entries(cfg.surfaces).filter(([, v]) => v).map(([k]) => k);
|
|
@@ -84,7 +121,7 @@ export async function install(surfaces, opts = {}) {
|
|
|
84
121
|
}
|
|
85
122
|
const wasWrapped = isWrapped(bin);
|
|
86
123
|
const shaBefore = sha256File(bin);
|
|
87
|
-
const r = installWrapper(bin, paths.adCache(), force);
|
|
124
|
+
const r = installWrapper(bin, paths.adCache(), force, id);
|
|
88
125
|
if (r.ok) {
|
|
89
126
|
const entry = loadManifest().entries.find((e) => e.surface === id && e.target === bin);
|
|
90
127
|
if (!entry) {
|
package/dist/surfaces/inline.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { createHash } from "node:crypto";
|
|
15
15
|
import { copyFileSync, existsSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
16
|
-
import {
|
|
16
|
+
import { hasWrapperMarker } from "../core/paths.js";
|
|
17
17
|
export const INLINE_MARKER = "===== TERMAD-INLINE =====";
|
|
18
18
|
export const INLINE_BACKUP_EXT = ".termad-bundle-orig";
|
|
19
19
|
export function inlineBackupPath(target) {
|
|
@@ -141,7 +141,7 @@ export function installInline(target) {
|
|
|
141
141
|
function isWrappedCheck(target) {
|
|
142
142
|
try {
|
|
143
143
|
const head = readFileSync(target, "utf8").slice(0, 256);
|
|
144
|
-
return head
|
|
144
|
+
return hasWrapperMarker(head);
|
|
145
145
|
}
|
|
146
146
|
catch {
|
|
147
147
|
return false;
|
|
@@ -1 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
import { type SurfaceId } from "../core/config.js";
|
|
2
|
+
export declare function runWithAdOverlay(binary: string, args?: string[], surface?: SurfaceId): Promise<void>;
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
import { readFileSync, appendFileSync } from "node:fs";
|
|
1
|
+
import { readFileSync, appendFileSync, existsSync, writeFileSync, mkdirSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import { isValidAd } from "../core/ad.js";
|
|
5
|
+
import { writeAdCache } from "../core/source.js";
|
|
4
6
|
const TERMAD_HOME = process.env.TERMAD_HOME || join(homedir(), ".termad");
|
|
5
7
|
const AD_FILE = join(TERMAD_HOME, "ad.txt");
|
|
6
8
|
const COUNT_FILE = join(TERMAD_HOME, "display.log");
|
|
9
|
+
const CONFIG_FILE = join(TERMAD_HOME, "config.json");
|
|
10
|
+
const STATS_FILE = join(TERMAD_HOME, "stats.json");
|
|
7
11
|
function readAd() {
|
|
8
12
|
try {
|
|
9
13
|
const raw = readFileSync(AD_FILE, "utf8").replace(/\r/g, "").split("\n");
|
|
@@ -18,6 +22,17 @@ function readAd() {
|
|
|
18
22
|
return null;
|
|
19
23
|
}
|
|
20
24
|
}
|
|
25
|
+
function readStyle() {
|
|
26
|
+
try {
|
|
27
|
+
if (!existsSync(CONFIG_FILE))
|
|
28
|
+
return "line";
|
|
29
|
+
const cfg = JSON.parse(readFileSync(CONFIG_FILE, "utf8"));
|
|
30
|
+
return cfg.style === "banner" ? "banner" : "line";
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return "line";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
21
36
|
function count(id) {
|
|
22
37
|
if (!id)
|
|
23
38
|
return;
|
|
@@ -26,20 +41,98 @@ function count(id) {
|
|
|
26
41
|
}
|
|
27
42
|
catch { }
|
|
28
43
|
}
|
|
29
|
-
function
|
|
44
|
+
function recordPrompt() {
|
|
45
|
+
try {
|
|
46
|
+
mkdirSync(TERMAD_HOME, { recursive: true });
|
|
47
|
+
let stats = { prompts: 0, sessions: 0 };
|
|
48
|
+
try {
|
|
49
|
+
stats = { ...stats, ...JSON.parse(readFileSync(STATS_FILE, "utf8")) };
|
|
50
|
+
}
|
|
51
|
+
catch { }
|
|
52
|
+
stats.prompts++;
|
|
53
|
+
writeFileSync(STATS_FILE, JSON.stringify(stats));
|
|
54
|
+
}
|
|
55
|
+
catch { }
|
|
56
|
+
}
|
|
57
|
+
function refreshAd(surface) {
|
|
58
|
+
try {
|
|
59
|
+
const cfg = JSON.parse(readFileSync(CONFIG_FILE, "utf8"));
|
|
60
|
+
if (!cfg.endpoint || !cfg.uid)
|
|
61
|
+
return Promise.resolve(false);
|
|
62
|
+
const url = `${String(cfg.endpoint).replace(/\/+$/, "")}/v1/ad?surface=${surface}&uid=${encodeURIComponent(cfg.uid)}`;
|
|
63
|
+
return fetch(url, { signal: AbortSignal.timeout(4000) }).then(async (res) => {
|
|
64
|
+
if (!res.ok)
|
|
65
|
+
return false;
|
|
66
|
+
const body = await res.json();
|
|
67
|
+
if (!isValidAd(body.ad))
|
|
68
|
+
return false;
|
|
69
|
+
writeAdCache(body.ad);
|
|
70
|
+
return true;
|
|
71
|
+
}).catch(() => false);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return Promise.resolve(false);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function osc8(url, text) {
|
|
78
|
+
if (!url)
|
|
79
|
+
return text;
|
|
80
|
+
return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`;
|
|
81
|
+
}
|
|
82
|
+
function getDomain(url) {
|
|
83
|
+
if (!url)
|
|
84
|
+
return null;
|
|
85
|
+
try {
|
|
86
|
+
const u = new URL(url);
|
|
87
|
+
return u.hostname.replace(/^www\./, "");
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function truncate(s, n) {
|
|
94
|
+
if (s.length <= n)
|
|
95
|
+
return s;
|
|
96
|
+
return s.slice(0, Math.max(0, n - 3)) + "...";
|
|
97
|
+
}
|
|
98
|
+
function buildFooter(ad, style, width, offset = 0) {
|
|
30
99
|
const label = "\x1b[2m\x1b[33m[ad]\x1b[0m";
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
100
|
+
const domain = getDomain(ad.url);
|
|
101
|
+
const w = Math.max(40, width - 4);
|
|
102
|
+
if (style === "banner") {
|
|
103
|
+
// Boxed style like Kickbacks
|
|
104
|
+
const innerW = Math.max(30, w - 2);
|
|
105
|
+
let displayText = ad.text;
|
|
106
|
+
// Marquee for long text
|
|
107
|
+
if (displayText.length > innerW - 20) {
|
|
108
|
+
const padded = displayText + " • " + displayText;
|
|
109
|
+
const start = offset % displayText.length;
|
|
110
|
+
displayText = padded.slice(start, start + innerW - 25);
|
|
111
|
+
}
|
|
112
|
+
const linkText = domain || ad.url;
|
|
113
|
+
const linked = ad.url ? osc8(ad.url, `\x1b[36m${truncate(linkText, 30)}\x1b[0m`) : "";
|
|
114
|
+
const content = ` ${label} ${truncate(displayText, innerW - 15)}${linked ? " " + linked : ""}`;
|
|
115
|
+
const padded = content + " ".repeat(Math.max(0, innerW - content.replace(/\x1b\[[0-9;]*m/g, "").length));
|
|
116
|
+
return `\x1b[90m┌${"─".repeat(innerW)}┐\x1b[0m\n\x1b[90m│\x1b[0m${padded}\x1b[90m│\x1b[0m\n\x1b[90m└${"─".repeat(innerW)}┘\x1b[0m`;
|
|
117
|
+
}
|
|
118
|
+
// Line style (minimal, clean)
|
|
119
|
+
let displayText = ad.text;
|
|
120
|
+
if (displayText.length > w - 40) {
|
|
121
|
+
const padded = displayText + " ";
|
|
122
|
+
const start = offset % (displayText.length + 5);
|
|
123
|
+
displayText = padded.slice(start, start + w - 45);
|
|
124
|
+
}
|
|
125
|
+
const linkText = domain ? `\x1b[2m•\x1b[0m \x1b[36m${domain}\x1b[0m` : (ad.url ? `\x1b[2m•\x1b[0m \x1b[36m${truncate(ad.url, 40)}\x1b[0m` : "");
|
|
126
|
+
const linked = ad.url ? osc8(ad.url, linkText) : linkText;
|
|
127
|
+
let line = ` ${label} ${truncate(displayText, w - 35)}`;
|
|
128
|
+
if (linked)
|
|
129
|
+
line += ` ${linked}`;
|
|
36
130
|
return line;
|
|
37
131
|
}
|
|
38
|
-
export function runWithAdOverlay(binary, args = []) {
|
|
39
|
-
return runWithAdOverlayAsync(binary, args);
|
|
132
|
+
export function runWithAdOverlay(binary, args = [], surface = "codex") {
|
|
133
|
+
return runWithAdOverlayAsync(binary, args, surface);
|
|
40
134
|
}
|
|
41
|
-
async function runWithAdOverlayAsync(binary, args = []) {
|
|
42
|
-
// Try to use node-pty if available
|
|
135
|
+
async function runWithAdOverlayAsync(binary, args = [], surface) {
|
|
43
136
|
let pty;
|
|
44
137
|
try {
|
|
45
138
|
// @ts-ignore
|
|
@@ -53,12 +146,11 @@ async function runWithAdOverlayAsync(binary, args = []) {
|
|
|
53
146
|
catch { }
|
|
54
147
|
}
|
|
55
148
|
if (!pty) {
|
|
56
|
-
// No PTY available - fall back to printing banner then execing
|
|
57
149
|
const ad = readAd();
|
|
58
150
|
if (ad) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
151
|
+
const style = readStyle();
|
|
152
|
+
const width = process.stdout.columns || 120;
|
|
153
|
+
console.log(buildFooter(ad, style, width).replace(/\n/g, "\n"));
|
|
62
154
|
count(ad.id);
|
|
63
155
|
}
|
|
64
156
|
const { spawn } = await import("node:child_process");
|
|
@@ -71,6 +163,7 @@ async function runWithAdOverlayAsync(binary, args = []) {
|
|
|
71
163
|
count(ad.id);
|
|
72
164
|
const cols = process.stdout.columns || 120;
|
|
73
165
|
const rows = process.stdout.rows || 40;
|
|
166
|
+
const style = readStyle();
|
|
74
167
|
const ptyProcess = pty.spawn(binary, args, {
|
|
75
168
|
name: "xterm-256color",
|
|
76
169
|
cols,
|
|
@@ -80,32 +173,93 @@ async function runWithAdOverlayAsync(binary, args = []) {
|
|
|
80
173
|
});
|
|
81
174
|
let footerVisible = !!ad;
|
|
82
175
|
let lastAd = ad;
|
|
176
|
+
let marqueeOffset = 0;
|
|
177
|
+
let fetching = false;
|
|
178
|
+
let fetchFrame = 0;
|
|
179
|
+
let promptBytes = [];
|
|
83
180
|
function drawFooter() {
|
|
181
|
+
if (fetching) {
|
|
182
|
+
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
183
|
+
const status = ` \x1b[36m${frames[fetchFrame++ % frames.length]} validating prompt • selecting an ad\x1b[0m`;
|
|
184
|
+
const termRows = ptyProcess.rows || rows;
|
|
185
|
+
const footerRow = style === "banner" ? Math.max(1, termRows - 2) : Math.max(1, termRows - 1);
|
|
186
|
+
process.stdout.write(`\x1b[s\x1b[${footerRow};1H\x1b[2K${status}\x1b[u`);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
84
189
|
if (!footerVisible || !lastAd)
|
|
85
190
|
return;
|
|
86
|
-
const
|
|
87
|
-
// Move to bottom row, clear line, print footer
|
|
191
|
+
const width = process.stdout.columns || cols;
|
|
88
192
|
const termRows = ptyProcess.rows || rows;
|
|
89
|
-
|
|
193
|
+
const footer = buildFooter(lastAd, style, width, marqueeOffset);
|
|
194
|
+
marqueeOffset++;
|
|
195
|
+
if (style === "banner") {
|
|
196
|
+
// Draw 3 lines for boxed
|
|
197
|
+
process.stdout.write(`\x1b[s\x1b[${termRows - 2};1H\x1b[2K${footer.split('\n')[0]}\x1b[u`);
|
|
198
|
+
process.stdout.write(`\x1b[s\x1b[${termRows - 1};1H\x1b[2K${footer.split('\n')[1]}\x1b[u`);
|
|
199
|
+
process.stdout.write(`\x1b[s\x1b[${termRows};1H\x1b[2K${footer.split('\n')[2]}\x1b[u`);
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
process.stdout.write(`\x1b[s\x1b[${termRows};1H\x1b[2K${footer}\x1b[u`);
|
|
203
|
+
}
|
|
90
204
|
}
|
|
91
205
|
function hideFooter() {
|
|
92
206
|
const termRows = ptyProcess.rows || rows;
|
|
93
|
-
|
|
207
|
+
if (style === "banner") {
|
|
208
|
+
process.stdout.write(`\x1b[s\x1b[${termRows - 2};1H\x1b[2K\x1b[${termRows - 1};1H\x1b[2K\x1b[${termRows};1H\x1b[2K\x1b[u`);
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
process.stdout.write(`\x1b[s\x1b[${termRows};1H\x1b[2K\x1b[u`);
|
|
212
|
+
}
|
|
94
213
|
}
|
|
95
|
-
// Pipe PTY output to stdout with footer redrawn after each chunk
|
|
96
214
|
ptyProcess.onData((data) => {
|
|
97
215
|
process.stdout.write(data);
|
|
98
|
-
|
|
99
|
-
setTimeout(drawFooter, 10);
|
|
216
|
+
setTimeout(drawFooter, 15);
|
|
100
217
|
});
|
|
101
|
-
// Pipe stdin to PTY
|
|
102
218
|
if (process.stdin.isTTY) {
|
|
103
|
-
|
|
219
|
+
try {
|
|
220
|
+
process.stdin.setRawMode(true);
|
|
221
|
+
}
|
|
222
|
+
catch { }
|
|
104
223
|
}
|
|
105
224
|
process.stdin.on("data", (data) => {
|
|
225
|
+
for (const byte of data) {
|
|
226
|
+
if (byte === 3 || byte === 4) {
|
|
227
|
+
promptBytes = [];
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (byte === 13 || byte === 10) {
|
|
231
|
+
const hasPrompt = promptBytes.some((b) => b >= 32 && b !== 127);
|
|
232
|
+
promptBytes = [];
|
|
233
|
+
if (hasPrompt && !fetching) {
|
|
234
|
+
recordPrompt();
|
|
235
|
+
fetching = true;
|
|
236
|
+
drawFooter();
|
|
237
|
+
void refreshAd(surface).then((updated) => {
|
|
238
|
+
if (updated) {
|
|
239
|
+
const next = readAd();
|
|
240
|
+
if (next) {
|
|
241
|
+
lastAd = next;
|
|
242
|
+
footerVisible = true;
|
|
243
|
+
if (next.id)
|
|
244
|
+
count(next.id);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
fetching = false;
|
|
248
|
+
drawFooter();
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
else if (byte === 127 || byte === 8) {
|
|
253
|
+
promptBytes.pop();
|
|
254
|
+
}
|
|
255
|
+
else if (byte >= 32 && byte !== 127) {
|
|
256
|
+
promptBytes.push(byte);
|
|
257
|
+
if (promptBytes.length > 20000)
|
|
258
|
+
promptBytes = promptBytes.slice(-20000);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
106
261
|
ptyProcess.write(data);
|
|
107
262
|
});
|
|
108
|
-
// Handle resize
|
|
109
263
|
process.stdout.on("resize", () => {
|
|
110
264
|
const c = process.stdout.columns || cols;
|
|
111
265
|
const r = process.stdout.rows || rows;
|
|
@@ -115,7 +269,6 @@ async function runWithAdOverlayAsync(binary, args = []) {
|
|
|
115
269
|
catch { }
|
|
116
270
|
setTimeout(drawFooter, 50);
|
|
117
271
|
});
|
|
118
|
-
// Handle exit
|
|
119
272
|
ptyProcess.onExit(({ exitCode }) => {
|
|
120
273
|
if (process.stdin.isTTY) {
|
|
121
274
|
try {
|
|
@@ -127,7 +280,6 @@ async function runWithAdOverlayAsync(binary, args = []) {
|
|
|
127
280
|
hideFooter();
|
|
128
281
|
process.exit(exitCode ?? 0);
|
|
129
282
|
});
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
setInterval(drawFooter, 500); // Keep footer visible on redraws
|
|
283
|
+
setTimeout(drawFooter, 150);
|
|
284
|
+
setInterval(drawFooter, style === "banner" ? 800 : 1000);
|
|
133
285
|
}
|
package/dist/surfaces/wrap.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export declare function isWrapped(target: string): boolean;
|
|
|
7
7
|
/** Wrap a binary/shim with the ad-printing wrapper. Idempotent.
|
|
8
8
|
* `force` re-bakes an existing wrapper (e.g. after a template upgrade)
|
|
9
9
|
* while keeping the pristine backup untouched. */
|
|
10
|
-
export declare function installWrapper(target: string, adCachePath: string, force?: boolean): WrapResult;
|
|
10
|
+
export declare function installWrapper(target: string, adCachePath: string, force?: boolean, surface?: string): WrapResult;
|
|
11
11
|
/** Restore the pristine binary and remove wrapper + ad cache. Idempotent. */
|
|
12
12
|
export declare function uninstallWrapper(target: string, adCachePath: string): WrapResult;
|
|
13
13
|
/**
|
package/dist/surfaces/wrap.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { chmodSync, copyFileSync, existsSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { delimiter, dirname, join } from "node:path";
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
|
-
import {
|
|
11
|
+
import { hasWrapperMarker, backupPath, paths } from "../core/paths.js";
|
|
12
12
|
const SH_TEMPLATE_OVERLAY = "wrapper.sh.overlay";
|
|
13
13
|
const SH_TEMPLATE = "wrapper.sh.asset";
|
|
14
14
|
const CMD_TEMPLATE = "wrapper.cmd.asset";
|
|
@@ -21,7 +21,10 @@ function templatePath(isWin, useOverlay = true) {
|
|
|
21
21
|
}
|
|
22
22
|
export function isWrapped(target) {
|
|
23
23
|
try {
|
|
24
|
-
|
|
24
|
+
// Any termad marker counts — the overlay variant is just as much
|
|
25
|
+
// "ours" as the plain wrapper. Missing it makes uninstall discard the
|
|
26
|
+
// pristine backup and leaves the shim behind.
|
|
27
|
+
return hasWrapperMarker(readFileSync(target, "utf8"));
|
|
25
28
|
}
|
|
26
29
|
catch {
|
|
27
30
|
return false;
|
|
@@ -30,7 +33,7 @@ export function isWrapped(target) {
|
|
|
30
33
|
/** Wrap a binary/shim with the ad-printing wrapper. Idempotent.
|
|
31
34
|
* `force` re-bakes an existing wrapper (e.g. after a template upgrade)
|
|
32
35
|
* while keeping the pristine backup untouched. */
|
|
33
|
-
export function installWrapper(target, adCachePath, force = false) {
|
|
36
|
+
export function installWrapper(target, adCachePath, force = false, surface = "codex") {
|
|
34
37
|
if (!existsSync(target))
|
|
35
38
|
return { ok: false, reason: `target not found: ${target}` };
|
|
36
39
|
if (isWrapped(target) && !force)
|
|
@@ -50,7 +53,8 @@ export function installWrapper(target, adCachePath, force = false) {
|
|
|
50
53
|
const wrapper = template
|
|
51
54
|
.split("__TERMAD_AD__").join(adCachePath)
|
|
52
55
|
.split("__TERMAD_COUNT__").join(paths.displayLog())
|
|
53
|
-
.split("__TERMAD_BACKUP__").join(bak)
|
|
56
|
+
.split("__TERMAD_BACKUP__").join(bak)
|
|
57
|
+
.split("__TERMAD_SURFACE__").join(surface);
|
|
54
58
|
if (wrapper.includes("__TERMAD_"))
|
|
55
59
|
return { ok: false, reason: "unbaked template token" };
|
|
56
60
|
const mode = statSync(target).mode & 0o777;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@expitc/termad",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
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",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"files": [
|
|
32
32
|
"dist",
|
|
33
33
|
"assets",
|
|
34
|
+
"scripts/ensure-pty-helper.mjs",
|
|
34
35
|
"README.md",
|
|
35
36
|
"POLICY.md",
|
|
36
37
|
"LICENSE"
|
|
@@ -39,6 +40,7 @@
|
|
|
39
40
|
"build": "tsc && node scripts/copy-assets.mjs",
|
|
40
41
|
"dev": "tsc --watch",
|
|
41
42
|
"server": "node server/server.mjs",
|
|
43
|
+
"postinstall": "node scripts/ensure-pty-helper.mjs || exit 0",
|
|
42
44
|
"prepublishOnly": "npm run build"
|
|
43
45
|
},
|
|
44
46
|
"engines": {
|
|
@@ -47,5 +49,11 @@
|
|
|
47
49
|
"devDependencies": {
|
|
48
50
|
"@types/node": "^22.10.0",
|
|
49
51
|
"typescript": "^5.7.0"
|
|
52
|
+
},
|
|
53
|
+
"optionalDependencies": {
|
|
54
|
+
"node-pty": "^1.1.0"
|
|
55
|
+
},
|
|
56
|
+
"allowScripts": {
|
|
57
|
+
"node-pty@1.1.0": true
|
|
50
58
|
}
|
|
51
59
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* node-pty ships its prebuilt `spawn-helper` (the tiny helper that opens the
|
|
4
|
+
* pty) inside the npm tarball WITHOUT the executable bit, and npm >= 11
|
|
5
|
+
* blocks dependency install scripts by default — so the helper is never
|
|
6
|
+
* rebuilt and never made executable. The result is a confusing
|
|
7
|
+
* "posix_spawnp failed" error and a silent downgrade to banner mode.
|
|
8
|
+
*
|
|
9
|
+
* This postinstall just fixes the mode. It is a no-op if the helper is
|
|
10
|
+
* already executable, and a no-op if node-pty is not installed at all
|
|
11
|
+
* (it is an optional dependency).
|
|
12
|
+
*/
|
|
13
|
+
import { chmodSync, existsSync, statSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { createRequire } from "node:module";
|
|
16
|
+
|
|
17
|
+
const require = createRequire(import.meta.url);
|
|
18
|
+
|
|
19
|
+
function findHelper() {
|
|
20
|
+
let root;
|
|
21
|
+
try {
|
|
22
|
+
root = join(require.resolve("node-pty/package.json"), "..");
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
const candidates = [
|
|
27
|
+
join(root, "build", "Release", "spawn-helper"),
|
|
28
|
+
join(root, "prebuilds", `${process.platform}-${process.arch}`, "spawn-helper"),
|
|
29
|
+
];
|
|
30
|
+
return candidates.find((c) => existsSync(c)) ?? null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const helper = findHelper();
|
|
34
|
+
if (!helper) process.exit(0);
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const mode = statSync(helper).mode & 0o777;
|
|
38
|
+
if (mode & 0o111) process.exit(0); // already executable
|
|
39
|
+
chmodSync(helper, 0o755);
|
|
40
|
+
console.log("termad: made node-pty spawn-helper executable");
|
|
41
|
+
} catch (e) {
|
|
42
|
+
// Non-fatal — the overlay degrades to banner mode.
|
|
43
|
+
console.log(`termad: could not make node-pty spawn-helper executable: ${e.message}`);
|
|
44
|
+
}
|