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/courier_hd.py
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
"""The neon courier at full resolution: real pixels (sixel) instead of half-block characters.
|
|
2
|
+
|
|
3
|
+
Windows Terminal (and other sixel terminals) can show images. The live status above the message
|
|
4
|
+
box is drawn by prompt_toolkit, which only knows text, so the status keeps a few blank rows for the
|
|
5
|
+
lane and, right after each prompt_toolkit redraw, Overlay paints the pile, the door, the road and
|
|
6
|
+
the courier into those rows as sixel images. Frames are baked from the original GIF at up to its own
|
|
7
|
+
size (courier_hd_data.py + courier_hd_data.bin, lzma, no Pillow at runtime); encoded images are
|
|
8
|
+
cached, so a redraw is only a write.
|
|
9
|
+
|
|
10
|
+
Barrow (city.py) drives what he does; courier.py is the half-block fallback for other terminals.
|
|
11
|
+
FORGE_COURIER_HD=off forces the fallback, =on forces HD; FORGE_CELL_PX=10x20 sets the pixel size of
|
|
12
|
+
a character cell (Windows Terminal maps sixel images to 10x20 per cell whatever the font).
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import lzma
|
|
17
|
+
import os
|
|
18
|
+
import pathlib
|
|
19
|
+
import re
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
|
|
23
|
+
from .courier_hd_data import CELL_H, FRAMES, ROAD_RGB, SIZES
|
|
24
|
+
|
|
25
|
+
T = 255 # transparent index
|
|
26
|
+
FRAME_SECS = 0.14 # the GIF's own frame time
|
|
27
|
+
DOOR_RGB = (156, 60, 140)
|
|
28
|
+
DOOR_LIT = (255, 79, 216)
|
|
29
|
+
OK_RGB = (125, 255, 154)
|
|
30
|
+
BAD_RGB = (255, 95, 109)
|
|
31
|
+
PILE_KEEP = 0.9
|
|
32
|
+
|
|
33
|
+
_BIN = pathlib.Path(__file__).with_name("courier_hd_data.bin")
|
|
34
|
+
_lock = threading.Lock()
|
|
35
|
+
_sizes: dict = {}
|
|
36
|
+
_cache: dict = {}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cell_px() -> tuple[int, int]:
|
|
40
|
+
v = os.environ.get("FORGE_CELL_PX", "")
|
|
41
|
+
m = re.fullmatch(r"\s*(\d+)\s*[xX]\s*(\d+)\s*", v)
|
|
42
|
+
if m:
|
|
43
|
+
return max(4, int(m.group(1))), max(8, int(m.group(2)))
|
|
44
|
+
return 10, CELL_H
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def enabled() -> bool:
|
|
48
|
+
"""HD when asked, or by default in Windows Terminal (it shows sixel images)."""
|
|
49
|
+
v = os.environ.get("FORGE_COURIER_HD", "").strip().lower()
|
|
50
|
+
if v in ("0", "off", "no", "false"):
|
|
51
|
+
return False
|
|
52
|
+
if os.environ.get("FORGE_COURIER", "").strip().lower() in ("0", "off", "no", "false"):
|
|
53
|
+
return False
|
|
54
|
+
if v in ("1", "on", "yes", "true"):
|
|
55
|
+
return True
|
|
56
|
+
if os.environ.get("FORGE_GRAPHICS", "").strip().lower() in ("blocks", "plain", "text"):
|
|
57
|
+
return False
|
|
58
|
+
return bool(os.environ.get("WT_SESSION")) and _BIN.exists()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def pick_rows(term_h: int, term_w: int) -> int:
|
|
62
|
+
"""Biggest baked size that fits: about a quarter of the window's height, and the lane in its width."""
|
|
63
|
+
sizes = sorted(SIZES)
|
|
64
|
+
want = os.environ.get("FORGE_COURIER_ROWS", "").strip()
|
|
65
|
+
if want.isdigit():
|
|
66
|
+
n = int(want)
|
|
67
|
+
rows = max([s for s in sizes if s <= n] or [sizes[0]])
|
|
68
|
+
else:
|
|
69
|
+
rows = max([s for s in sizes if s <= max(sizes[0], term_h // 4)] or [sizes[0]])
|
|
70
|
+
while rows > sizes[0] and lane_cells(rows) + 4 > term_w:
|
|
71
|
+
rows = max(s for s in sizes if s < rows)
|
|
72
|
+
return rows
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _load(rows: int):
|
|
76
|
+
"""(w, h, pw, ph, road, palette, frames[(full, empty)], pile) for one size."""
|
|
77
|
+
with _lock:
|
|
78
|
+
got = _sizes.get(rows)
|
|
79
|
+
if got:
|
|
80
|
+
return got
|
|
81
|
+
w, h, pw, ph, road, off, n = SIZES[rows]
|
|
82
|
+
with open(_BIN, "rb") as fh:
|
|
83
|
+
fh.seek(off)
|
|
84
|
+
raw = lzma.decompress(fh.read(n))
|
|
85
|
+
pal = [tuple(raw[i:i + 3]) for i in range(0, 255 * 3, 3)]
|
|
86
|
+
p, size = 255 * 3, w * h
|
|
87
|
+
frames = []
|
|
88
|
+
for _ in range(FRAMES):
|
|
89
|
+
full = raw[p:p + size]
|
|
90
|
+
empty = raw[p + size:p + 2 * size]
|
|
91
|
+
frames.append((full, empty))
|
|
92
|
+
p += 2 * size
|
|
93
|
+
pile = raw[p:p + pw * ph]
|
|
94
|
+
got = (w, h, pw, ph, road, pal, frames, pile)
|
|
95
|
+
_sizes[rows] = got
|
|
96
|
+
return got
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def geometry(rows: int) -> dict:
|
|
100
|
+
"""Where things sit in the lane, in cells: pile, travel, sprite, door."""
|
|
101
|
+
cw, ch = cell_px()
|
|
102
|
+
w, h, pw, ph = SIZES[rows][:4]
|
|
103
|
+
sprite_c = -(-w // cw)
|
|
104
|
+
pile_c = -(-pw // cw) + 1
|
|
105
|
+
door_c = max(4, -(-rows * ch // 3 // cw) // 2 + 2)
|
|
106
|
+
travel_c = max(6, sprite_c // 2 + 6)
|
|
107
|
+
return {"cw": cw, "ch": ch, "sprite_c": sprite_c, "pile_c": pile_c, "door_c": door_c,
|
|
108
|
+
"travel_c": travel_c, "lane_c": pile_c + travel_c + sprite_c + door_c}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def lane_cells(rows: int) -> int:
|
|
112
|
+
return geometry(rows)["lane_c"]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---------------------------------------------------------------- sixel encoding
|
|
116
|
+
|
|
117
|
+
_TO_CHAR = bytes(range(63, 127)) + bytes(192)
|
|
118
|
+
_RLE = re.compile(rb"(.)\1{3,}")
|
|
119
|
+
_TABLES: dict = {}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _table(c: int, k: int) -> bytes:
|
|
123
|
+
key = (c, k)
|
|
124
|
+
t = _TABLES.get(key)
|
|
125
|
+
if t is None:
|
|
126
|
+
b = bytearray(256)
|
|
127
|
+
b[c] = 1 << k
|
|
128
|
+
t = _TABLES[key] = bytes(b)
|
|
129
|
+
return t
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _rle(m: re.Match) -> bytes:
|
|
133
|
+
run = m.group(0)
|
|
134
|
+
return b"!%d%s" % (len(run), run[:1])
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def encode(idx: bytes, w: int, h: int, pal) -> bytes:
|
|
138
|
+
"""Indexed pixels (T = transparent) to a sixel image with a transparent background."""
|
|
139
|
+
out = [b'\x1bP0;1;0q"1;1;%d;%d' % (w, h)]
|
|
140
|
+
used = sorted(set(idx) - {T})
|
|
141
|
+
for c in used:
|
|
142
|
+
r, g, b = pal[c]
|
|
143
|
+
out.append(b"#%d;2;%d;%d;%d" % (c, r * 100 // 255, g * 100 // 255, b * 100 // 255))
|
|
144
|
+
for y0 in range(0, h, 6):
|
|
145
|
+
rows = [idx[y * w:(y + 1) * w] for y in range(y0, min(h, y0 + 6))]
|
|
146
|
+
here = set()
|
|
147
|
+
for r in rows:
|
|
148
|
+
here.update(r)
|
|
149
|
+
here.discard(T)
|
|
150
|
+
first = True
|
|
151
|
+
for c in sorted(here):
|
|
152
|
+
acc = 0
|
|
153
|
+
for k, r in enumerate(rows):
|
|
154
|
+
acc |= int.from_bytes(r.translate(_table(c, k)), "big")
|
|
155
|
+
line = acc.to_bytes(w, "big").translate(_TO_CHAR).rstrip(b"?")
|
|
156
|
+
if not line:
|
|
157
|
+
continue
|
|
158
|
+
out.append((b"" if first else b"$") + b"#%d" % c + _RLE.sub(_rle, line))
|
|
159
|
+
first = False
|
|
160
|
+
out.append(b"-")
|
|
161
|
+
out.append(b"\x1b\\")
|
|
162
|
+
return b"".join(out)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _dimmed(pal, k: float):
|
|
166
|
+
return [(int(r * k), int(g * k), int(b * k)) for r, g, b in pal]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ---------------------------------------------------------------- the pieces
|
|
170
|
+
|
|
171
|
+
def courier_image(rows: int, frame: int, flip: bool, fill: int, dim: bool, spilled: bool) -> tuple[bytes, int, int]:
|
|
172
|
+
"""The kid and his barrow; fill 0..4 = how full the tub is (parcels appear from the bottom)."""
|
|
173
|
+
key = ("c", rows, frame, flip, fill, dim, spilled)
|
|
174
|
+
got = _cache.get(key)
|
|
175
|
+
if got:
|
|
176
|
+
return got
|
|
177
|
+
w, h, _, _, _, pal, frames, _ = _load(rows)
|
|
178
|
+
full, empty = frames[frame % len(frames)]
|
|
179
|
+
if fill >= 4 and not spilled:
|
|
180
|
+
px = bytearray(full)
|
|
181
|
+
elif fill <= 0 or spilled:
|
|
182
|
+
px = bytearray(empty)
|
|
183
|
+
else:
|
|
184
|
+
# parcel pixels are where the full frame has something the empty one does not
|
|
185
|
+
px = bytearray(empty)
|
|
186
|
+
rows_with = [y for y in range(h) if any(full[y * w + x] != T and empty[y * w + x] == T for x in range(w))]
|
|
187
|
+
if rows_with:
|
|
188
|
+
top, bot = rows_with[0], rows_with[-1]
|
|
189
|
+
show_from = bot - (bot - top + 1) * fill / 4
|
|
190
|
+
for y in rows_with:
|
|
191
|
+
if y < show_from:
|
|
192
|
+
continue
|
|
193
|
+
for x in range(y * w, (y + 1) * w):
|
|
194
|
+
if empty[x] == T and full[x] != T:
|
|
195
|
+
px[x] = full[x]
|
|
196
|
+
if flip:
|
|
197
|
+
px = bytearray(b"".join(bytes(px[y * w:(y + 1) * w])[::-1] for y in range(h)))
|
|
198
|
+
got = (encode(bytes(px), w, h, _dimmed(pal, 0.55) if dim else pal), w, h)
|
|
199
|
+
if len(_cache) > 600:
|
|
200
|
+
_cache.clear()
|
|
201
|
+
_cache[key] = got
|
|
202
|
+
return got
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def pile_image(rows: int, dim: bool, red: bool) -> tuple[bytes, int, int]:
|
|
206
|
+
key = ("p", rows, dim, red)
|
|
207
|
+
got = _cache.get(key)
|
|
208
|
+
if got:
|
|
209
|
+
return got
|
|
210
|
+
_, _, pw, ph, _, pal, _, pile = _load(rows)
|
|
211
|
+
p = pal
|
|
212
|
+
if red:
|
|
213
|
+
p = [(min(255, int(r * 0.5 + 128)), int(g * 0.35), int(b * 0.35)) for r, g, b in pal]
|
|
214
|
+
if dim:
|
|
215
|
+
p = _dimmed(p, 0.55)
|
|
216
|
+
# Sit the pile on the road: pad with transparent rows above it.
|
|
217
|
+
road_y = SIZES[rows][4][0]
|
|
218
|
+
hh = max(ph, road_y)
|
|
219
|
+
px = bytes([T]) * (pw * (hh - ph)) + bytes(pile)
|
|
220
|
+
got = (encode(px, pw, hh, p), pw, hh)
|
|
221
|
+
_cache[key] = got
|
|
222
|
+
return got
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def door_image(rows: int, state: str, lit: bool) -> tuple[bytes, int, int]:
|
|
226
|
+
"""A neon doorway; green with a tick when done, red when it failed, glowing while a tool runs."""
|
|
227
|
+
key = ("d", rows, state, lit)
|
|
228
|
+
got = _cache.get(key)
|
|
229
|
+
if got:
|
|
230
|
+
return got
|
|
231
|
+
g = geometry(rows)
|
|
232
|
+
w = (g["door_c"] - 1) * g["cw"]
|
|
233
|
+
lane_h = rows * g["ch"]
|
|
234
|
+
road_y = SIZES[rows][4][0]
|
|
235
|
+
h = min(lane_h, max(24, int(lane_h * 0.62)))
|
|
236
|
+
top = max(0, road_y - h)
|
|
237
|
+
rgb = OK_RGB if state == "completed" else BAD_RGB if state == "failed" else DOOR_LIT if lit else DOOR_RGB
|
|
238
|
+
pal = [rgb, tuple(v // 3 for v in rgb)]
|
|
239
|
+
t = max(2, w // 12)
|
|
240
|
+
full_h = road_y
|
|
241
|
+
px = bytearray([T]) * (w * full_h)
|
|
242
|
+
for y in range(top, full_h):
|
|
243
|
+
for x in range(w):
|
|
244
|
+
edge = y < top + t or x < t or x >= w - t
|
|
245
|
+
if edge:
|
|
246
|
+
px[y * w + x] = 0
|
|
247
|
+
elif lit or state in ("completed", "failed"):
|
|
248
|
+
px[y * w + x] = 1
|
|
249
|
+
if state == "completed": # a tick in the doorway
|
|
250
|
+
cx, cy, s = w // 2, top + (full_h - top) * 2 // 3, max(3, w // 6)
|
|
251
|
+
pts = [(cx - s + i, cy - s + i) for i in range(s)] + [(cx + i, cy - i) for i in range(2 * s)]
|
|
252
|
+
for (x, y) in pts:
|
|
253
|
+
for dy in range(-t // 2, t // 2 + 1):
|
|
254
|
+
for dx in range(-t // 2, t // 2 + 1):
|
|
255
|
+
if 0 <= x + dx < w and 0 <= y + dy < full_h:
|
|
256
|
+
px[(y + dy) * w + x + dx] = 0
|
|
257
|
+
got = (encode(bytes(px), w, full_h, pal), w, full_h)
|
|
258
|
+
_cache[key] = got
|
|
259
|
+
return got
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def road_image(rows: int, cells: int) -> tuple[bytes, int, int]:
|
|
263
|
+
"""The dashed neon road under the whole lane (static, like the GIF's)."""
|
|
264
|
+
key = ("r", rows, cells)
|
|
265
|
+
got = _cache.get(key)
|
|
266
|
+
if got:
|
|
267
|
+
return got
|
|
268
|
+
g = geometry(rows)
|
|
269
|
+
w = cells * g["cw"]
|
|
270
|
+
road_y, road_h = SIZES[rows][4]
|
|
271
|
+
h = road_y + road_h
|
|
272
|
+
px = bytearray([T]) * (w * h)
|
|
273
|
+
dash, gap = g["cw"] * 3, g["cw"]
|
|
274
|
+
for y in range(road_y, h):
|
|
275
|
+
for x in range(w):
|
|
276
|
+
if (x % (dash + gap)) < dash:
|
|
277
|
+
px[y * w + x] = 0
|
|
278
|
+
got = (encode(bytes(px), w, h, [ROAD_RGB]), w, h)
|
|
279
|
+
_cache[key] = got
|
|
280
|
+
return got
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def lane_images(barrow, now: float, rows: int) -> tuple[list, tuple]:
|
|
284
|
+
"""[(cell column, sixel)] for everything in the lane right now, and a key that changes when
|
|
285
|
+
the picture does (so unchanged frames are not sent again)."""
|
|
286
|
+
snap = barrow.snapshot(now)
|
|
287
|
+
g = geometry(rows)
|
|
288
|
+
st = snap["state"]
|
|
289
|
+
dim = st in ("waiting", "cancelled")
|
|
290
|
+
failed = st == "failed"
|
|
291
|
+
x = g["pile_c"] + round(snap["pos"] / snap["track"] * g["travel_c"])
|
|
292
|
+
frame = int(now / FRAME_SECS) % FRAMES if snap["moving"] else 0
|
|
293
|
+
fill = round(snap["fill"] * 4)
|
|
294
|
+
flip = not snap["right"]
|
|
295
|
+
lit = snap["at_door"] and (snap["busy"] or snap["parked"])
|
|
296
|
+
pulse = lit and ((int(now * 3) % 2 == 0) or not snap["motion"])
|
|
297
|
+
items = [(0, road_image(rows, g["lane_c"])[0]),
|
|
298
|
+
(0, pile_image(rows, dim, False)[0]),
|
|
299
|
+
(g["lane_c"] - g["door_c"] + 1, door_image(rows, st, pulse)[0])]
|
|
300
|
+
if failed: # the load tipped out on the road behind him
|
|
301
|
+
items.append((max(0, x - g["pile_c"] + 1), pile_image(rows, False, True)[0]))
|
|
302
|
+
items.append((x, courier_image(rows, frame, flip, fill, dim, failed)[0]))
|
|
303
|
+
key = (rows, x, frame, fill, flip, st, pulse, dim)
|
|
304
|
+
return items, key
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# ---------------------------------------------------------------- painting over prompt_toolkit
|
|
308
|
+
|
|
309
|
+
class Overlay:
|
|
310
|
+
"""Paints the lane into the blank rows the status keeps for it, after each prompt_toolkit redraw.
|
|
311
|
+
|
|
312
|
+
The UI sets `lane` = (first line of the lane inside the prompt's message, rows, indent) while it
|
|
313
|
+
wants the courier shown, or None. The overlay saves the cursor, moves up from where
|
|
314
|
+
prompt_toolkit left it to the lane, wipes those rows (images there are erased with the cells),
|
|
315
|
+
draws the images, and restores the cursor, so prompt_toolkit never notices.
|
|
316
|
+
"""
|
|
317
|
+
|
|
318
|
+
MIN_REDRAW = 0.35 # repaint at least this often (prompt_toolkit may have cleared it)
|
|
319
|
+
|
|
320
|
+
def __init__(self, ui, app):
|
|
321
|
+
self.ui = ui
|
|
322
|
+
self.app = app
|
|
323
|
+
self.drawn = None # (row offset above cursor at draw time, rows, indent, cells)
|
|
324
|
+
self.last_key = None
|
|
325
|
+
self.last_at = 0.0
|
|
326
|
+
self.failed = False
|
|
327
|
+
app.after_render += self._after
|
|
328
|
+
r = app.renderer
|
|
329
|
+
self._erase, self._reset = r.erase, r.reset
|
|
330
|
+
|
|
331
|
+
def erase(*a, **k):
|
|
332
|
+
self._wipe()
|
|
333
|
+
return self._erase(*a, **k)
|
|
334
|
+
|
|
335
|
+
def reset(*a, **k):
|
|
336
|
+
self.drawn = None
|
|
337
|
+
self.last_key = None
|
|
338
|
+
return self._reset(*a, **k)
|
|
339
|
+
|
|
340
|
+
r.erase, r.reset = erase, reset
|
|
341
|
+
|
|
342
|
+
def _write(self, s: str) -> None:
|
|
343
|
+
out = self.app.output
|
|
344
|
+
out.write_raw(s)
|
|
345
|
+
out.flush()
|
|
346
|
+
|
|
347
|
+
def _cursor_y(self) -> int:
|
|
348
|
+
try:
|
|
349
|
+
return self.app.renderer._cursor_pos.y
|
|
350
|
+
except Exception:
|
|
351
|
+
return -1
|
|
352
|
+
|
|
353
|
+
def _wipe(self) -> None:
|
|
354
|
+
"""Clear what was painted (before prompt_toolkit erases or the lane goes away)."""
|
|
355
|
+
d = self.drawn
|
|
356
|
+
if not d:
|
|
357
|
+
return
|
|
358
|
+
top, rows, indent, cells = d
|
|
359
|
+
up = self._cursor_y() - top
|
|
360
|
+
self.drawn = None
|
|
361
|
+
self.last_key = None
|
|
362
|
+
if up <= 0:
|
|
363
|
+
return
|
|
364
|
+
s = ["\x1b7", f"\x1b[{up}A\r"]
|
|
365
|
+
for i in range(rows):
|
|
366
|
+
s.append(f"\x1b[{indent + 1}G\x1b[{cells}X")
|
|
367
|
+
if i < rows - 1:
|
|
368
|
+
s.append("\x1b[1B")
|
|
369
|
+
s.append("\x1b8")
|
|
370
|
+
try:
|
|
371
|
+
self._write("".join(s))
|
|
372
|
+
except Exception:
|
|
373
|
+
pass
|
|
374
|
+
|
|
375
|
+
def _after(self, app) -> None:
|
|
376
|
+
if self.failed:
|
|
377
|
+
return
|
|
378
|
+
try:
|
|
379
|
+
self._paint()
|
|
380
|
+
except Exception:
|
|
381
|
+
self.failed = True # never break the prompt over a picture
|
|
382
|
+
self.ui.courier_hd_failed = True
|
|
383
|
+
|
|
384
|
+
def _paint(self) -> None:
|
|
385
|
+
lane = getattr(self.ui, "hd_lane", None)
|
|
386
|
+
if not lane:
|
|
387
|
+
if self.drawn:
|
|
388
|
+
self._wipe()
|
|
389
|
+
return
|
|
390
|
+
top, rows, indent = lane
|
|
391
|
+
up = self._cursor_y() - top
|
|
392
|
+
try:
|
|
393
|
+
screen = self.app.output.get_size().rows
|
|
394
|
+
except Exception:
|
|
395
|
+
screen = 0
|
|
396
|
+
if up <= 0 or (screen and up >= screen):
|
|
397
|
+
return # the lane is scrolled off the top: nothing safe to paint
|
|
398
|
+
now = time.monotonic()
|
|
399
|
+
items, key = lane_images(self.ui.barrow, now, rows)
|
|
400
|
+
cells = lane_cells(rows)
|
|
401
|
+
if key == self.last_key and self.drawn == (top, rows, indent, cells) and now - self.last_at < self.MIN_REDRAW:
|
|
402
|
+
return
|
|
403
|
+
s = ["\x1b7", f"\x1b[{up}A\r"]
|
|
404
|
+
for i in range(rows): # wipe the old picture
|
|
405
|
+
s.append(f"\x1b[{indent + 1}G\x1b[{cells}X")
|
|
406
|
+
if i < rows - 1:
|
|
407
|
+
s.append("\x1b[1B")
|
|
408
|
+
if rows > 1:
|
|
409
|
+
s.append(f"\x1b[{rows - 1}A")
|
|
410
|
+
for col, img in items:
|
|
411
|
+
s.append(f"\x1b[{indent + col + 1}G")
|
|
412
|
+
s.append(img.decode("ascii"))
|
|
413
|
+
s.append("\x1b8\x1b7" + f"\x1b[{up}A") # back to the lane's first row after each image
|
|
414
|
+
s.append("\x1b8")
|
|
415
|
+
self._write("".join(s))
|
|
416
|
+
self.drawn = (top, rows, indent, cells)
|
|
417
|
+
self.last_key = key
|
|
418
|
+
self.last_at = now
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def attach(ui, app) -> Overlay | None:
|
|
422
|
+
"""Hook the overlay onto a prompt_toolkit Application if HD is on."""
|
|
423
|
+
if not enabled():
|
|
424
|
+
return None
|
|
425
|
+
ov = Overlay(ui, app)
|
|
426
|
+
ui.courier_overlay = ov
|
|
427
|
+
try:
|
|
428
|
+
ui.barrow.speed = 2.5 # the lane is wider in pixels: walk, don't sprint
|
|
429
|
+
except Exception:
|
|
430
|
+
pass
|
|
431
|
+
return ov
|
|
Binary file
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Baked from the neon courier GIF by bake_hd.py: do not edit by hand.
|
|
2
|
+
|
|
3
|
+
SIZES[rows] = (sprite_w, sprite_h, pile_w, pile_h, (road_y, road_h), offset, length) into
|
|
4
|
+
courier_hd.bin; each slice is lzma of palette (255 rgb) + 8 x (full frame, empty-barrow
|
|
5
|
+
frame) + pile, one byte per pixel, index 255 = transparent.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
CELL_H = 20
|
|
9
|
+
FRAMES = 8
|
|
10
|
+
ROAD_RGB = (156, 87, 214)
|
|
11
|
+
|
|
12
|
+
SIZES = {
|
|
13
|
+
6: (140, 116, 53, 27, (118, 2), 0, 41508),
|
|
14
|
+
8: (187, 154, 70, 36, (157, 2), 41508, 64944),
|
|
15
|
+
10: (233, 193, 88, 46, (196, 3), 106452, 92812),
|
|
16
|
+
12: (280, 232, 105, 55, (235, 3), 199264, 122528),
|
|
17
|
+
14: (327, 270, 123, 64, (275, 4), 321792, 155372),
|
|
18
|
+
16: (373, 309, 141, 73, (314, 4), 477164, 195736),
|
|
19
|
+
}
|
forgecli/ghlogin.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Sign in to GitHub from inside Forge with GitHub's device flow, without the gh CLI: Forge shows a
|
|
2
|
+
short code, you enter it at github.com/login/device, and the token is saved in the key store
|
|
3
|
+
(Windows Credential Manager / macOS Keychain when available).
|
|
4
|
+
|
|
5
|
+
Needs a GitHub OAuth App's client id with device flow enabled: FORGE_GITHUB_CLIENT_ID, or the
|
|
6
|
+
CLIENT_ID constant below once the Forge project registers one. A client id is public; no secret is
|
|
7
|
+
used or needed."""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import time
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.parse
|
|
15
|
+
import urllib.request
|
|
16
|
+
|
|
17
|
+
CLIENT_ID = "" # set when the Forge OAuth App is registered
|
|
18
|
+
SCOPES = "repo read:org workflow"
|
|
19
|
+
CODE_URL = "https://github.com/login/device/code"
|
|
20
|
+
TOKEN_URL = "https://github.com/login/oauth/access_token"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def client_id() -> str:
|
|
24
|
+
return os.environ.get("FORGE_GITHUB_CLIENT_ID", "").strip() or CLIENT_ID
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _post(url: str, fields: dict, timeout: float = 15) -> dict:
|
|
28
|
+
req = urllib.request.Request(url, data=urllib.parse.urlencode(fields).encode(),
|
|
29
|
+
headers={"Accept": "application/json", "User-Agent": "forge-cli"})
|
|
30
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
31
|
+
return json.loads(r.read().decode("utf-8", "replace") or "{}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def start(cid: str, post=_post) -> dict:
|
|
35
|
+
"""Ask GitHub for a device code. Returns {device_code, user_code, verification_uri, interval,
|
|
36
|
+
expires_in} or raises RuntimeError with a plain message."""
|
|
37
|
+
data = post(CODE_URL, {"client_id": cid, "scope": SCOPES})
|
|
38
|
+
if "device_code" not in data:
|
|
39
|
+
raise RuntimeError(data.get("error_description") or data.get("error") or "GitHub refused the request")
|
|
40
|
+
return data
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def poll(cid: str, device: dict, post=_post, sleep=time.sleep, cancelled=lambda: False) -> str:
|
|
44
|
+
"""Wait for the user to approve; returns the access token or raises RuntimeError."""
|
|
45
|
+
interval = int(device.get("interval", 5) or 5)
|
|
46
|
+
deadline = time.time() + int(device.get("expires_in", 900) or 900)
|
|
47
|
+
while time.time() < deadline:
|
|
48
|
+
if cancelled():
|
|
49
|
+
raise RuntimeError("sign-in cancelled")
|
|
50
|
+
sleep(interval)
|
|
51
|
+
data = post(TOKEN_URL, {"client_id": cid, "device_code": device["device_code"],
|
|
52
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code"})
|
|
53
|
+
if data.get("access_token"):
|
|
54
|
+
return data["access_token"]
|
|
55
|
+
err = data.get("error", "")
|
|
56
|
+
if err == "authorization_pending":
|
|
57
|
+
continue
|
|
58
|
+
if err == "slow_down":
|
|
59
|
+
interval = int(data.get("interval", interval + 5))
|
|
60
|
+
continue
|
|
61
|
+
if err == "expired_token":
|
|
62
|
+
raise RuntimeError("the code expired; run /github login again")
|
|
63
|
+
if err == "access_denied":
|
|
64
|
+
raise RuntimeError("you declined access on GitHub")
|
|
65
|
+
raise RuntimeError(data.get("error_description") or err or "unexpected reply from GitHub")
|
|
66
|
+
raise RuntimeError("the code expired; run /github login again")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def whoami(token: str, timeout: float = 10) -> str:
|
|
70
|
+
req = urllib.request.Request("https://api.github.com/user", headers={
|
|
71
|
+
"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json", "User-Agent": "forge-cli"})
|
|
72
|
+
try:
|
|
73
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
74
|
+
return json.loads(r.read().decode("utf-8", "replace")).get("login", "")
|
|
75
|
+
except (urllib.error.URLError, OSError, ValueError):
|
|
76
|
+
return ""
|
forgecli/github.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""GitHub control (NEXT 9 foundation): sort REST calls by risk and check "PR opened"-style claims.
|
|
2
|
+
|
|
3
|
+
GitHub API credentials can come from GITHUB_TOKEN, the key store, or `gh auth login`; the token is
|
|
4
|
+
resolved only for the request and redacted from output. API and git/gh writes use the existing
|
|
5
|
+
approval policy, destructive operations always ask, and creation claims require returned URLs.
|
|
6
|
+
See HANDOFF.md NEXT 9 for workflow coverage and remaining limits.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
READ, WRITE, DESTRUCTIVE = "read", "write", "destructive"
|
|
13
|
+
|
|
14
|
+
# Always ask, even in auto mode (HANDOFF NEXT 9, item 3).
|
|
15
|
+
_DESTRUCTIVE = [
|
|
16
|
+
("DELETE", r".*"), # repos, branches, releases, comments…
|
|
17
|
+
("PUT", r"^/repos/[^/]+/[^/]+/pulls/\d+/merge$"), # merging a PR
|
|
18
|
+
("PATCH", r"^/repos/[^/]+/[^/]+$"), # repo settings / visibility / rename
|
|
19
|
+
("PUT", r"^/repos/[^/]+/[^/]+/collaborators/"),
|
|
20
|
+
("PUT", r"^/repos/[^/]+/[^/]+/actions/secrets/"),
|
|
21
|
+
("PUT", r"^/repos/[^/]+/[^/]+/branches/[^/]+/protection"),
|
|
22
|
+
("PATCH", r"^/repos/[^/]+/[^/]+/git/refs/heads/(main|master)$"), # moving the default branch
|
|
23
|
+
("POST", r"^/repos/[^/]+/[^/]+/transfer$"),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def classify(method: str, path: str) -> str:
|
|
28
|
+
method = method.upper()
|
|
29
|
+
path = "/" + re.sub(r"^https?://[^/]+", "", path).split("?", 1)[0].strip("/")
|
|
30
|
+
if method in ("GET", "HEAD", "OPTIONS"):
|
|
31
|
+
return READ
|
|
32
|
+
for m, rx in _DESTRUCTIVE:
|
|
33
|
+
if method == m and re.match(rx, path):
|
|
34
|
+
return DESTRUCTIVE
|
|
35
|
+
return WRITE
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def git_risk(command: str) -> str:
|
|
39
|
+
"""Same split for git/gh shell commands."""
|
|
40
|
+
c = command.strip()
|
|
41
|
+
if re.search(r"\bpush\b.*(--force|-f\b|--force-with-lease)|\bpush\b.*\b(main|master)\b"
|
|
42
|
+
r"|\bgh (repo delete|release delete|pr merge|secret set|repo edit)\b|\bbranch -D\b", c):
|
|
43
|
+
return DESTRUCTIVE
|
|
44
|
+
if re.search(r"\b(push|commit|gh (pr|issue) (create|comment|edit|close)|gh release create)\b", c):
|
|
45
|
+
return WRITE
|
|
46
|
+
return READ
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_PR_URL = re.compile(r"https://github\.com/[^/\s]+/[^/\s]+/(pull|issues)/\d+")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def pr_url_ok(answer_url: str, tool_outputs: list[str]) -> bool:
|
|
53
|
+
"""A claimed PR/issue URL counts only if a tool actually returned it."""
|
|
54
|
+
return bool(_PR_URL.fullmatch(answer_url.strip())) and any(answer_url.strip() in o for o in tool_outputs)
|