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/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
|