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/config.py ADDED
@@ -0,0 +1,194 @@
1
+ """Settings, .env loading and provider definitions."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import pathlib
6
+ import platform
7
+ from dataclasses import dataclass, field
8
+
9
+ APP_DIR = pathlib.Path(__file__).resolve().parent.parent
10
+ HOME_DIR = pathlib.Path.home() / ".forge"
11
+
12
+ # Built-in OpenAI-compatible providers: name -> (base_url, default model).
13
+ # Any other name works too if you set <NAME>_BASE_URL and <NAME>_MODEL.
14
+ KNOWN_PROVIDERS = {
15
+ "groq": ("https://api.groq.com/openai/v1", "openai/gpt-oss-120b"),
16
+ "cerebras": ("https://api.cerebras.ai/v1", "gpt-oss-120b"),
17
+ "nvidia": ("https://integrate.api.nvidia.com/v1", "minimaxai/minimax-m3.1-flash"),
18
+ "mistral": ("https://api.mistral.ai/v1", "mistral-medium-latest"),
19
+ "github": ("https://models.github.ai/inference", "openai/gpt-4.1"),
20
+ "sambanova": ("https://api.sambanova.ai/v1", "Meta-Llama-3.3-70B-Instruct"),
21
+ "huggingface": ("https://router.huggingface.co/v1", "openai/gpt-oss-120b"),
22
+ "together": ("https://api.together.xyz/v1", "meta-llama/Llama-3.3-70B-Instruct-Turbo"),
23
+ "cohere": ("https://api.cohere.ai/compatibility/v1", "command-a-03-2025"),
24
+ "deepseek": ("https://api.deepseek.com/v1", "deepseek-chat"),
25
+ "openrouter": ("https://openrouter.ai/api/v1", "nvidia/nemotron-3-ultra-550b-a55b:free"),
26
+ "openai": ("https://api.openai.com/v1", "gpt-4.1-mini"),
27
+ "gemini": ("https://generativelanguage.googleapis.com/v1beta/openai", "gemini-2.5-flash"),
28
+ # More free tiers (from github.com/mnfst/awesome-free-llm-apis); used once you add a key.
29
+ "zai": ("https://open.bigmodel.cn/api/paas/v4", "glm-4.7-flash"),
30
+ "gonka": ("https://proxy.gonkabroker.com/v1", "deepseek-ai/DeepSeek-V4-Flash-0731"),
31
+ "aion": ("https://api.aionlabs.ai/v1", "aion-labs/aion-3.0-mini"),
32
+ "modelscope": ("https://api-inference.modelscope.cn/v1", "Qwen/Qwen3.5-35B-A3B"),
33
+ "siliconflow": ("https://api.siliconflow.cn/v1", "Qwen/Qwen3-8B"),
34
+ # No key needed at all. Tried last, as a safety net when every keyed provider is resting.
35
+ # They are shared public gateways: FORGE_KEYLESS=0 turns them off.
36
+ "kilo": ("https://api.kilo.ai/api/gateway", "kilo-auto/free"),
37
+ "llm7": ("https://api.llm7.io/v1", "gpt-oss:20b"),
38
+ "ovh": ("https://oai.endpoints.kepler.ai.cloud.ovh.net/v1", "gpt-oss-120b"),
39
+ }
40
+
41
+ # Providers that work without a key (verified 2026-11-08 with tool calls), with extra models.
42
+ KEYLESS = {
43
+ "kilo": ["nvidia/nemotron-3.5-lightning:free", "nvidia/nemotron-3-ultra-550b-a55b:free"],
44
+ "llm7": [],
45
+ "ovh": ["Qwen3.5-397B-A17B"],
46
+ }
47
+ ANON_KEY = "anonymous" # placeholder key: no Authorization header is sent
48
+
49
+
50
+ def load_dotenv(path: pathlib.Path) -> None:
51
+ """Minimal .env reader. Real environment variables always win."""
52
+ if not path.is_file():
53
+ return
54
+ for line in path.read_text(encoding="utf-8").splitlines():
55
+ line = line.strip()
56
+ if not line or line.startswith("#") or "=" not in line:
57
+ continue
58
+ k, v = line.split("=", 1)
59
+ os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
60
+
61
+
62
+ @dataclass
63
+ class ProviderConfig:
64
+ name: str
65
+ base_url: str
66
+ model: str
67
+ keys: list[str]
68
+ models: list[str] = field(default_factory=list) # extra models on the same keys (<NAME>_MODELS)
69
+
70
+
71
+ @dataclass
72
+ class Settings:
73
+ providers: list[ProviderConfig] = field(default_factory=list)
74
+ max_tokens: int = 4096
75
+ temperature: float = 0.2
76
+ request_timeout: int = 30 # give up on a silent provider after this many seconds
77
+ stream: bool = True # show replies as they are written
78
+ fastest_first: bool = True # among equally capable models, try the fastest first
79
+ routing: str = "quality" # "quality": most capable model first, kept for the whole task; "speed": fastest first
80
+ auto_open: bool = True # open pages/servers a turn built in the browser
81
+ auto_skill: bool = True # load the skill a request needs automatically
82
+ keys_per_provider: int = 4 # cap pool attempts per provider per chain pass
83
+ retry_rounds: int = 3 # whole-chain retries (jittered backoff)
84
+ max_wait: int = 150 # longest to wait for a rate limit to clear before giving up
85
+ max_continuations: int = 3 # for replies cut off by max_tokens
86
+ max_steps: int = 40 # tool-use steps per user turn
87
+ guards: bool = True # check final answers' claims against what tools did (FORGE_GUARDS=0 turns off)
88
+ max_agents: int = 5 # parallel sub-agents at once
89
+ coordinator: str = "" # "provider:model" that plans and delegates in team mode
90
+ team: bool = False # start in team (coordinator) mode
91
+ context_chars: int = 80_000 # compact history beyond this (~20k tokens)
92
+ approval: str = "smart" # smart | ask | auto (see safety.py)
93
+ shell: str = "powershell"
94
+ auto_approve: bool = False
95
+ verbose: bool = False
96
+
97
+
98
+ def _int(name: str, default: int) -> int:
99
+ try:
100
+ return int(os.environ.get(name, default))
101
+ except ValueError:
102
+ return default
103
+
104
+
105
+ def load_settings() -> Settings:
106
+ # Only trusted locations: the app folder and ~/.forge. A .env in whatever
107
+ # project you are working in is NOT loaded, so a repo cannot redirect your
108
+ # keys to another server.
109
+ load_dotenv(APP_DIR / ".env")
110
+ load_dotenv(HOME_DIR / ".env")
111
+
112
+ s = Settings()
113
+ # Default: every built-in provider, in the order above. Ones without keys are skipped.
114
+ order = os.environ.get("FORGE_PROVIDERS") or ",".join(KNOWN_PROVIDERS)
115
+ names = [n.strip().lower() for n in order.split(",") if n.strip()]
116
+ keyless_on = os.environ.get("FORGE_KEYLESS", "1").strip().lower() not in ("0", "off", "false", "no")
117
+ # Keys added with /keys or /setup (NEXT 7): OS keychain or ~/.forge/keys.json, any number per
118
+ # provider, plus custom OpenAI-compatible providers. A broken store never stops startup.
119
+ stored: dict[str, list[str]] = {}
120
+ custom: dict[str, dict] = {}
121
+ try:
122
+ from . import keyvault
123
+ store = keyvault.KeyStore()
124
+ stored, custom = store.load(), store.custom()
125
+ except Exception:
126
+ pass
127
+ names += [n for n in list(stored) + list(custom) if n not in names and n not in KEYLESS]
128
+ if keyless_on:
129
+ names += [n for n in KEYLESS if n not in names] # always last unless you place them
130
+ for name in names:
131
+ up = name.upper()
132
+ base, model = KNOWN_PROVIDERS.get(name, ("", ""))
133
+ if name in custom:
134
+ base, model = custom[name].get("base_url", base), custom[name].get("model", model)
135
+ base = os.environ.get(f"{up}_BASE_URL", base).rstrip("/")
136
+ model = os.environ.get(f"{up}_MODEL", model)
137
+ raw = os.environ.get(f"{up}_API_KEYS") or os.environ.get(f"{up}_API_KEY", "")
138
+ keys = [k.strip() for k in raw.split(",") if k.strip()]
139
+ keys += [k for k in stored.get(name, []) if k not in keys]
140
+ extra = [m.strip() for m in os.environ.get(f"{up}_MODELS", "").split(",") if m.strip()]
141
+ if name in KEYLESS and not keys:
142
+ if not keyless_on:
143
+ continue
144
+ keys = [ANON_KEY]
145
+ extra = extra or list(KEYLESS[name])
146
+ if base and model and keys:
147
+ s.providers.append(ProviderConfig(name, base, model, keys, [m for m in extra if m != model]))
148
+
149
+ s.max_tokens = _int("FORGE_MAX_TOKENS", s.max_tokens)
150
+ s.request_timeout = _int("FORGE_TIMEOUT", s.request_timeout)
151
+ s.stream = os.environ.get("FORGE_STREAM", "1").strip().lower() not in ("0", "off", "false", "no")
152
+ s.auto_open = os.environ.get("FORGE_AUTO_OPEN", "1").strip().lower() not in ("0", "off", "false", "no")
153
+ s.auto_skill = os.environ.get("FORGE_AUTO_SKILL", "1").strip().lower() not in ("0", "off", "false", "no")
154
+ s.fastest_first = os.environ.get("FORGE_FASTEST_FIRST", "1").strip().lower() not in ("0", "off", "false", "no")
155
+ s.routing = "speed" if os.environ.get("FORGE_ROUTING", "").strip().lower() == "speed" else "quality"
156
+ s.keys_per_provider = _int("FORGE_KEYS_PER_PROVIDER", s.keys_per_provider)
157
+ s.retry_rounds = _int("FORGE_RETRY_ROUNDS", s.retry_rounds)
158
+ s.max_wait = _int("FORGE_MAX_WAIT", s.max_wait)
159
+ s.max_continuations = _int("FORGE_MAX_CONTINUATIONS", s.max_continuations)
160
+ s.max_steps = _int("FORGE_MAX_STEPS", s.max_steps)
161
+ s.guards = os.environ.get("FORGE_GUARDS", "1").strip().lower() not in ("0", "false", "no", "off")
162
+ s.max_agents = max(1, _int("FORGE_MAX_AGENTS", s.max_agents))
163
+ s.coordinator = os.environ.get("FORGE_COORDINATOR", "").strip()
164
+ s.team = os.environ.get("FORGE_TEAM", "").strip().lower() in ("1", "on", "true", "yes")
165
+ s.context_chars = _int("FORGE_CONTEXT_CHARS", s.context_chars)
166
+ s.approval = (os.environ.get("FORGE_APPROVAL") or s.approval).strip().lower()
167
+ default_shell = "powershell" if platform.system() == "Windows" else "bash"
168
+ s.shell = os.environ.get("FORGE_SHELL", default_shell).lower()
169
+ return s
170
+
171
+
172
+ # --- user preferences (chosen models, pinned provider) ----------------------
173
+ PREFS_FILE = HOME_DIR / "config.json"
174
+
175
+
176
+ def load_prefs() -> dict:
177
+ try:
178
+ import json
179
+ return json.loads(PREFS_FILE.read_text(encoding="utf-8"))
180
+ except (OSError, ValueError):
181
+ return {}
182
+
183
+
184
+ def save_prefs(prefs: dict) -> None:
185
+ import json
186
+ HOME_DIR.mkdir(parents=True, exist_ok=True)
187
+ PREFS_FILE.write_text(json.dumps(prefs, indent=2), encoding="utf-8")
188
+
189
+
190
+ def apply_prefs(settings: Settings, prefs: dict) -> None:
191
+ """Models picked with /model override the .env defaults."""
192
+ for p in settings.providers:
193
+ if p.name in prefs.get("models", {}):
194
+ p.model = prefs["models"][p.name]
forgecli/courier.py ADDED
@@ -0,0 +1,177 @@
1
+ """The neon courier: a pixel-art kid pushing a wheelbarrow, drawn with half-block characters.
2
+
3
+ It replaces the spinner while Forge works and acts out real agent events (the state machine is
4
+ city.Barrow): loads parcels at the pile while the model thinks, pushes them to the door when a tool
5
+ runs and waits there until it finishes, unloads, walks back empty. Waiting on rate limits he stands
6
+ dimmed; done turns the door green with a tick; a failure spills the parcels and turns it red.
7
+
8
+ The frames come from courier_data.py (baked from the GIF at several sizes); the size follows the
9
+ terminal: taller windows get a bigger, sharper courier. FORGE_COURIER=off falls back to the
10
+ one-line wheelbarrow; FORGE_COURIER_ROWS=4..12 fixes the size.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import os
15
+
16
+ from rich.text import Text
17
+
18
+ from .courier_data import ALPHA, SIZES
19
+
20
+ # Parcels keep their own amber/cyan colours, like the original art. Set a kind here to recolour.
21
+ KIND_RGB: dict = {}
22
+ GROUND = (74, 52, 128)
23
+ DOOR = (156, 60, 140)
24
+ DOOR_LIT = (255, 79, 216)
25
+ OK = (125, 255, 154)
26
+ BAD = (255, 95, 109)
27
+ STEP_PX = 2.0 # pixels walked per animation frame, so the legs match the ground speed
28
+
29
+ _cache: dict = {}
30
+
31
+
32
+ def _hex(c) -> str:
33
+ return "#%02x%02x%02x" % c
34
+
35
+
36
+ def _rgb(h: str):
37
+ return int(h[1:3], 16), int(h[3:5], 16), int(h[5:7], 16)
38
+
39
+
40
+ def frames(rows: int):
41
+ """(w, h, [frame pixels], [cargo masks]) for a size; pixels are rgb tuples or None."""
42
+ got = _cache.get(rows)
43
+ if got:
44
+ return got
45
+ w, h, palette, fr, cargo = SIZES[rows]
46
+ pal = [_rgb(p) for p in palette]
47
+ idx = {ch: i for i, ch in enumerate(ALPHA)}
48
+ px = [[[None if ch == " " else pal[idx[ch]] for ch in row] for row in f] for f in fr]
49
+ masks = [[[ch == "1" for ch in row] for row in m] for m in cargo]
50
+ _cache[rows] = got = (w, h, px, masks)
51
+ return got
52
+
53
+
54
+ def pick_rows(term_h: int, term_w: int) -> int:
55
+ """Courier size for this window: as big as fits without crowding the conversation."""
56
+ env = os.environ.get("FORGE_COURIER_ROWS", "")
57
+ if env.isdigit():
58
+ return max(min(SIZES), min(max(SIZES), int(env)))
59
+ rows = 5 if term_h < 24 else 6 if term_h < 30 else 8 if term_h < 40 else 10 if term_h < 52 else 12
60
+ while rows > min(SIZES) and SIZES[rows][0] * 3 + 40 > term_w: # lane plus the status text
61
+ rows -= 1
62
+ return rows
63
+
64
+
65
+ def enabled() -> bool:
66
+ return os.environ.get("FORGE_COURIER", "on").lower() not in ("off", "0", "no", "false")
67
+
68
+
69
+ def _tint(c, kind_rgb, a: float = 1.0):
70
+ lum = (0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2]) / 255
71
+ k = min(1.0, 0.25 + lum * 1.25) * a
72
+ return tuple(min(255, int(v * k)) for v in kind_rgb)
73
+
74
+
75
+ def scene(barrow, now: float, rows: int) -> list[Text]:
76
+ """The lane (pile, courier, door) as `rows` lines of rich Text."""
77
+ snap = barrow.snapshot(now)
78
+ w, h, px, masks = frames(rows)
79
+ travel = w + 4 # how far he walks: a bit more than his length
80
+ pile_w, door_w = 4, 3
81
+ lane_w = pile_w + 1 + travel + w + 1 + door_w
82
+ grid = [[None] * lane_w for _ in range(h)]
83
+
84
+ st = snap["state"]
85
+ x0 = pile_w + 1 + round(snap["pos"] / snap["track"] * travel)
86
+ moving = snap["moving"]
87
+ f = int(snap["walked"] * travel / snap["track"] / STEP_PX) % len(px) if moving else 0
88
+ sprite, mask = px[f], masks[f]
89
+ dim = 0.55 if st in ("waiting", "cancelled") else 1.0
90
+ kind_rgb = KIND_RGB.get(snap["kind"])
91
+ fill = snap["fill"] # 0..1: how much of the load is in the tub
92
+ cargo_rows = [y for y in range(h) if any(mask[y])]
93
+ top = cargo_rows[0] if cargo_rows else 0
94
+ span = (cargo_rows[-1] - top + 1) if cargo_rows else 1
95
+ show_from = top + span - span * fill # parcels appear from the bottom up
96
+
97
+ # ground: a dashed neon line along the whole lane
98
+ for x in range(lane_w):
99
+ if (x // 2) % 3 != 2:
100
+ grid[h - 1][x] = GROUND
101
+ # the pile of parcels at the left
102
+ pile_rgb = kind_rgb or (255, 150, 60)
103
+ for (px_, py_, bw, bh) in ((0, h - 5, 3, 3), (1, h - 8, 3, 3)):
104
+ for yy in range(max(0, py_), min(h - 1, py_ + bh)):
105
+ for xx in range(px_, min(pile_w, px_ + bw)):
106
+ edge = yy == py_ or xx == px_
107
+ grid[yy][xx] = _tint((255, 200, 120) if edge else (200, 110, 40), pile_rgb, 0.9 * dim)
108
+ # the door at the right
109
+ door_x = lane_w - door_w
110
+ door_h = max(4, int(h * 0.7))
111
+ lit = snap["at_door"] and (snap["busy"] or snap["parked"])
112
+ pulse = (int(now * 3) % 2 == 0) or not snap["motion"]
113
+ door = OK if st == "completed" else BAD if st == "failed" else (DOOR_LIT if lit and pulse else DOOR)
114
+ for yy in range(h - 1 - door_h, h - 1):
115
+ for xx in range(door_x, lane_w):
116
+ if yy == h - 1 - door_h or xx in (door_x, lane_w - 1):
117
+ grid[yy][xx] = door
118
+ if st == "completed": # a tick in the doorway
119
+ cx, cy = door_x + 1, h - 3
120
+ for (dx, dy) in ((-1, -1), (0, 0), (1, -1), (2, -2)):
121
+ if 0 <= cy + dy < h and 0 <= cx + dx < lane_w:
122
+ grid[cy + dy][cx + dx] = OK
123
+ # the courier (mirrored when walking back)
124
+ flip = not snap["right"]
125
+ for y in range(h):
126
+ row, mrow = sprite[y], mask[y]
127
+ for x in range(w):
128
+ sx = w - 1 - x if flip else x
129
+ c = row[sx]
130
+ if c is None:
131
+ continue
132
+ if mrow[sx]:
133
+ if st == "failed" or y < show_from:
134
+ continue
135
+ if kind_rgb:
136
+ c = _tint(c, kind_rgb)
137
+ if dim < 1:
138
+ c = tuple(int(v * dim) for v in c)
139
+ gx = x0 + x
140
+ if 0 <= gx < lane_w:
141
+ grid[y][gx] = c
142
+ if st == "failed": # spilled parcels on the ground behind him
143
+ for i, xx in enumerate(range(x0 - 3, x0 + w + 3, 4)):
144
+ if 0 <= xx < lane_w - door_w:
145
+ grid[h - 2][xx] = BAD if i % 2 else (255, 150, 60)
146
+ if st == "waiting" and snap["motion"]: # a little z drifting up
147
+ zx, zy = x0 + w // 3, max(0, 1 - int(now * 2) % 2)
148
+ for xx in range(zx, zx + 3):
149
+ if 0 <= xx < lane_w:
150
+ grid[zy][xx] = (160, 150, 220)
151
+ return _to_text(grid, lane_w, h)
152
+
153
+
154
+ def _to_text(grid, w: int, h: int) -> list[Text]:
155
+ lines = []
156
+ styles: dict = {}
157
+ for y in range(0, h - 1, 2):
158
+ t = Text()
159
+ top, bot = grid[y], grid[y + 1]
160
+ for x in range(w):
161
+ a, b = top[x], bot[x]
162
+ if a is None and b is None:
163
+ t.append(" ")
164
+ continue
165
+ key = (a, b)
166
+ st = styles.get(key)
167
+ if st is None:
168
+ if a is not None and b is not None:
169
+ st = (_hex(a) + " on " + _hex(b), "▀") if a != b else (_hex(a), "█")
170
+ elif a is not None:
171
+ st = (_hex(a), "▀")
172
+ else:
173
+ st = (_hex(b), "▄")
174
+ styles[key] = st
175
+ t.append(st[1], style=st[0])
176
+ lines.append(t)
177
+ return lines