forgecommandline 0.8.0__py3-none-any.whl
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.
- forgecli/__init__.py +2 -0
- forgecli/ads.py +322 -0
- forgecli/agent.py +1093 -0
- forgecli/art.py +574 -0
- forgecli/attach.py +246 -0
- forgecli/brief.py +215 -0
- forgecli/browsercheck.py +99 -0
- forgecli/checkpoints.py +81 -0
- forgecli/city.py +833 -0
- forgecli/cli.py +1668 -0
- forgecli/config.py +194 -0
- forgecli/courier.py +177 -0
- forgecli/courier_data.py +164 -0
- forgecli/courier_hd.py +431 -0
- forgecli/courier_hd_data.bin +0 -0
- forgecli/courier_hd_data.py +19 -0
- forgecli/ghlogin.py +76 -0
- forgecli/github.py +54 -0
- forgecli/guards.py +403 -0
- forgecli/hooks.py +50 -0
- forgecli/httpapi.py +434 -0
- forgecli/images.py +110 -0
- forgecli/keys.py +152 -0
- forgecli/keyvault.py +320 -0
- forgecli/mcp.py +674 -0
- forgecli/memory.py +42 -0
- forgecli/openapi.py +331 -0
- forgecli/parallel.py +308 -0
- forgecli/prices.py +72 -0
- forgecli/procs.py +144 -0
- forgecli/providers.py +758 -0
- forgecli/quality.py +192 -0
- forgecli/repomap.py +78 -0
- forgecli/roster.py +227 -0
- forgecli/safety.py +93 -0
- forgecli/sandbox.py +83 -0
- forgecli/sessions.py +97 -0
- forgecli/skills.py +446 -0
- forgecli/tools.py +1184 -0
- forgecli/ui.py +860 -0
- forgecli/worktrees.py +79 -0
- forgecommandline-0.8.0.data/data/skills/commit/SKILL.md +14 -0
- forgecommandline-0.8.0.data/data/skills/debug/SKILL.md +17 -0
- forgecommandline-0.8.0.data/data/skills/explain/SKILL.md +14 -0
- forgecommandline-0.8.0.data/data/skills/init/SKILL.md +19 -0
- forgecommandline-0.8.0.data/data/skills/library/LICENSES/agent-skills-MIT.txt +21 -0
- forgecommandline-0.8.0.data/data/skills/library/LICENSES/wondelai-MIT.txt +23 -0
- forgecommandline-0.8.0.data/data/skills/library/README.md +9 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/accessibility-checklist.md +160 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/definition-of-done.md +67 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/observability-checklist.md +92 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/orchestration-patterns.md +370 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/performance-checklist.md +238 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/security-checklist.md +248 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/testing-patterns.md +235 -0
- forgecommandline-0.8.0.data/data/skills/library/api-and-interface-design/SKILL.md +367 -0
- forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/SKILL.md +279 -0
- forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/references/artifact-templates.md +157 -0
- forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/references/methods.md +87 -0
- forgecommandline-0.8.0.data/data/skills/library/awwwards-tier-web-application/SKILL.md +877 -0
- forgecommandline-0.8.0.data/data/skills/library/browser-testing-with-devtools/SKILL.md +317 -0
- forgecommandline-0.8.0.data/data/skills/library/ci-cd-and-automation/SKILL.md +390 -0
- forgecommandline-0.8.0.data/data/skills/library/code-review-and-quality/SKILL.md +398 -0
- forgecommandline-0.8.0.data/data/skills/library/code-simplification/SKILL.md +331 -0
- forgecommandline-0.8.0.data/data/skills/library/constraint-driven-development/SKILL.md +311 -0
- forgecommandline-0.8.0.data/data/skills/library/constraint-driven-development/references/floor-guard.md +182 -0
- forgecommandline-0.8.0.data/data/skills/library/context-engineering/SKILL.md +353 -0
- forgecommandline-0.8.0.data/data/skills/library/debugging-and-error-recovery/SKILL.md +300 -0
- forgecommandline-0.8.0.data/data/skills/library/deprecation-and-migration/SKILL.md +247 -0
- forgecommandline-0.8.0.data/data/skills/library/documentation-and-adrs/SKILL.md +297 -0
- forgecommandline-0.8.0.data/data/skills/library/doubt-driven-development/SKILL.md +243 -0
- forgecommandline-0.8.0.data/data/skills/library/frontend-ui-engineering/SKILL.md +340 -0
- forgecommandline-0.8.0.data/data/skills/library/git-workflow-and-versioning/SKILL.md +355 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/SKILL.md +193 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/examples.md +238 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/frameworks.md +99 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/refinement-criteria.md +113 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/scripts/idea-refine.sh +15 -0
- forgecommandline-0.8.0.data/data/skills/library/improve-code-quality/SKILL.md +275 -0
- forgecommandline-0.8.0.data/data/skills/library/improve-code-quality/references/artifact-templates.md +101 -0
- forgecommandline-0.8.0.data/data/skills/library/incremental-implementation/SKILL.md +249 -0
- forgecommandline-0.8.0.data/data/skills/library/interview-me/SKILL.md +235 -0
- forgecommandline-0.8.0.data/data/skills/library/observability-and-instrumentation/SKILL.md +238 -0
- forgecommandline-0.8.0.data/data/skills/library/performance-optimization/SKILL.md +267 -0
- forgecommandline-0.8.0.data/data/skills/library/performance-optimization/references/optimization-patterns.md +250 -0
- forgecommandline-0.8.0.data/data/skills/library/planning-and-task-breakdown/SKILL.md +257 -0
- forgecommandline-0.8.0.data/data/skills/library/remove-technical-debt/SKILL.md +203 -0
- forgecommandline-0.8.0.data/data/skills/library/remove-technical-debt/references/artifact-templates.md +79 -0
- forgecommandline-0.8.0.data/data/skills/library/security-and-hardening/SKILL.md +216 -0
- forgecommandline-0.8.0.data/data/skills/library/security-and-hardening/references/hardening-patterns.md +324 -0
- forgecommandline-0.8.0.data/data/skills/library/shipping-and-launch/SKILL.md +336 -0
- forgecommandline-0.8.0.data/data/skills/library/source-driven-development/SKILL.md +216 -0
- forgecommandline-0.8.0.data/data/skills/library/spec-driven-development/SKILL.md +259 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/SKILL.md +207 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/building-blocks.md +272 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/common-designs.md +257 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/database-scaling.md +295 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/estimation-numbers.md +250 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/four-step-process.md +286 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/reliability-operations.md +373 -0
- forgecommandline-0.8.0.data/data/skills/library/test-driven-development/SKILL.md +398 -0
- forgecommandline-0.8.0.data/data/skills/library/using-agent-skills/SKILL.md +192 -0
- forgecommandline-0.8.0.data/data/skills/review/SKILL.md +16 -0
- forgecommandline-0.8.0.data/data/skills/run/SKILL.md +17 -0
- forgecommandline-0.8.0.data/data/skills/security-review/SKILL.md +20 -0
- forgecommandline-0.8.0.data/data/skills/simplify/SKILL.md +18 -0
- forgecommandline-0.8.0.data/data/skills/test/SKILL.md +16 -0
- forgecommandline-0.8.0.data/data/skills/web-build/SKILL.md +25 -0
- forgecommandline-0.8.0.dist-info/METADATA +357 -0
- forgecommandline-0.8.0.dist-info/RECORD +113 -0
- forgecommandline-0.8.0.dist-info/WHEEL +5 -0
- forgecommandline-0.8.0.dist-info/entry_points.txt +2 -0
- forgecommandline-0.8.0.dist-info/top_level.txt +1 -0
forgecli/__init__.py
ADDED
forgecli/ads.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""One sponsor line, shown on the goodbye screen, under the rules in HANDOFF.md (NEXT 8).
|
|
2
|
+
|
|
3
|
+
- Off unless an ad server is configured (FORGE_ADS_URL, or AD_SERVER below once Harry runs one) and
|
|
4
|
+
its public key is known (FORGE_ADS_KEY / AD_PUBLIC_KEY). Users turn it off with FORGE_ADS=0 or
|
|
5
|
+
`/ads off`. Never in pipes, scripts, CI or `forge -p` runs, and not in the first week after install.
|
|
6
|
+
- The request carries only Forge's version and a random install id (`/ads reset` makes a new one):
|
|
7
|
+
no prompts, code, paths, keys or project details.
|
|
8
|
+
- Fetched in the background with a 0.5 s timeout; slow or down server = no ad, never a slower reply.
|
|
9
|
+
- Every ad is signed with Harry's Ed25519 key and rejected otherwise, so whoever takes over the
|
|
10
|
+
server's address can't show their own lines. The text is reduced to plain printable words and one
|
|
11
|
+
https link, so an ad can't clear the screen, move the cursor, fake a Forge message or hide its link.
|
|
12
|
+
- An ad is display text only: it never reaches the model, a tool or the shell.
|
|
13
|
+
|
|
14
|
+
`python -m forgecli.ads keygen` makes the key pair for the server (see server/ads-worker/README.md).
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import pathlib
|
|
22
|
+
import re
|
|
23
|
+
import secrets
|
|
24
|
+
import sys
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
import unicodedata
|
|
28
|
+
import urllib.parse
|
|
29
|
+
import urllib.request
|
|
30
|
+
|
|
31
|
+
AD_SERVER = "" # e.g. "https://ads.forge-cli.dev" once deployed
|
|
32
|
+
AD_PUBLIC_KEY = "" # hex Ed25519 public key printed by `python -m forgecli.ads keygen`
|
|
33
|
+
TIMEOUT = 0.5
|
|
34
|
+
QUIET_DAYS = 7
|
|
35
|
+
MAX_TEXT, MAX_URL = 90, 100
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _home() -> pathlib.Path:
|
|
39
|
+
return pathlib.Path(os.environ.get("FORGE_HOME", pathlib.Path.home() / ".forge"))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _state_path() -> pathlib.Path:
|
|
43
|
+
return _home() / "ads.json"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_state() -> dict:
|
|
47
|
+
try:
|
|
48
|
+
st = json.loads(_state_path().read_text(encoding="utf-8"))
|
|
49
|
+
if isinstance(st, dict):
|
|
50
|
+
return st
|
|
51
|
+
except (OSError, ValueError):
|
|
52
|
+
pass
|
|
53
|
+
return {}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def save_state(st: dict) -> None:
|
|
57
|
+
try:
|
|
58
|
+
_state_path().parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
_state_path().write_text(json.dumps(st, indent=1), encoding="utf-8")
|
|
60
|
+
except OSError:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def install_id(st: dict) -> str:
|
|
65
|
+
if not st.get("id"):
|
|
66
|
+
st["id"] = secrets.token_hex(12)
|
|
67
|
+
st.setdefault("since", time.time())
|
|
68
|
+
save_state(st)
|
|
69
|
+
return st["id"]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def server() -> str:
|
|
73
|
+
return (os.environ.get("FORGE_ADS_URL") or AD_SERVER).strip().rstrip("/")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def public_key() -> str:
|
|
77
|
+
return (os.environ.get("FORGE_ADS_KEY") or AD_PUBLIC_KEY).strip().lower()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def user_off(st: dict | None = None) -> bool:
|
|
81
|
+
env = os.environ.get("FORGE_ADS", "").strip().lower()
|
|
82
|
+
if env in ("0", "off", "false", "no"):
|
|
83
|
+
return True
|
|
84
|
+
return (st if st is not None else load_state()).get("off") is True
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def allowed(st: dict | None = None, interactive: bool | None = None) -> tuple[bool, str]:
|
|
88
|
+
"""(may we show an ad, why not)."""
|
|
89
|
+
st = st if st is not None else load_state()
|
|
90
|
+
if user_off(st):
|
|
91
|
+
return False, "turned off"
|
|
92
|
+
if not server() or not public_key():
|
|
93
|
+
return False, "no ad server configured"
|
|
94
|
+
if interactive is None:
|
|
95
|
+
interactive = sys.stdin.isatty() and sys.stdout.isatty()
|
|
96
|
+
if not interactive or os.environ.get("CI") or os.environ.get("GITHUB_ACTIONS"):
|
|
97
|
+
return False, "not an interactive terminal"
|
|
98
|
+
since = st.get("since") or time.time()
|
|
99
|
+
if time.time() - since < QUIET_DAYS * 86400:
|
|
100
|
+
return False, f"first {QUIET_DAYS} days after install"
|
|
101
|
+
return True, ""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# --- cleaning --------------------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
_BIDI = set(range(0x202A, 0x202F)) | set(range(0x2066, 0x206A)) | {0x200E, 0x200F, 0x061C}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def clean_text(text: str) -> str:
|
|
110
|
+
"""Printable words only: no escape codes, control or format characters, bidi tricks or newlines."""
|
|
111
|
+
out = []
|
|
112
|
+
raw = str(text or "")
|
|
113
|
+
raw = re.sub(r"\x1b\][^\x07\x1b]*(\x07|\x1b\\)?", " ", raw) # OSC (titles, hyperlinks)
|
|
114
|
+
raw = re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", " ", raw) # CSI (cursor, colour, clear)
|
|
115
|
+
raw = re.sub(r"\x1b[PX^_][^\x1b]*(\x1b\\)?", " ", raw) # DCS/SOS/PM/APC (sixel etc.)
|
|
116
|
+
raw = re.sub(r"\x1b.?", " ", raw)
|
|
117
|
+
raw = re.sub(r"\x9b[0-?]*[ -/]*[@-~]", " ", raw) # 8-bit CSI
|
|
118
|
+
for ch in unicodedata.normalize("NFKC", raw):
|
|
119
|
+
cat = unicodedata.category(ch)
|
|
120
|
+
if ord(ch) in _BIDI or cat in ("Cc", "Cf", "Cs", "Co", "Cn", "Zl", "Zp"):
|
|
121
|
+
out.append(" ")
|
|
122
|
+
else:
|
|
123
|
+
out.append(ch)
|
|
124
|
+
s = re.sub(r"\s+", " ", "".join(out)).strip()
|
|
125
|
+
return s if len(s) <= MAX_TEXT else s[:MAX_TEXT - 1].rstrip() + "…"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def clean_url(url: str) -> str:
|
|
129
|
+
"""One plain https link a person can read; anything else is dropped."""
|
|
130
|
+
u = str(url or "").strip()
|
|
131
|
+
if len(u) > MAX_URL or not re.fullmatch(r"https://[A-Za-z0-9.\-]+(:\d+)?(/[A-Za-z0-9._~/%?=&+\-#]*)?", u):
|
|
132
|
+
return ""
|
|
133
|
+
host = urllib.parse.urlparse(u).hostname or ""
|
|
134
|
+
if "xn--" in host or "." not in host:
|
|
135
|
+
return ""
|
|
136
|
+
return u
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# --- signatures (pure-Python Ed25519, RFC 8032) -----------------------------------------------------
|
|
140
|
+
|
|
141
|
+
_P = 2 ** 255 - 19
|
|
142
|
+
_L = 2 ** 252 + 27742317777372353535851937790883648493
|
|
143
|
+
_D = -121665 * pow(121666, _P - 2, _P) % _P
|
|
144
|
+
_I = pow(2, (_P - 1) // 4, _P)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _inv(x):
|
|
148
|
+
return pow(x, _P - 2, _P)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _xrecover(y):
|
|
152
|
+
xx = (y * y - 1) * _inv(_D * y * y + 1)
|
|
153
|
+
x = pow(xx, (_P + 3) // 8, _P)
|
|
154
|
+
if (x * x - xx) % _P:
|
|
155
|
+
x = x * _I % _P
|
|
156
|
+
if (x * x - xx) % _P:
|
|
157
|
+
raise ValueError("not on curve")
|
|
158
|
+
return _P - x if x & 1 else x
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
_BY = 4 * _inv(5) % _P
|
|
162
|
+
_BX = _xrecover(_BY)
|
|
163
|
+
_B = (_BX, _BY, 1, _BX * _BY % _P)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _add(p, q):
|
|
167
|
+
a = (p[1] - p[0]) * (q[1] - q[0]) % _P
|
|
168
|
+
b = (p[1] + p[0]) * (q[1] + q[0]) % _P
|
|
169
|
+
c = 2 * p[3] * q[3] * _D % _P
|
|
170
|
+
d = 2 * p[2] * q[2] % _P
|
|
171
|
+
e, f, g, h = b - a, d - c, d + c, b + a
|
|
172
|
+
return (e * f % _P, g * h % _P, f * g % _P, e * h % _P)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _mul(s, p):
|
|
176
|
+
q = (0, 1, 1, 0)
|
|
177
|
+
while s:
|
|
178
|
+
if s & 1:
|
|
179
|
+
q = _add(q, p)
|
|
180
|
+
p = _add(p, p)
|
|
181
|
+
s >>= 1
|
|
182
|
+
return q
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _enc(p):
|
|
186
|
+
zi = _inv(p[2])
|
|
187
|
+
x, y = p[0] * zi % _P, p[1] * zi % _P
|
|
188
|
+
return (y | ((x & 1) << 255)).to_bytes(32, "little")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _dec(b):
|
|
192
|
+
y = int.from_bytes(b, "little")
|
|
193
|
+
sign, y = y >> 255, y & ((1 << 255) - 1)
|
|
194
|
+
if y >= _P:
|
|
195
|
+
raise ValueError("bad point")
|
|
196
|
+
x = _xrecover(y)
|
|
197
|
+
if (x & 1) != sign:
|
|
198
|
+
if x == 0:
|
|
199
|
+
raise ValueError("bad point")
|
|
200
|
+
x = _P - x
|
|
201
|
+
return (x, y, 1, x * y % _P)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _h(m):
|
|
205
|
+
return hashlib.sha512(m).digest()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _secret(seed: bytes):
|
|
209
|
+
h = _h(seed)
|
|
210
|
+
a = int.from_bytes(h[:32], "little")
|
|
211
|
+
a = (a & ((1 << 254) - 8)) | (1 << 254)
|
|
212
|
+
return a, h[32:]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def public_from_seed(seed: bytes) -> bytes:
|
|
216
|
+
return _enc(_mul(_secret(seed)[0], _B))
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def sign(seed: bytes, msg: bytes) -> bytes:
|
|
220
|
+
a, prefix = _secret(seed)
|
|
221
|
+
pub = _enc(_mul(a, _B))
|
|
222
|
+
r = int.from_bytes(_h(prefix + msg), "little") % _L
|
|
223
|
+
rb = _enc(_mul(r, _B))
|
|
224
|
+
k = int.from_bytes(_h(rb + pub + msg), "little") % _L
|
|
225
|
+
return rb + ((r + k * a) % _L).to_bytes(32, "little")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def verify(pub: bytes, msg: bytes, sig: bytes) -> bool:
|
|
229
|
+
if len(pub) != 32 or len(sig) != 64:
|
|
230
|
+
return False
|
|
231
|
+
try:
|
|
232
|
+
a, r = _dec(pub), _dec(sig[:32])
|
|
233
|
+
except ValueError:
|
|
234
|
+
return False
|
|
235
|
+
s = int.from_bytes(sig[32:], "little")
|
|
236
|
+
if s >= _L:
|
|
237
|
+
return False
|
|
238
|
+
k = int.from_bytes(_h(sig[:32] + pub + msg), "little") % _L
|
|
239
|
+
return _enc(_mul(s, _B)) == _enc(_add(r, _mul(k, a)))
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def signed_message(ad: dict) -> bytes:
|
|
243
|
+
"""Exactly what the server signs (server/ads-worker/worker.js builds the same string)."""
|
|
244
|
+
return "\n".join(str(ad.get(k, "")) for k in ("id", "text", "url", "exp")).encode("utf-8")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def check(ad: dict, key_hex: str | None = None, now: float | None = None) -> dict | None:
|
|
248
|
+
"""A signed, unexpired, cleaned ad, or None."""
|
|
249
|
+
try:
|
|
250
|
+
key = bytes.fromhex(key_hex if key_hex is not None else public_key())
|
|
251
|
+
sig = bytes.fromhex(str(ad.get("sig", "")))
|
|
252
|
+
exp = int(ad.get("exp", 0))
|
|
253
|
+
except (ValueError, TypeError, AttributeError):
|
|
254
|
+
return None
|
|
255
|
+
if exp < (now or time.time()) or not verify(key, signed_message(ad), sig):
|
|
256
|
+
return None
|
|
257
|
+
text, url = clean_text(ad.get("text", "")), clean_url(ad.get("url", ""))
|
|
258
|
+
if not text:
|
|
259
|
+
return None
|
|
260
|
+
return {"id": str(ad.get("id", ""))[:40], "text": text, "url": url, "exp": exp}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# --- fetching and showing ---------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
class Ads:
|
|
266
|
+
"""Starts one background fetch per session; `line()` gives the goodbye line, if any."""
|
|
267
|
+
|
|
268
|
+
def __init__(self, version: str, interactive: bool | None = None):
|
|
269
|
+
self.version = version
|
|
270
|
+
self.st = load_state()
|
|
271
|
+
install_id(self.st)
|
|
272
|
+
self.ok, self.why = allowed(self.st, interactive)
|
|
273
|
+
self.ad: dict | None = None
|
|
274
|
+
self._thread: threading.Thread | None = None
|
|
275
|
+
if self.ok:
|
|
276
|
+
self._thread = threading.Thread(target=self._fetch, daemon=True)
|
|
277
|
+
self._thread.start()
|
|
278
|
+
|
|
279
|
+
def _fetch(self) -> None:
|
|
280
|
+
q = urllib.parse.urlencode({"v": self.version, "id": self.st["id"]})
|
|
281
|
+
try:
|
|
282
|
+
req = urllib.request.Request(f"{server()}/v1/ad?{q}", headers={"User-Agent": "forge-cli"})
|
|
283
|
+
with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
|
|
284
|
+
data = json.loads(r.read(4096).decode("utf-8", "replace"))
|
|
285
|
+
ad = check(data) if isinstance(data, dict) and data.get("id") else None
|
|
286
|
+
if ad:
|
|
287
|
+
self.st["cached"] = ad
|
|
288
|
+
save_state(self.st)
|
|
289
|
+
self.ad = ad
|
|
290
|
+
except Exception:
|
|
291
|
+
self.ad = None # offline or slow: fall back to the cached ad below
|
|
292
|
+
|
|
293
|
+
def line(self) -> str | None:
|
|
294
|
+
if not self.ok:
|
|
295
|
+
return None
|
|
296
|
+
if self._thread is not None:
|
|
297
|
+
self._thread.join(timeout=0.05) # never hold up quitting
|
|
298
|
+
ad = self.ad
|
|
299
|
+
if ad is None:
|
|
300
|
+
cached = self.st.get("cached")
|
|
301
|
+
ad = check(cached) if isinstance(cached, dict) and cached.get("sig") else None
|
|
302
|
+
if ad is None:
|
|
303
|
+
return None
|
|
304
|
+
return f"sponsor · {ad['text']}" + (f" {ad['url']}" if ad["url"] else "")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def keygen() -> None:
|
|
308
|
+
import base64
|
|
309
|
+
seed = secrets.token_bytes(32)
|
|
310
|
+
pub = public_from_seed(seed)
|
|
311
|
+
pkcs8 = bytes.fromhex("302e020100300506032b657004220420") + seed
|
|
312
|
+
print("PUBLIC key (put in forgecli/ads.py AD_PUBLIC_KEY, or FORGE_ADS_KEY):")
|
|
313
|
+
print(" " + pub.hex())
|
|
314
|
+
print("PRIVATE key for the Worker (wrangler secret put AD_SIGNING_KEY) - keep it secret, never commit:")
|
|
315
|
+
print(" " + base64.b64encode(pkcs8).decode())
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
if __name__ == "__main__":
|
|
319
|
+
if sys.argv[1:2] == ["keygen"]:
|
|
320
|
+
keygen()
|
|
321
|
+
else:
|
|
322
|
+
print("usage: python -m forgecli.ads keygen")
|