@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 EXPITC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/POLICY.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# termad — Content & Platform Policy
|
|
2
|
+
|
|
3
|
+
This policy governs (a) ads served through a termad endpoint, and (b) the
|
|
4
|
+
termad platform (advertisers, runners, revenue share). It exists so every
|
|
5
|
+
party — advertiser, runner, and the person who sees the ad — knows exactly
|
|
6
|
+
what is and isn't allowed. terms of use for the platform should reference
|
|
7
|
+
this file.
|
|
8
|
+
|
|
9
|
+
## 1. What an ad is
|
|
10
|
+
|
|
11
|
+
An ad in termad is **one line of plain text plus an optional URL**, printed
|
|
12
|
+
above the CLI's own output with a fixed `[ad]` label. It is never mixed into
|
|
13
|
+
the CLI's output, never styled to look like the CLI's own messages, and is
|
|
14
|
+
always removable with `termad uninstall`.
|
|
15
|
+
|
|
16
|
+
## 2. Allowed ad content
|
|
17
|
+
|
|
18
|
+
- Honest, legal products and services aimed at developers.
|
|
19
|
+
- Clear claims. No "your machine is infected" urgency, no fake system
|
|
20
|
+
warnings, no impersonation of terminals, OS dialogs, or AI assistants.
|
|
21
|
+
- URLs must resolve to the advertised destination (no cloaking, no
|
|
22
|
+
redirect chains to unrelated domains).
|
|
23
|
+
|
|
24
|
+
## 3. Prohibited ad content
|
|
25
|
+
|
|
26
|
+
- Malware, phishing, credential harvesting, or any link whose landing page
|
|
27
|
+
would fail a safe-browsing check.
|
|
28
|
+
- Illegal goods/services; adult content; violence; deceptive financial
|
|
29
|
+
offers ("guaranteed returns"); counterfeit goods.
|
|
30
|
+
- Content that mimics terminal output, error messages, or the branding of
|
|
31
|
+
claude/codex/opencode/gemini or their companies.
|
|
32
|
+
- Ads claiming affiliation with, or endorsement by, the CLIs termad runs in.
|
|
33
|
+
- Self-promotion loops: an ad for an endpoint is fine; an ad that instructs
|
|
34
|
+
viewers to click it, or to run commands that generate ad impressions, is
|
|
35
|
+
not (see §5).
|
|
36
|
+
|
|
37
|
+
Enforcement: the operator of an endpoint is responsible for its inventory.
|
|
38
|
+
Anyone may propose additions/exceptions via PR to this file; removals for
|
|
39
|
+
policy violations are immediate.
|
|
40
|
+
|
|
41
|
+
## 4. Measurement honesty
|
|
42
|
+
|
|
43
|
+
- An **impression** means: the ad was rendered into a terminal the viewer
|
|
44
|
+
was already using. Counting starts when the wrapper prints the line.
|
|
45
|
+
- termad clients never click their own ads and never simulate engagement.
|
|
46
|
+
No auto-follow of ad URLs, no beacon on render, no fingerprinting, no
|
|
47
|
+
cross-site identifiers. The viewer's `uid` is a random local identifier
|
|
48
|
+
used only for per-install ad rotation and revenue accounting.
|
|
49
|
+
- Reported numbers in the dashboard are raw counts (per ad, per surface)
|
|
50
|
+
— not modeled, not extrapolated. Gaps (e.g. offline periods) are gaps.
|
|
51
|
+
|
|
52
|
+
## 5. Revenue share (the 50%)
|
|
53
|
+
|
|
54
|
+
- Advertisers prepay credit (via the platform's payment provider).
|
|
55
|
+
- For each reported impression: **50% of the ad's CPM value is credited to
|
|
56
|
+
the runner** (the person/organization whose terminals display the ad),
|
|
57
|
+
50% stays with the platform to cover payment processing and operations.
|
|
58
|
+
- Runners never need to (and never should) click ads, ask others to click,
|
|
59
|
+
or inflate counts mechanically — counts come from actual renders only.
|
|
60
|
+
- Payouts are made against the runner's recorded balance; the ledger in
|
|
61
|
+
the dashboard is the source of truth and shows every credited impression.
|
|
62
|
+
|
|
63
|
+
## 6. Trademarks & non-affiliation
|
|
64
|
+
|
|
65
|
+
`claude`, `codex`, `opencode`, and `gemini` are trademarks of their
|
|
66
|
+
respective owners. termad uses these names only to identify compatible
|
|
67
|
+
software. **termad is not affiliated with, sponsored by, or endorsed by
|
|
68
|
+
Anthropic, OpenAI, Google, or the opencode project.** Ad content may not
|
|
69
|
+
claim such affiliation either.
|
|
70
|
+
|
|
71
|
+
## 7. Privacy
|
|
72
|
+
|
|
73
|
+
- Collected: random install `uid`, impression timestamps, ad id, surface.
|
|
74
|
+
- Not collected: prompts, conversations, file paths, machine identifiers,
|
|
75
|
+
IP-based profiles. Impressions are counted by the client and reported in
|
|
76
|
+
batches; no per-render network activity occurs.
|
|
77
|
+
- Deletion: `termad uninstall` removes local state; operators may purge
|
|
78
|
+
`uid` records from their ledger at any time on request.
|
|
79
|
+
|
|
80
|
+
## 8. Reversibility commitment
|
|
81
|
+
|
|
82
|
+
Every termad change is (a) shown to the user before consent,
|
|
83
|
+
(b) sha256-recorded in a local manifest, and (c) fully reverted by
|
|
84
|
+
`termad uninstall`. `termad audit` re-verifies (b) and refuses to restore
|
|
85
|
+
from a backup that fails verification.
|
|
86
|
+
|
|
87
|
+
## 9. Contact / takedown
|
|
88
|
+
|
|
89
|
+
Report policy-violating ads to the operator of the endpoint that served
|
|
90
|
+
them (the endpoint is always identifiable by the serving domain). For
|
|
91
|
+
platform-level issues, open an issue in this repository.
|
package/README.md
CHANGED
|
@@ -1,3 +1,95 @@
|
|
|
1
|
-
#
|
|
1
|
+
# termad
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Opt-in, fully reversible text ads for terminal AI CLIs — **claude**, **codex**,
|
|
4
|
+
**opencode**, **gemini**. Your words, your endpoint, one line in the terminal.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
$ opencode --version
|
|
8
|
+
|
|
9
|
+
[ad] termad — put your words in the terminal
|
|
10
|
+
https://github.com/yourname/termad
|
|
11
|
+
|
|
12
|
+
opencode v2.1.1
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## How it works
|
|
16
|
+
|
|
17
|
+
| Surface | Mechanism | Risk |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| **claude** | Public `statusLine` in `~/.claude/settings.json` (chained — your existing statusline is captured and still runs) | none — documented feature |
|
|
20
|
+
| **codex / opencode / gemini** | Marker-tagged wrapper script in front of the binary; original backed up as `<bin>.termad-orig` | fully reversible |
|
|
21
|
+
|
|
22
|
+
- Ads are fetched from **your** HTTP endpoint once, cached to
|
|
23
|
+
`~/.termad/ad.txt`, and printed by the wrapper — zero network in the CLI's
|
|
24
|
+
hot path, and CLIs keep working if the endpoint is down.
|
|
25
|
+
- Click = a bare URL the terminal auto-links. No tracking, no beacons.
|
|
26
|
+
- `termad uninstall` restores every binary and your settings verbatim.
|
|
27
|
+
If a CLI self-updates over the wrapper, termad detects the marker is gone
|
|
28
|
+
and leaves the new binary alone.
|
|
29
|
+
|
|
30
|
+
## Quick start
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
npm install && npm run build
|
|
34
|
+
|
|
35
|
+
# 1. start your ad server (zero deps)
|
|
36
|
+
node server/server.mjs 8787
|
|
37
|
+
|
|
38
|
+
# 2. point termad at it
|
|
39
|
+
node dist/cli.js set-endpoint http://localhost:8787
|
|
40
|
+
|
|
41
|
+
# 3. install (choose surfaces, or omit for all opted-in)
|
|
42
|
+
node dist/cli.js install claude codex opencode
|
|
43
|
+
|
|
44
|
+
# 4. anytime: full revert
|
|
45
|
+
node dist/cli.js uninstall
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Other commands: `status`, `sync`, `watch` (poll endpoint), `style line|banner`.
|
|
49
|
+
|
|
50
|
+
## Trust & compliance
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
node dist/cli.js install # shows the exact plan, asks for consent
|
|
54
|
+
# (non-interactive: --yes required)
|
|
55
|
+
node dist/cli.js audit # sha256-verifies every recorded change
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
- **Consent first** — nothing is modified until the plan (files, backups)
|
|
59
|
+
is shown and confirmed.
|
|
60
|
+
- **Tamper-evident** — every change is recorded in `~/.termad/manifest.json`
|
|
61
|
+
with sha256 of original and installed content. `audit` flags tampering,
|
|
62
|
+
and `uninstall` *refuses* to restore from a backup that fails its check.
|
|
63
|
+
- **No tracking** — no beacons, no click counting, no fingerprints. One
|
|
64
|
+
impression = one render, counted locally, reported in batches.
|
|
65
|
+
- Content rules: [POLICY.md](POLICY.md).
|
|
66
|
+
- **Non-affiliation**: claude, codex, opencode, and gemini are trademarks of
|
|
67
|
+
their respective owners (Anthropic, OpenAI, Google, opencode project).
|
|
68
|
+
termad is not affiliated with, sponsored by, or endorsed by any of them.
|
|
69
|
+
|
|
70
|
+
## Ad inventory
|
|
71
|
+
|
|
72
|
+
Edit `server/ads.json` — plain JSON, hot-reloads every 30s:
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{ "ads": [
|
|
76
|
+
{ "id": "sponsor:acme", "text": "Acme — ship faster", "url": "https://acme.dev" }
|
|
77
|
+
]}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Round-robin rotation per install `uid`; impressions counted in memory
|
|
81
|
+
(`GET /v1/stats`). Swap in a DB when it's real.
|
|
82
|
+
|
|
83
|
+
## Programmatic use
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import { install, uninstall, renderBanner, fetchAd } from "termad";
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Design notes
|
|
90
|
+
|
|
91
|
+
- **Never breaks the CLI**: every ad operation is wrapped; worst case is a
|
|
92
|
+
missing banner.
|
|
93
|
+
- **Opt-in per surface**, stored in `~/.termad/config.json`.
|
|
94
|
+
- `TERMAD_HOME`, `TERMAD_<CLI>_BIN`, `TERMAD_CLAUDE_HOME` override all paths
|
|
95
|
+
(used for testing; nothing touches your real setup without `install`).
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
@ECHO off
|
|
2
|
+
REM ===== TERMAD-CLI =====
|
|
3
|
+
REM Reversible termad wrapper. Prints a one-line ad banner read from
|
|
4
|
+
REM "__TERMAD_AD__" (line 1 = wording, line 2 = click URL, line 4 = ad id)
|
|
5
|
+
REM then delegates to the pristine binary at "__TERMAD_BACKUP__".
|
|
6
|
+
REM Each render is appended to "__TERMAD_COUNT__" (one line of ad id — the
|
|
7
|
+
REM batch counter `termad sync` reports; no network happens here).
|
|
8
|
+
REM Restore: copy the backup over this file, or run `termad uninstall`.
|
|
9
|
+
setlocal enabledelayedexpansion
|
|
10
|
+
set "__AD_FILE=__TERMAD_AD__"
|
|
11
|
+
set "__COUNT_FILE=__TERMAD_COUNT__"
|
|
12
|
+
set "__AD="
|
|
13
|
+
set "__URL="
|
|
14
|
+
set "__ID="
|
|
15
|
+
set "__N=0"
|
|
16
|
+
if exist "%__AD_FILE%" (
|
|
17
|
+
for /f "usebackq delims=" %%A in ("%__AD_FILE%") do (
|
|
18
|
+
if !__N! EQU 0 set "__AD=%%A"
|
|
19
|
+
if !__N! EQU 1 set "__URL=%%A"
|
|
20
|
+
if !__N! EQU 3 set "__ID=%%A"
|
|
21
|
+
set /a __N+=1
|
|
22
|
+
)
|
|
23
|
+
)
|
|
24
|
+
if defined __AD (
|
|
25
|
+
echo.
|
|
26
|
+
echo [ad] !__AD!
|
|
27
|
+
if defined __URL if not "!__URL!"=="-" echo !__URL!
|
|
28
|
+
echo.
|
|
29
|
+
if defined __ID echo !__ID!>>"%__COUNT_FILE%"
|
|
30
|
+
)
|
|
31
|
+
endlocal & call "__TERMAD_BACKUP__" %*
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# ===== TERMAD-CLI =====
|
|
3
|
+
# Reversible termad wrapper. Prints a one-line ad banner read from
|
|
4
|
+
# "__TERMAD_AD__" (line 1 = wording, line 2 = click URL, line 4 = ad id)
|
|
5
|
+
# then exec's the pristine binary at "__TERMAD_BACKUP__".
|
|
6
|
+
# Each render is appended to "__TERMAD_COUNT__" (one line of ad id — the
|
|
7
|
+
# batch counter `termad sync` reports; no network happens here).
|
|
8
|
+
# Restore: copy the backup over this file, or run `termad uninstall`.
|
|
9
|
+
AD_FILE="__TERMAD_AD__"
|
|
10
|
+
COUNT_FILE="__TERMAD_COUNT__"
|
|
11
|
+
AD_TEXT=""
|
|
12
|
+
AD_URL=""
|
|
13
|
+
AD_ID=""
|
|
14
|
+
if [ -r "$AD_FILE" ]; then
|
|
15
|
+
AD_TEXT=$(head -n 1 "$AD_FILE" 2>/dev/null)
|
|
16
|
+
AD_URL=$(sed -n 2p "$AD_FILE" 2>/dev/null)
|
|
17
|
+
AD_ID=$(sed -n 4p "$AD_FILE" 2>/dev/null)
|
|
18
|
+
fi
|
|
19
|
+
if [ -n "$AD_TEXT" ]; then
|
|
20
|
+
printf '\n [ad] %s\n' "$AD_TEXT"
|
|
21
|
+
case "$AD_URL" in
|
|
22
|
+
http://*|https://*) printf ' %s\n\n' "$AD_URL" ;;
|
|
23
|
+
*) printf '\n' ;;
|
|
24
|
+
esac
|
|
25
|
+
if [ -n "$AD_ID" ]; then
|
|
26
|
+
printf '%s\n' "$AD_ID" >> "$COUNT_FILE" 2>/dev/null || true
|
|
27
|
+
fi
|
|
28
|
+
fi
|
|
29
|
+
exec "__TERMAD_BACKUP__" "$@"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
@ECHO off
|
|
2
|
+
REM ===== TERMAD-CLI =====
|
|
3
|
+
REM Reversible termad wrapper. Prints a one-line ad banner read from
|
|
4
|
+
REM "__TERMAD_AD__" (line 1 = wording, line 2 = click URL, line 4 = ad id)
|
|
5
|
+
REM then delegates to the pristine binary at "__TERMAD_BACKUP__".
|
|
6
|
+
REM Each render is appended to "__TERMAD_COUNT__" (one line of ad id — the
|
|
7
|
+
REM batch counter `termad sync` reports; no network happens here).
|
|
8
|
+
REM Restore: copy the backup over this file, or run `termad uninstall`.
|
|
9
|
+
setlocal enabledelayedexpansion
|
|
10
|
+
set "__AD_FILE=__TERMAD_AD__"
|
|
11
|
+
set "__COUNT_FILE=__TERMAD_COUNT__"
|
|
12
|
+
set "__AD="
|
|
13
|
+
set "__URL="
|
|
14
|
+
set "__ID="
|
|
15
|
+
set "__N=0"
|
|
16
|
+
if exist "%__AD_FILE%" (
|
|
17
|
+
for /f "usebackq delims=" %%A in ("%__AD_FILE%") do (
|
|
18
|
+
if !__N! EQU 0 set "__AD=%%A"
|
|
19
|
+
if !__N! EQU 1 set "__URL=%%A"
|
|
20
|
+
if !__N! EQU 3 set "__ID=%%A"
|
|
21
|
+
set /a __N+=1
|
|
22
|
+
)
|
|
23
|
+
)
|
|
24
|
+
if defined __AD (
|
|
25
|
+
echo.
|
|
26
|
+
echo [ad] !__AD!
|
|
27
|
+
if defined __URL if not "!__URL!"=="-" echo !__URL!
|
|
28
|
+
echo.
|
|
29
|
+
if defined __ID echo !__ID!>>"%__COUNT_FILE%"
|
|
30
|
+
)
|
|
31
|
+
endlocal & call "__TERMAD_BACKUP__" %*
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# ===== TERMAD-CLI =====
|
|
3
|
+
# Reversible termad wrapper. Prints a one-line ad banner read from
|
|
4
|
+
# "__TERMAD_AD__" (line 1 = wording, line 2 = click URL, line 4 = ad id)
|
|
5
|
+
# then exec's the pristine binary at "__TERMAD_BACKUP__".
|
|
6
|
+
# Each render is appended to "__TERMAD_COUNT__" (one line of ad id — the
|
|
7
|
+
# batch counter `termad sync` reports; no network happens here).
|
|
8
|
+
# Restore: copy the backup over this file, or run `termad uninstall`.
|
|
9
|
+
AD_FILE="__TERMAD_AD__"
|
|
10
|
+
COUNT_FILE="__TERMAD_COUNT__"
|
|
11
|
+
AD_TEXT=""
|
|
12
|
+
AD_URL=""
|
|
13
|
+
AD_ID=""
|
|
14
|
+
if [ -r "$AD_FILE" ]; then
|
|
15
|
+
AD_TEXT=$(head -n 1 "$AD_FILE" 2>/dev/null)
|
|
16
|
+
AD_URL=$(sed -n 2p "$AD_FILE" 2>/dev/null)
|
|
17
|
+
AD_ID=$(sed -n 4p "$AD_FILE" 2>/dev/null)
|
|
18
|
+
fi
|
|
19
|
+
if [ -n "$AD_TEXT" ]; then
|
|
20
|
+
printf '\n [ad] %s\n' "$AD_TEXT"
|
|
21
|
+
case "$AD_URL" in
|
|
22
|
+
http://*|https://*) printf ' %s\n\n' "$AD_URL" ;;
|
|
23
|
+
*) printf '\n' ;;
|
|
24
|
+
esac
|
|
25
|
+
if [ -n "$AD_ID" ]; then
|
|
26
|
+
printf '%s\n' "$AD_ID" >> "$COUNT_FILE" 2>/dev/null || true
|
|
27
|
+
fi
|
|
28
|
+
fi
|
|
29
|
+
exec "__TERMAD_BACKUP__" "$@"
|
package/dist/audit.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface AuditRow {
|
|
2
|
+
surface: string;
|
|
3
|
+
target: string;
|
|
4
|
+
status: "ok" | "restored" | "updated" | "tampered" | "missing" | "unknown";
|
|
5
|
+
detail: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function audit(): AuditRow[];
|
|
8
|
+
/** Exit-code helper: only true integrity problems fail an audit. */
|
|
9
|
+
export declare function auditFailed(rows: AuditRow[]): boolean;
|
package/dist/audit.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `termad audit` — re-verify every recorded change against the filesystem.
|
|
3
|
+
*
|
|
4
|
+
* Statuses:
|
|
5
|
+
* ok wrapper/statusline installed, backup matches the recorded original
|
|
6
|
+
* restored target matches the recorded original (uninstalled or reinstalled by the CLI)
|
|
7
|
+
* updated marker gone AND content changed — the CLI self-updated; termad left it alone
|
|
8
|
+
* tampered termad's own wrapper was altered, or the backup no longer matches
|
|
9
|
+
* the recorded original — NOT something termad did silently
|
|
10
|
+
* missing the target file no longer exists
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
13
|
+
import { loadManifest, sha256File } from "./core/manifest.js";
|
|
14
|
+
import { isWrapped } from "./surfaces/wrap.js";
|
|
15
|
+
export function audit() {
|
|
16
|
+
const m = loadManifest();
|
|
17
|
+
return m.entries.map((e) => {
|
|
18
|
+
const base = { surface: e.surface, target: e.target };
|
|
19
|
+
if (!existsSync(e.target))
|
|
20
|
+
return { ...base, status: "missing", detail: "target file no longer exists" };
|
|
21
|
+
if (e.mechanism === "statusline") {
|
|
22
|
+
let installed = false;
|
|
23
|
+
try {
|
|
24
|
+
installed = readFileSync(e.target, "utf8").includes("termad");
|
|
25
|
+
}
|
|
26
|
+
catch { }
|
|
27
|
+
if (installed)
|
|
28
|
+
return { ...base, status: "ok", detail: "statusline installed, original captured in backup" };
|
|
29
|
+
return { ...base, status: "restored", detail: "settings no longer reference termad" };
|
|
30
|
+
}
|
|
31
|
+
const cur = sha256File(e.target);
|
|
32
|
+
const wrapped = isWrapped(e.target);
|
|
33
|
+
if (wrapped) {
|
|
34
|
+
if (cur !== e.sha256Installed)
|
|
35
|
+
return { ...base, status: "tampered", detail: "wrapper content differs from what termad installed" };
|
|
36
|
+
if (!e.backup || sha256File(e.backup) === null)
|
|
37
|
+
return { ...base, status: "tampered", detail: "wrapper present but original backup is missing" };
|
|
38
|
+
if (sha256File(e.backup) !== e.sha256Original)
|
|
39
|
+
return { ...base, status: "tampered", detail: "backup no longer matches the recorded original" };
|
|
40
|
+
return { ...base, status: "ok", detail: "wrapper installed, original backup verified (sha256)" };
|
|
41
|
+
}
|
|
42
|
+
if (cur === e.sha256Original)
|
|
43
|
+
return { ...base, status: "restored", detail: "target byte-identical to recorded original" };
|
|
44
|
+
return {
|
|
45
|
+
...base,
|
|
46
|
+
status: "updated",
|
|
47
|
+
detail: "marker absent and content changed — CLI replaced the wrapper; termad left it untouched",
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/** Exit-code helper: only true integrity problems fail an audit. */
|
|
52
|
+
export function auditFailed(rows) {
|
|
53
|
+
return rows.some((r) => r.status === "tampered" || r.status === "missing");
|
|
54
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* termad — opt-in, reversible text ads for terminal AI CLIs.
|
|
4
|
+
*
|
|
5
|
+
* termad install [claude|codex|opencode|gemini ...] install (default: all opted-in)
|
|
6
|
+
* termad uninstall [surface ...] full revert
|
|
7
|
+
* termad audit re-verify every recorded change
|
|
8
|
+
* termad status what's installed where
|
|
9
|
+
* termad sync fetch ad now, write cache
|
|
10
|
+
* termad watch poll endpoint, refresh cache
|
|
11
|
+
* termad set-endpoint <url> point at your ad server
|
|
12
|
+
* termad style line|banner render style
|
|
13
|
+
*/
|
|
14
|
+
import { ALL_SURFACES, loadConfig, saveConfig } from "./core/config.js";
|
|
15
|
+
import { readSync } from "node:fs";
|
|
16
|
+
import { paths, termadHome } from "./core/paths.js";
|
|
17
|
+
import { readAdCache, fetchAd } from "./core/source.js";
|
|
18
|
+
import { install, uninstall, sync, planInstall } from "./install.js";
|
|
19
|
+
import { surfaces } from "./surfaces/surfaces.js";
|
|
20
|
+
import { isWrapped } from "./surfaces/wrap.js";
|
|
21
|
+
import { isStatuslineInstalled } from "./surfaces/statusline.js";
|
|
22
|
+
import { render } from "./render/render.js";
|
|
23
|
+
import { audit, auditFailed } from "./audit.js";
|
|
24
|
+
const [cmd, ...args] = process.argv.slice(2);
|
|
25
|
+
import { reportDisplays } from "./report.js";
|
|
26
|
+
function confirmPlan(surfaces, force = false) {
|
|
27
|
+
if (args.includes("--yes"))
|
|
28
|
+
return;
|
|
29
|
+
const plan = planInstall(surfaces, force);
|
|
30
|
+
console.log("termad will make these changes (nothing happens without your consent):\n");
|
|
31
|
+
for (const p of plan) {
|
|
32
|
+
console.log(` ${p.surface.padEnd(9)} ${p.action}`);
|
|
33
|
+
console.log(` ${"".padEnd(9)} → ${p.target}`);
|
|
34
|
+
}
|
|
35
|
+
console.log(`
|
|
36
|
+
• every change is sha256-recorded in ${paths.manifest()}
|
|
37
|
+
• \`termad uninstall\` restores everything byte-for-byte
|
|
38
|
+
• \`termad audit\` re-verifies all of it at any time`);
|
|
39
|
+
if (!process.stdin.isTTY) {
|
|
40
|
+
console.error("\nnon-interactive session: re-run with --yes to confirm");
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
process.stdout.write("\nProceed? [y/N] ");
|
|
44
|
+
const answer = readlineSync();
|
|
45
|
+
if (!/^y(es)?$/i.test(answer.trim())) {
|
|
46
|
+
console.log("aborted — nothing changed");
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function readlineSync() {
|
|
51
|
+
// Minimal blocking read for the consent prompt (no deps).
|
|
52
|
+
try {
|
|
53
|
+
const buf = Buffer.alloc(1024);
|
|
54
|
+
const n = readSync(0, buf, 0, 1024, null);
|
|
55
|
+
return buf.subarray(0, n).toString("utf8");
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return "";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function parseSurfaces(argv) {
|
|
62
|
+
const picked = argv.filter((a) => ALL_SURFACES.includes(a));
|
|
63
|
+
return picked.length ? picked : undefined;
|
|
64
|
+
}
|
|
65
|
+
function printOutcomes(out) {
|
|
66
|
+
for (const o of out)
|
|
67
|
+
console.log(` ${o.ok ? "✓" : "✗"} ${o.surface.padEnd(9)} ${o.detail}`);
|
|
68
|
+
if (out.some((o) => !o.ok))
|
|
69
|
+
process.exitCode = 1;
|
|
70
|
+
}
|
|
71
|
+
switch (cmd) {
|
|
72
|
+
case "install": {
|
|
73
|
+
const force = args.includes("--force");
|
|
74
|
+
confirmPlan(parseSurfaces(args), force);
|
|
75
|
+
const out = await install(parseSurfaces(args), { force });
|
|
76
|
+
printOutcomes(out);
|
|
77
|
+
const ad = readAdCache();
|
|
78
|
+
if (ad)
|
|
79
|
+
console.log(`\n ad cache: ${paths.adCache()}\n ${render(ad, loadConfig().style)}`);
|
|
80
|
+
const rows = audit();
|
|
81
|
+
if (rows.length)
|
|
82
|
+
console.log(` audit: ${rows.length} change(s) recorded → \`termad audit\` to re-verify`);
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
case "uninstall": {
|
|
86
|
+
const out = uninstall(parseSurfaces(args));
|
|
87
|
+
printOutcomes(out);
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
case "status": {
|
|
91
|
+
const cfg = loadConfig();
|
|
92
|
+
console.log(`termad home : ${termadHome()}`);
|
|
93
|
+
console.log(`endpoint : ${cfg.endpoint ?? "(none — local/house ads only)"}`);
|
|
94
|
+
console.log(`style : ${cfg.style}`);
|
|
95
|
+
console.log("");
|
|
96
|
+
for (const s of surfaces) {
|
|
97
|
+
const bin = s.locate();
|
|
98
|
+
const state = s.id === "claude"
|
|
99
|
+
? isStatuslineInstalled()
|
|
100
|
+
? "installed (statusline)"
|
|
101
|
+
: "—"
|
|
102
|
+
: bin && isWrapped(bin)
|
|
103
|
+
? `installed (wrapper)`
|
|
104
|
+
: "—";
|
|
105
|
+
console.log(` ${s.id.padEnd(9)} ${String(bin ?? "not found").padEnd(42)} ${state}`);
|
|
106
|
+
}
|
|
107
|
+
const ad = readAdCache();
|
|
108
|
+
if (ad)
|
|
109
|
+
console.log(`\ncached ad:\n${render(ad, cfg.style)}`);
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
case "audit": {
|
|
113
|
+
const rows = audit();
|
|
114
|
+
if (!rows.length) {
|
|
115
|
+
console.log(` no recorded changes (nothing installed by this CLI yet)`);
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
console.log(` ${"SURFACE".padEnd(9)} ${"STATUS".padEnd(9)} DETAIL`);
|
|
119
|
+
for (const r of rows) {
|
|
120
|
+
const icon = r.status === "ok" ? "✓" : r.status === "tampered" || r.status === "missing" ? "✗" : "•";
|
|
121
|
+
console.log(` ${icon} ${r.surface.padEnd(9)} ${r.status.padEnd(9)} ${r.detail}`);
|
|
122
|
+
console.log(` ${"".padEnd(9)} ${"".padEnd(9)} ${r.target}`);
|
|
123
|
+
}
|
|
124
|
+
if (auditFailed(rows)) {
|
|
125
|
+
console.log(`\n integrity problems found — see entries above`);
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
console.log(`\n all recorded changes verified (sha256)`);
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
case "sync": {
|
|
132
|
+
const text = await sync();
|
|
133
|
+
const rep = await reportDisplays();
|
|
134
|
+
console.log(` ad cache refreshed: ${text}`);
|
|
135
|
+
if (rep)
|
|
136
|
+
console.log(` display report: ${rep}`);
|
|
137
|
+
else
|
|
138
|
+
console.log(` display report: nothing pending (or endpoint unreachable — counts retained)`);
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
case "watch": {
|
|
142
|
+
const cfg = loadConfig();
|
|
143
|
+
if (!cfg.endpoint) {
|
|
144
|
+
console.error(" no endpoint configured — run: termad set-endpoint <url>");
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
console.log(` watching ${cfg.endpoint} every ${cfg.pollSeconds}s (ctrl-c to stop)`);
|
|
148
|
+
for (;;) {
|
|
149
|
+
const text = await sync().catch(() => null);
|
|
150
|
+
if (text)
|
|
151
|
+
process.stdout.write(` ${new Date().toISOString()} ${text}\n`);
|
|
152
|
+
await new Promise((r) => setTimeout(r, cfg.pollSeconds * 1000));
|
|
153
|
+
}
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
case "set-endpoint": {
|
|
157
|
+
if (!args[0]) {
|
|
158
|
+
console.error(" usage: termad set-endpoint <url>");
|
|
159
|
+
process.exit(1);
|
|
160
|
+
}
|
|
161
|
+
const cfg = loadConfig();
|
|
162
|
+
cfg.endpoint = args[0].replace(/\/+$/, "");
|
|
163
|
+
saveConfig(cfg);
|
|
164
|
+
console.log(` endpoint set: ${cfg.endpoint}`);
|
|
165
|
+
// Pull the first ad immediately so the cache isn't empty.
|
|
166
|
+
const ad = await fetchAd("claude").catch(() => null);
|
|
167
|
+
if (ad)
|
|
168
|
+
console.log(` first ad: ${ad.text}`);
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
case "style": {
|
|
172
|
+
if (args[0] !== "line" && args[0] !== "banner") {
|
|
173
|
+
console.error(" usage: termad style line|banner");
|
|
174
|
+
process.exit(1);
|
|
175
|
+
}
|
|
176
|
+
const cfg = loadConfig();
|
|
177
|
+
cfg.style = args[0];
|
|
178
|
+
saveConfig(cfg);
|
|
179
|
+
console.log(` style set: ${args[0]}`);
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
default:
|
|
183
|
+
console.log(`termad — opt-in, reversible text ads for terminal AI CLIs
|
|
184
|
+
|
|
185
|
+
termad install [surface ...] install (claude | codex | opencode | gemini)
|
|
186
|
+
termad uninstall [surface ...] remove everything termad added
|
|
187
|
+
termad audit re-verify every recorded change (sha256)
|
|
188
|
+
termad status show installed surfaces + cached ad
|
|
189
|
+
termad set-endpoint <url> point at your ad server
|
|
190
|
+
termad sync fetch ad now + report pending displays
|
|
191
|
+
termad watch poll the endpoint continuously
|
|
192
|
+
termad style line|banner render style`);
|
|
193
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The smallest possible ad: one line of wording, optionally a URL.
|
|
3
|
+
* No images, no beacons, no HTML — the terminal renders it.
|
|
4
|
+
*/
|
|
5
|
+
export interface Ad {
|
|
6
|
+
/** Unique id (server-assigned or slug of the text). */
|
|
7
|
+
id: string;
|
|
8
|
+
/** The wording shown to the user. Kept short — terminal line. */
|
|
9
|
+
text: string;
|
|
10
|
+
/** Click target. Rendered as a bare URL so the terminal auto-links it. */
|
|
11
|
+
url?: string;
|
|
12
|
+
/** Optional second line (e.g. "sponsored by …"). */
|
|
13
|
+
sub?: string;
|
|
14
|
+
}
|
|
15
|
+
/** Shape the HTTP endpoint returns. */
|
|
16
|
+
export interface AdResponse {
|
|
17
|
+
ad: Ad;
|
|
18
|
+
}
|
|
19
|
+
export declare function isValidAd(v: unknown): v is Ad;
|
package/dist/core/ad.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function isValidAd(v) {
|
|
2
|
+
if (typeof v !== "object" || v === null)
|
|
3
|
+
return false;
|
|
4
|
+
const a = v;
|
|
5
|
+
return (typeof a.id === "string" &&
|
|
6
|
+
a.id.length > 0 &&
|
|
7
|
+
typeof a.text === "string" &&
|
|
8
|
+
a.text.length > 0 &&
|
|
9
|
+
a.text.length <= 300 &&
|
|
10
|
+
(a.url === undefined ||
|
|
11
|
+
(typeof a.url === "string" && /^https?:\/\//.test(a.url) && a.url.length <= 500)));
|
|
12
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type SurfaceId = "claude" | "codex" | "opencode" | "gemini";
|
|
2
|
+
export declare const ALL_SURFACES: SurfaceId[];
|
|
3
|
+
export interface TermadConfig {
|
|
4
|
+
/** Your ad endpoint. GET <endpoint>/v1/ad?surface=<s>&uid=<u> → {ad:{...}} */
|
|
5
|
+
endpoint: string | null;
|
|
6
|
+
/** Stable anonymous install id (rotation/frequency capping server-side). */
|
|
7
|
+
uid: string;
|
|
8
|
+
/** How often `termad watch` re-fetches, seconds. */
|
|
9
|
+
pollSeconds: number;
|
|
10
|
+
/** Which surfaces are opted in. */
|
|
11
|
+
surfaces: Record<SurfaceId, boolean>;
|
|
12
|
+
/** "line" | "banner" — render style in the terminal. */
|
|
13
|
+
style: "line" | "banner";
|
|
14
|
+
}
|
|
15
|
+
export declare function loadConfig(): TermadConfig;
|
|
16
|
+
export declare function saveConfig(cfg: TermadConfig): void;
|
|
17
|
+
/** Atomic-ish write so wrappers never read a half-written cache. */
|
|
18
|
+
export declare function writeFileAtomic(path: string, data: string): void;
|