@expitc/termad 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/POLICY.md +91 -0
- package/README.md +94 -2
- package/assets/wrapper.cmd.asset +31 -0
- package/assets/wrapper.sh.asset +29 -0
- package/dist/assets/wrapper.cmd.asset +31 -0
- package/dist/assets/wrapper.sh.asset +29 -0
- package/dist/audit.d.ts +9 -0
- package/dist/audit.js +54 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +193 -0
- package/dist/core/ad.d.ts +19 -0
- package/dist/core/ad.js +12 -0
- package/dist/core/config.d.ts +18 -0
- package/dist/core/config.js +33 -0
- package/dist/core/manifest.d.ts +20 -0
- package/dist/core/manifest.js +42 -0
- package/dist/core/paths.d.ts +23 -0
- package/dist/core/paths.js +29 -0
- package/dist/core/source.d.ts +18 -0
- package/dist/core/source.js +78 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/install.d.ts +26 -0
- package/dist/install.js +182 -0
- package/dist/render/render.d.ts +20 -0
- package/dist/render/render.js +47 -0
- package/dist/report.d.ts +7 -0
- package/dist/report.js +91 -0
- package/dist/surfaces/statusline.d.ts +9 -0
- package/dist/surfaces/statusline.js +115 -0
- package/dist/surfaces/surfaces.d.ts +10 -0
- package/dist/surfaces/surfaces.js +47 -0
- package/dist/surfaces/wrap.d.ts +16 -0
- package/dist/surfaces/wrap.js +108 -0
- package/package.json +49 -4
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude CLI surface: uses the PUBLIC statusLine feature in
|
|
3
|
+
* ~/.claude/settings.json. No binary patching, no wrapper.
|
|
4
|
+
*
|
|
5
|
+
* Chain-capture pattern: if the user already has a statusLine, we save it and
|
|
6
|
+
* our launcher prints the ad, then runs their original with the same stdin.
|
|
7
|
+
* Uninstall restores the captured statusLine verbatim.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { dirname } from "node:path";
|
|
11
|
+
import { paths } from "../core/paths.js";
|
|
12
|
+
export function settingsPath() {
|
|
13
|
+
const home = process.env.TERMAD_CLAUDE_HOME || `${process.env.HOME}/.claude`;
|
|
14
|
+
return `${home}/settings.json`;
|
|
15
|
+
}
|
|
16
|
+
function readSettings() {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(readFileSync(settingsPath(), "utf8"));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function writeSettings(s) {
|
|
25
|
+
mkdirSync(dirname(settingsPath()), { recursive: true });
|
|
26
|
+
writeFileSync(settingsPath(), JSON.stringify(s, null, 2) + "\n");
|
|
27
|
+
}
|
|
28
|
+
const LAUNCHER = `// termad statusline launcher (safe to delete; \`termad uninstall\` restores your config)
|
|
29
|
+
import { readFileSync } from "node:fs";
|
|
30
|
+
import { execSync } from "node:child_process";
|
|
31
|
+
|
|
32
|
+
const AD = "__AD__";
|
|
33
|
+
const PREV = "__PREV__";
|
|
34
|
+
|
|
35
|
+
let input = "";
|
|
36
|
+
try { input = readFileSync(0, "utf8"); } catch {}
|
|
37
|
+
|
|
38
|
+
let prev = "";
|
|
39
|
+
try {
|
|
40
|
+
const cfg = JSON.parse(readFileSync(PREV, "utf8"));
|
|
41
|
+
if (cfg && cfg.statusLine && cfg.statusLine.command) {
|
|
42
|
+
prev = execSync(cfg.statusLine.command, {
|
|
43
|
+
input,
|
|
44
|
+
timeout: 2000,
|
|
45
|
+
encoding: "utf8",
|
|
46
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
47
|
+
}).trimEnd();
|
|
48
|
+
}
|
|
49
|
+
} catch {}
|
|
50
|
+
|
|
51
|
+
let ad = "";
|
|
52
|
+
try { ad = readFileSync(AD, "utf8").split("\\n")[0].trim(); } catch {}
|
|
53
|
+
|
|
54
|
+
if (ad) {
|
|
55
|
+
console.log(\` [ad] \${ad}\`);
|
|
56
|
+
if (prev) console.log(prev);
|
|
57
|
+
} else {
|
|
58
|
+
console.log(prev);
|
|
59
|
+
}
|
|
60
|
+
`;
|
|
61
|
+
export function installStatusline() {
|
|
62
|
+
const s = readSettings();
|
|
63
|
+
const current = s.statusLine;
|
|
64
|
+
if (current?.command?.includes("termad")) {
|
|
65
|
+
return { ok: true, alreadyInstalled: true };
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
mkdirSync(dirname(paths.statuslineScript()), { recursive: true });
|
|
69
|
+
// Chain-capture the user's statusLine before replacing it.
|
|
70
|
+
if (current)
|
|
71
|
+
writeFileSync(paths.prevStatusline(), JSON.stringify({ statusLine: current }, null, 2));
|
|
72
|
+
else if (existsSync(paths.prevStatusline()))
|
|
73
|
+
rmSync(paths.prevStatusline());
|
|
74
|
+
const script = LAUNCHER.split("__AD__").join(paths.adCache())
|
|
75
|
+
.split("__PREV__").join(paths.prevStatusline());
|
|
76
|
+
writeFileSync(paths.statuslineScript(), script);
|
|
77
|
+
s.statusLine = { type: "command", command: `node "${paths.statuslineScript()}"` };
|
|
78
|
+
writeSettings(s);
|
|
79
|
+
return { ok: true };
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
return { ok: false, reason: String(e) };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export function uninstallStatusline() {
|
|
86
|
+
const s = readSettings();
|
|
87
|
+
const current = s.statusLine;
|
|
88
|
+
if (!current?.command?.includes("termad")) {
|
|
89
|
+
return { ok: true, alreadyInstalled: false, reason: "statusline not installed by termad" };
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
let prev = {};
|
|
93
|
+
try {
|
|
94
|
+
prev = JSON.parse(readFileSync(paths.prevStatusline(), "utf8"));
|
|
95
|
+
}
|
|
96
|
+
catch { }
|
|
97
|
+
if (prev.statusLine)
|
|
98
|
+
s.statusLine = prev.statusLine;
|
|
99
|
+
else
|
|
100
|
+
delete s.statusLine;
|
|
101
|
+
writeSettings(s);
|
|
102
|
+
if (existsSync(paths.prevStatusline()))
|
|
103
|
+
rmSync(paths.prevStatusline());
|
|
104
|
+
if (existsSync(paths.statuslineScript()))
|
|
105
|
+
rmSync(paths.statuslineScript());
|
|
106
|
+
return { ok: true, alreadyInstalled: true };
|
|
107
|
+
}
|
|
108
|
+
catch (e) {
|
|
109
|
+
return { ok: false, reason: String(e) };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
export function isStatuslineInstalled() {
|
|
113
|
+
const current = readSettings().statusLine;
|
|
114
|
+
return !!current?.command?.includes("termad");
|
|
115
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface Surface {
|
|
2
|
+
id: "claude" | "codex" | "opencode" | "gemini";
|
|
3
|
+
label: string;
|
|
4
|
+
/** How the ad gets in front of the user. */
|
|
5
|
+
mechanism: "statusline" | "wrapper";
|
|
6
|
+
/** Best-effort executable discovery. Null = not installed. */
|
|
7
|
+
locate(): string | null;
|
|
8
|
+
}
|
|
9
|
+
export declare const surfaces: Surface[];
|
|
10
|
+
export declare function getSurface(id: string): Surface | undefined;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Surface registry: each CLI knows how to locate itself and whether it needs
|
|
3
|
+
* a wrapper (binaries/shims) or config injection (claude statusline).
|
|
4
|
+
*/
|
|
5
|
+
import { locateCli } from "./wrap.js";
|
|
6
|
+
export const surfaces = [
|
|
7
|
+
{
|
|
8
|
+
id: "claude",
|
|
9
|
+
label: "Claude CLI",
|
|
10
|
+
mechanism: "statusline",
|
|
11
|
+
// claude is wrapped via ~/.claude/settings.json statusLine — no binary
|
|
12
|
+
// patching. locate() is just for the doctor/status report.
|
|
13
|
+
locate: () => locateCli("TERMAD_CLAUDE_BIN", ["claude"], [".local/bin/claude"]),
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
id: "codex",
|
|
17
|
+
label: "Codex CLI",
|
|
18
|
+
mechanism: "wrapper",
|
|
19
|
+
locate: () => locateCli("TERMAD_CODEX_BIN", ["codex", "codex.cmd"], [
|
|
20
|
+
".codex/bin/codex",
|
|
21
|
+
".npm-global/bin/codex",
|
|
22
|
+
".bun/bin/codex",
|
|
23
|
+
".volta/bin/codex",
|
|
24
|
+
".asdf/shims/codex",
|
|
25
|
+
]),
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: "opencode",
|
|
29
|
+
label: "OpenCode CLI",
|
|
30
|
+
mechanism: "wrapper",
|
|
31
|
+
locate: () => locateCli("TERMAD_OPENCODE_BIN", ["opencode", "opencode.cmd"], [
|
|
32
|
+
".opencode/bin/opencode",
|
|
33
|
+
".local/bin/opencode",
|
|
34
|
+
".npm-global/bin/opencode",
|
|
35
|
+
".bun/bin/opencode",
|
|
36
|
+
]),
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: "gemini",
|
|
40
|
+
label: "Gemini CLI",
|
|
41
|
+
mechanism: "wrapper",
|
|
42
|
+
locate: () => locateCli("TERMAD_GEMINI_BIN", ["gemini", "gemini.cmd"], [".local/bin/gemini", ".npm-global/bin/gemini", ".bun/bin/gemini", ".volta/bin/gemini"]),
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
export function getSurface(id) {
|
|
46
|
+
return surfaces.find((s) => s.id === id);
|
|
47
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface WrapResult {
|
|
2
|
+
ok: boolean;
|
|
3
|
+
alreadyInstalled?: boolean;
|
|
4
|
+
reason?: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function isWrapped(target: string): boolean;
|
|
7
|
+
/** Wrap a binary/shim with the ad-printing wrapper. Idempotent.
|
|
8
|
+
* `force` re-bakes an existing wrapper (e.g. after a template upgrade)
|
|
9
|
+
* while keeping the pristine backup untouched. */
|
|
10
|
+
export declare function installWrapper(target: string, adCachePath: string, force?: boolean): WrapResult;
|
|
11
|
+
/** Restore the pristine binary and remove wrapper + ad cache. Idempotent. */
|
|
12
|
+
export declare function uninstallWrapper(target: string, adCachePath: string): WrapResult;
|
|
13
|
+
/**
|
|
14
|
+
* Locate a CLI executable: explicit env override → known install dirs → PATH.
|
|
15
|
+
*/
|
|
16
|
+
export declare function locateCli(envVar: string, names: string[], relPaths: string[]): string | null;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared wrapper mechanics for the CLI surfaces that need a shim in front of
|
|
3
|
+
* a binary (codex, opencode, gemini): move the original aside to
|
|
4
|
+
* `<bin>.termad-orig`, write a marker-tagged wrapper script that prints the
|
|
5
|
+
* ad cache then execs the backup. Fully reversible, idempotent, and the
|
|
6
|
+
* marker makes it self-detecting after CLI self-updates.
|
|
7
|
+
*/
|
|
8
|
+
import { chmodSync, copyFileSync, existsSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { delimiter, dirname, join } from "node:path";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { WRAPPER_MARKER, backupPath, paths } from "../core/paths.js";
|
|
12
|
+
const SH_TEMPLATE = "wrapper.sh.asset";
|
|
13
|
+
const CMD_TEMPLATE = "wrapper.cmd.asset";
|
|
14
|
+
function templatePath(isWin) {
|
|
15
|
+
const here = dirname(new URL(import.meta.url).pathname);
|
|
16
|
+
// dist/surfaces → ../assets (copy-assets.mjs mirrors assets/ into dist/)
|
|
17
|
+
return join(here, "..", "assets", isWin ? CMD_TEMPLATE : SH_TEMPLATE);
|
|
18
|
+
}
|
|
19
|
+
export function isWrapped(target) {
|
|
20
|
+
try {
|
|
21
|
+
return readFileSync(target, "utf8").includes(WRAPPER_MARKER);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Wrap a binary/shim with the ad-printing wrapper. Idempotent.
|
|
28
|
+
* `force` re-bakes an existing wrapper (e.g. after a template upgrade)
|
|
29
|
+
* while keeping the pristine backup untouched. */
|
|
30
|
+
export function installWrapper(target, adCachePath, force = false) {
|
|
31
|
+
if (!existsSync(target))
|
|
32
|
+
return { ok: false, reason: `target not found: ${target}` };
|
|
33
|
+
if (isWrapped(target) && !force)
|
|
34
|
+
return { ok: true, alreadyInstalled: true };
|
|
35
|
+
const isWin = /\.(cmd|bat)$/i.test(target);
|
|
36
|
+
const bak = backupPath(target);
|
|
37
|
+
try {
|
|
38
|
+
// First install: stash the pristine original. Re-wraps (force) and
|
|
39
|
+
// subsequent installs keep the original backup — never overwrite it
|
|
40
|
+
// with a wrapper.
|
|
41
|
+
if (!existsSync(bak)) {
|
|
42
|
+
if (isWrapped(target))
|
|
43
|
+
return { ok: false, reason: "wrapped target without backup — run `termad audit`" };
|
|
44
|
+
copyFileSync(target, bak);
|
|
45
|
+
}
|
|
46
|
+
const template = readFileSync(templatePath(isWin), "utf8");
|
|
47
|
+
const wrapper = template
|
|
48
|
+
.split("__TERMAD_AD__").join(adCachePath)
|
|
49
|
+
.split("__TERMAD_COUNT__").join(paths.displayLog())
|
|
50
|
+
.split("__TERMAD_BACKUP__").join(bak);
|
|
51
|
+
if (wrapper.includes("__TERMAD_"))
|
|
52
|
+
return { ok: false, reason: "unbaked template token" };
|
|
53
|
+
const mode = statSync(target).mode & 0o777;
|
|
54
|
+
writeFileSync(target, wrapper);
|
|
55
|
+
if (!isWin)
|
|
56
|
+
chmodSync(target, 0o755);
|
|
57
|
+
else
|
|
58
|
+
chmodSync(target, mode);
|
|
59
|
+
return { ok: true };
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
return { ok: false, reason: String(e) };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Restore the pristine binary and remove wrapper + ad cache. Idempotent. */
|
|
66
|
+
export function uninstallWrapper(target, adCachePath) {
|
|
67
|
+
const bak = backupPath(target);
|
|
68
|
+
if (!existsSync(bak))
|
|
69
|
+
return { ok: true, alreadyInstalled: false, reason: "no backup (not installed)" };
|
|
70
|
+
try {
|
|
71
|
+
// Only restore over our own wrapper — never clobber a self-updated CLI.
|
|
72
|
+
if (existsSync(target) && !isWrapped(target)) {
|
|
73
|
+
rmSync(bak);
|
|
74
|
+
return { ok: true, reason: "target changed outside termad; backup discarded, left target alone" };
|
|
75
|
+
}
|
|
76
|
+
const mode = statSync(bak).mode & 0o777;
|
|
77
|
+
copyFileSync(bak, target);
|
|
78
|
+
chmodSync(target, mode);
|
|
79
|
+
rmSync(bak);
|
|
80
|
+
if (existsSync(adCachePath))
|
|
81
|
+
rmSync(adCachePath);
|
|
82
|
+
return { ok: true, alreadyInstalled: true };
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
return { ok: false, reason: String(e) };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Locate a CLI executable: explicit env override → known install dirs → PATH.
|
|
90
|
+
*/
|
|
91
|
+
export function locateCli(envVar, names, relPaths) {
|
|
92
|
+
const explicit = process.env[envVar];
|
|
93
|
+
if (explicit && existsSync(explicit))
|
|
94
|
+
return explicit;
|
|
95
|
+
const home = homedir();
|
|
96
|
+
const candidates = relPaths.map((p) => join(home, p));
|
|
97
|
+
for (const c of candidates)
|
|
98
|
+
if (existsSync(c))
|
|
99
|
+
return c;
|
|
100
|
+
for (const dir of (process.env.PATH ?? "").split(delimiter).filter(Boolean)) {
|
|
101
|
+
for (const name of names) {
|
|
102
|
+
const p = join(dir, name);
|
|
103
|
+
if (existsSync(p))
|
|
104
|
+
return p;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@expitc/termad",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "EXPITC",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/EXPITC/termad.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/EXPITC/termad/issues"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/EXPITC/termad#readme",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"cli",
|
|
18
|
+
"ads",
|
|
19
|
+
"terminal",
|
|
20
|
+
"claude",
|
|
21
|
+
"codex",
|
|
22
|
+
"opencode",
|
|
23
|
+
"gemini",
|
|
24
|
+
"statusline"
|
|
25
|
+
],
|
|
26
|
+
"bin": {
|
|
27
|
+
"termad": "dist/cli.js"
|
|
28
|
+
},
|
|
29
|
+
"main": "dist/index.js",
|
|
30
|
+
"types": "dist/index.d.ts",
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"assets",
|
|
34
|
+
"README.md",
|
|
35
|
+
"POLICY.md",
|
|
36
|
+
"LICENSE"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsc && node scripts/copy-assets.mjs",
|
|
40
|
+
"dev": "tsc --watch",
|
|
41
|
+
"server": "node server/server.mjs",
|
|
42
|
+
"prepublishOnly": "npm run build"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=18"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/node": "^22.10.0",
|
|
49
|
+
"typescript": "^5.7.0"
|
|
50
|
+
}
|
|
51
|
+
}
|