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.
Files changed (113) hide show
  1. forgecli/__init__.py +2 -0
  2. forgecli/ads.py +322 -0
  3. forgecli/agent.py +1093 -0
  4. forgecli/art.py +574 -0
  5. forgecli/attach.py +246 -0
  6. forgecli/brief.py +215 -0
  7. forgecli/browsercheck.py +99 -0
  8. forgecli/checkpoints.py +81 -0
  9. forgecli/city.py +833 -0
  10. forgecli/cli.py +1668 -0
  11. forgecli/config.py +194 -0
  12. forgecli/courier.py +177 -0
  13. forgecli/courier_data.py +164 -0
  14. forgecli/courier_hd.py +431 -0
  15. forgecli/courier_hd_data.bin +0 -0
  16. forgecli/courier_hd_data.py +19 -0
  17. forgecli/ghlogin.py +76 -0
  18. forgecli/github.py +54 -0
  19. forgecli/guards.py +403 -0
  20. forgecli/hooks.py +50 -0
  21. forgecli/httpapi.py +434 -0
  22. forgecli/images.py +110 -0
  23. forgecli/keys.py +152 -0
  24. forgecli/keyvault.py +320 -0
  25. forgecli/mcp.py +674 -0
  26. forgecli/memory.py +42 -0
  27. forgecli/openapi.py +331 -0
  28. forgecli/parallel.py +308 -0
  29. forgecli/prices.py +72 -0
  30. forgecli/procs.py +144 -0
  31. forgecli/providers.py +758 -0
  32. forgecli/quality.py +192 -0
  33. forgecli/repomap.py +78 -0
  34. forgecli/roster.py +227 -0
  35. forgecli/safety.py +93 -0
  36. forgecli/sandbox.py +83 -0
  37. forgecli/sessions.py +97 -0
  38. forgecli/skills.py +446 -0
  39. forgecli/tools.py +1184 -0
  40. forgecli/ui.py +860 -0
  41. forgecli/worktrees.py +79 -0
  42. forgecommandline-0.8.0.data/data/skills/commit/SKILL.md +14 -0
  43. forgecommandline-0.8.0.data/data/skills/debug/SKILL.md +17 -0
  44. forgecommandline-0.8.0.data/data/skills/explain/SKILL.md +14 -0
  45. forgecommandline-0.8.0.data/data/skills/init/SKILL.md +19 -0
  46. forgecommandline-0.8.0.data/data/skills/library/LICENSES/agent-skills-MIT.txt +21 -0
  47. forgecommandline-0.8.0.data/data/skills/library/LICENSES/wondelai-MIT.txt +23 -0
  48. forgecommandline-0.8.0.data/data/skills/library/README.md +9 -0
  49. forgecommandline-0.8.0.data/data/skills/library/_references/accessibility-checklist.md +160 -0
  50. forgecommandline-0.8.0.data/data/skills/library/_references/definition-of-done.md +67 -0
  51. forgecommandline-0.8.0.data/data/skills/library/_references/observability-checklist.md +92 -0
  52. forgecommandline-0.8.0.data/data/skills/library/_references/orchestration-patterns.md +370 -0
  53. forgecommandline-0.8.0.data/data/skills/library/_references/performance-checklist.md +238 -0
  54. forgecommandline-0.8.0.data/data/skills/library/_references/security-checklist.md +248 -0
  55. forgecommandline-0.8.0.data/data/skills/library/_references/testing-patterns.md +235 -0
  56. forgecommandline-0.8.0.data/data/skills/library/api-and-interface-design/SKILL.md +367 -0
  57. forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/SKILL.md +279 -0
  58. forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/references/artifact-templates.md +157 -0
  59. forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/references/methods.md +87 -0
  60. forgecommandline-0.8.0.data/data/skills/library/awwwards-tier-web-application/SKILL.md +877 -0
  61. forgecommandline-0.8.0.data/data/skills/library/browser-testing-with-devtools/SKILL.md +317 -0
  62. forgecommandline-0.8.0.data/data/skills/library/ci-cd-and-automation/SKILL.md +390 -0
  63. forgecommandline-0.8.0.data/data/skills/library/code-review-and-quality/SKILL.md +398 -0
  64. forgecommandline-0.8.0.data/data/skills/library/code-simplification/SKILL.md +331 -0
  65. forgecommandline-0.8.0.data/data/skills/library/constraint-driven-development/SKILL.md +311 -0
  66. forgecommandline-0.8.0.data/data/skills/library/constraint-driven-development/references/floor-guard.md +182 -0
  67. forgecommandline-0.8.0.data/data/skills/library/context-engineering/SKILL.md +353 -0
  68. forgecommandline-0.8.0.data/data/skills/library/debugging-and-error-recovery/SKILL.md +300 -0
  69. forgecommandline-0.8.0.data/data/skills/library/deprecation-and-migration/SKILL.md +247 -0
  70. forgecommandline-0.8.0.data/data/skills/library/documentation-and-adrs/SKILL.md +297 -0
  71. forgecommandline-0.8.0.data/data/skills/library/doubt-driven-development/SKILL.md +243 -0
  72. forgecommandline-0.8.0.data/data/skills/library/frontend-ui-engineering/SKILL.md +340 -0
  73. forgecommandline-0.8.0.data/data/skills/library/git-workflow-and-versioning/SKILL.md +355 -0
  74. forgecommandline-0.8.0.data/data/skills/library/idea-refine/SKILL.md +193 -0
  75. forgecommandline-0.8.0.data/data/skills/library/idea-refine/examples.md +238 -0
  76. forgecommandline-0.8.0.data/data/skills/library/idea-refine/frameworks.md +99 -0
  77. forgecommandline-0.8.0.data/data/skills/library/idea-refine/refinement-criteria.md +113 -0
  78. forgecommandline-0.8.0.data/data/skills/library/idea-refine/scripts/idea-refine.sh +15 -0
  79. forgecommandline-0.8.0.data/data/skills/library/improve-code-quality/SKILL.md +275 -0
  80. forgecommandline-0.8.0.data/data/skills/library/improve-code-quality/references/artifact-templates.md +101 -0
  81. forgecommandline-0.8.0.data/data/skills/library/incremental-implementation/SKILL.md +249 -0
  82. forgecommandline-0.8.0.data/data/skills/library/interview-me/SKILL.md +235 -0
  83. forgecommandline-0.8.0.data/data/skills/library/observability-and-instrumentation/SKILL.md +238 -0
  84. forgecommandline-0.8.0.data/data/skills/library/performance-optimization/SKILL.md +267 -0
  85. forgecommandline-0.8.0.data/data/skills/library/performance-optimization/references/optimization-patterns.md +250 -0
  86. forgecommandline-0.8.0.data/data/skills/library/planning-and-task-breakdown/SKILL.md +257 -0
  87. forgecommandline-0.8.0.data/data/skills/library/remove-technical-debt/SKILL.md +203 -0
  88. forgecommandline-0.8.0.data/data/skills/library/remove-technical-debt/references/artifact-templates.md +79 -0
  89. forgecommandline-0.8.0.data/data/skills/library/security-and-hardening/SKILL.md +216 -0
  90. forgecommandline-0.8.0.data/data/skills/library/security-and-hardening/references/hardening-patterns.md +324 -0
  91. forgecommandline-0.8.0.data/data/skills/library/shipping-and-launch/SKILL.md +336 -0
  92. forgecommandline-0.8.0.data/data/skills/library/source-driven-development/SKILL.md +216 -0
  93. forgecommandline-0.8.0.data/data/skills/library/spec-driven-development/SKILL.md +259 -0
  94. forgecommandline-0.8.0.data/data/skills/library/system-design/SKILL.md +207 -0
  95. forgecommandline-0.8.0.data/data/skills/library/system-design/references/building-blocks.md +272 -0
  96. forgecommandline-0.8.0.data/data/skills/library/system-design/references/common-designs.md +257 -0
  97. forgecommandline-0.8.0.data/data/skills/library/system-design/references/database-scaling.md +295 -0
  98. forgecommandline-0.8.0.data/data/skills/library/system-design/references/estimation-numbers.md +250 -0
  99. forgecommandline-0.8.0.data/data/skills/library/system-design/references/four-step-process.md +286 -0
  100. forgecommandline-0.8.0.data/data/skills/library/system-design/references/reliability-operations.md +373 -0
  101. forgecommandline-0.8.0.data/data/skills/library/test-driven-development/SKILL.md +398 -0
  102. forgecommandline-0.8.0.data/data/skills/library/using-agent-skills/SKILL.md +192 -0
  103. forgecommandline-0.8.0.data/data/skills/review/SKILL.md +16 -0
  104. forgecommandline-0.8.0.data/data/skills/run/SKILL.md +17 -0
  105. forgecommandline-0.8.0.data/data/skills/security-review/SKILL.md +20 -0
  106. forgecommandline-0.8.0.data/data/skills/simplify/SKILL.md +18 -0
  107. forgecommandline-0.8.0.data/data/skills/test/SKILL.md +16 -0
  108. forgecommandline-0.8.0.data/data/skills/web-build/SKILL.md +25 -0
  109. forgecommandline-0.8.0.dist-info/METADATA +357 -0
  110. forgecommandline-0.8.0.dist-info/RECORD +113 -0
  111. forgecommandline-0.8.0.dist-info/WHEEL +5 -0
  112. forgecommandline-0.8.0.dist-info/entry_points.txt +2 -0
  113. 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)