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/quality.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""How capable each model is at building things, scored 1-10, worked out automatically.
|
|
2
|
+
|
|
3
|
+
Routing sends the work to the highest-scoring model that is available; speed only breaks ties.
|
|
4
|
+
Nobody has to maintain a list: any model a user adds gets a score from three layers.
|
|
5
|
+
|
|
6
|
+
1. A first guess from the name: known model families, then the parameter count in the id
|
|
7
|
+
("70b", "397B-A17B"), lowered for "mini", "flash", "nano" and similar light versions.
|
|
8
|
+
2. A quick automatic test (roster.bench_one): a small reasoning check and a tool-call check,
|
|
9
|
+
run once in the background for models that haven't had one. Failing the tool call matters
|
|
10
|
+
most: a model that can't call tools can't edit files or run commands.
|
|
11
|
+
3. Its track record in real work: empty or junk replies, broken tool calls, claims the check
|
|
12
|
+
caught, getting stuck, versus turns that finished cleanly. Recorded per model and kept.
|
|
13
|
+
|
|
14
|
+
Override any score in ~/.forge/model_quality.json: {"gpt-oss-120b": 9, "groq:openai/gpt-oss-120b": 7}
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
|
|
23
|
+
from .config import HOME_DIR
|
|
24
|
+
|
|
25
|
+
OVERRIDES_FILE = HOME_DIR / "model_quality.json"
|
|
26
|
+
RECORD_FILE = HOME_DIR / "model_record.json"
|
|
27
|
+
STATS_FILE = HOME_DIR / "roster_stats.json" # written by roster.bench
|
|
28
|
+
BENCH_EVERY = 14 * 86400 # re-test a model after two weeks
|
|
29
|
+
RECORD_SIZE = 40 # outcomes kept per model
|
|
30
|
+
MIN_RECORD = 6 # outcomes needed before the record counts
|
|
31
|
+
|
|
32
|
+
# Frontier families whose names don't say how big they are. First match wins.
|
|
33
|
+
FAMILIES = [
|
|
34
|
+
(r"claude-(opus|sonnet)|gpt-5(?![\w.-]*(mini|nano))|gemini-[2-9][.\d]*-pro|grok-4|kimi-k2"
|
|
35
|
+
r"|qwen3-coder-480|qwen3\.5-397|nemotron-3-ultra|deepseek-(v[3-9]|r1)(?!.*(flash|lite|distill))"
|
|
36
|
+
r"|glm-[4-9][.\d]*(?![\w.-]*(flash|air))|o[34](-pro)?$", 10),
|
|
37
|
+
(r"claude-haiku|gpt-4\.1(?!-nano)|gpt-4o(?!-mini)|gemini-[2-9][.\d]*-flash(?!-lite)|gpt-5[\w.-]*mini"
|
|
38
|
+
r"|mistral-(large|medium)|command-a", 8),
|
|
39
|
+
]
|
|
40
|
+
FAMILIES.insert(1, (r"minimax-m\d|deepseek-v[3-9]|qwen3[.\d]*-(max|plus)|kimi", 9))
|
|
41
|
+
# Light versions, matched as whole words so "minimax" and "gemini" don't count as "mini".
|
|
42
|
+
LIGHT = [(r"(?<![a-z])(nano|tiny|e[1-4]b)(?![a-z])", -3),
|
|
43
|
+
(r"(?<![a-z])(mini|lite|small|bolt|air)(?![a-z])", -2),
|
|
44
|
+
(r"(?<![a-z])(flash|lightning|turbo|haiku|instant|fast)(?![a-z])", -1)]
|
|
45
|
+
SIZE_TIERS = [(300, 9), (100, 8), (30, 7), (13, 6), (7, 5), (3, 4), (0, 3)]
|
|
46
|
+
_SIZE = re.compile(r"(?<![a-z])(\d+(?:\.\d+)?)\s*b(?![a-z])", re.I)
|
|
47
|
+
|
|
48
|
+
_lock = threading.Lock()
|
|
49
|
+
_cache: dict = {"at": 0.0, "over": {}, "stats": {}, "record": {}}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _load(path) -> dict:
|
|
53
|
+
try:
|
|
54
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
55
|
+
return data if isinstance(data, dict) else {}
|
|
56
|
+
except (OSError, ValueError):
|
|
57
|
+
return {}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _fresh() -> dict:
|
|
61
|
+
with _lock:
|
|
62
|
+
if time.time() - _cache["at"] > 30:
|
|
63
|
+
over = {}
|
|
64
|
+
for k, v in _load(OVERRIDES_FILE).items():
|
|
65
|
+
try:
|
|
66
|
+
over[str(k).lower()] = int(v)
|
|
67
|
+
except (TypeError, ValueError):
|
|
68
|
+
pass
|
|
69
|
+
_cache.update(at=time.time(), over=over, stats=_load(STATS_FILE),
|
|
70
|
+
record=_load(RECORD_FILE) or _cache["record"])
|
|
71
|
+
return _cache
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def size_of(model: str) -> float:
|
|
75
|
+
"""Total parameters in billions, from the model id (0 when it doesn't say)."""
|
|
76
|
+
sizes = [float(n) for n in _SIZE.findall(model.split("/")[-1].replace("_", "-"))]
|
|
77
|
+
sizes = [s for s in sizes if s >= 0.1]
|
|
78
|
+
return max(sizes) if sizes else 0.0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def guess(model: str) -> int:
|
|
82
|
+
"""Layer 1: a first guess from the model id alone."""
|
|
83
|
+
m = model.lower()
|
|
84
|
+
for pattern, score in FAMILIES:
|
|
85
|
+
if re.search(pattern, m):
|
|
86
|
+
base = score
|
|
87
|
+
break
|
|
88
|
+
else:
|
|
89
|
+
size = size_of(m)
|
|
90
|
+
if size:
|
|
91
|
+
base = next(score for floor, score in SIZE_TIERS if size >= floor)
|
|
92
|
+
elif re.search(r"auto|free$", m.split("/")[-1]) and "/" not in m.rstrip("/").split("/")[-1]:
|
|
93
|
+
base = 5 # a router's "auto" model: could be anything
|
|
94
|
+
else:
|
|
95
|
+
base = 6 # an unknown name is usually a mid-sized current model
|
|
96
|
+
for pattern, cut in LIGHT:
|
|
97
|
+
if re.search(pattern, m):
|
|
98
|
+
return max(1, base + cut)
|
|
99
|
+
return base
|
|
100
|
+
for pattern, cut in LIGHT:
|
|
101
|
+
if re.search(pattern, m):
|
|
102
|
+
return max(1, base + max(cut, -2))
|
|
103
|
+
return base
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def tested(provider: str, model: str) -> int:
|
|
107
|
+
"""Layer 2: adjustment from the automatic test (0 when not tested yet)."""
|
|
108
|
+
s = _fresh()["stats"].get(f"{provider}:{model}") or {}
|
|
109
|
+
if not s.get("up"):
|
|
110
|
+
return 0
|
|
111
|
+
return (-3 if s.get("tools") is False else 0) + (-1 if s.get("check") is False else 0)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def track(provider: str, model: str) -> int:
|
|
115
|
+
"""Layer 3: adjustment from how the model has done in real work."""
|
|
116
|
+
rec = _fresh()["record"].get(f"{provider}:{model}") or []
|
|
117
|
+
if len(rec) < MIN_RECORD:
|
|
118
|
+
return 0
|
|
119
|
+
rate = sum(1 for x in rec if x) / len(rec)
|
|
120
|
+
return 1 if rate >= 0.9 else 0 if rate >= 0.7 else -1 if rate >= 0.5 else -2
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def score(model: str, provider: str = "") -> int:
|
|
124
|
+
over = _fresh()["over"]
|
|
125
|
+
m = model.lower()
|
|
126
|
+
for key in (f"{provider}:{m}", m):
|
|
127
|
+
if key in over:
|
|
128
|
+
return max(1, min(10, over[key]))
|
|
129
|
+
return max(1, min(10, guess(model) + tested(provider, model) + track(provider, model)))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def explain(model: str, provider: str = "") -> str:
|
|
133
|
+
"""A short "why" for /roster."""
|
|
134
|
+
over = _fresh()["over"]
|
|
135
|
+
if f"{provider}:{model.lower()}" in over or model.lower() in over:
|
|
136
|
+
return "set in model_quality.json"
|
|
137
|
+
parts = [f"name {guess(model)}"]
|
|
138
|
+
t, k = tested(provider, model), track(provider, model)
|
|
139
|
+
if t:
|
|
140
|
+
parts.append(f"test {t:+d}")
|
|
141
|
+
if k:
|
|
142
|
+
parts.append(f"record {k:+d}")
|
|
143
|
+
n = len(_fresh()["record"].get(f"{provider}:{model}") or [])
|
|
144
|
+
if n:
|
|
145
|
+
parts.append(f"{n} graded")
|
|
146
|
+
return " · ".join(parts)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def record(provider: str, model: str, good: bool) -> None:
|
|
150
|
+
"""Remember one outcome of real work (kept across sessions)."""
|
|
151
|
+
if not provider or not model:
|
|
152
|
+
return
|
|
153
|
+
c = _fresh()
|
|
154
|
+
with _lock:
|
|
155
|
+
rec = c["record"].setdefault(f"{provider}:{model}", [])
|
|
156
|
+
rec.append(bool(good))
|
|
157
|
+
del rec[:-RECORD_SIZE]
|
|
158
|
+
try:
|
|
159
|
+
HOME_DIR.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
RECORD_FILE.write_text(json.dumps(c["record"]), encoding="utf-8")
|
|
161
|
+
except OSError:
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def untested(router) -> list:
|
|
166
|
+
"""Workers with no recent automatic test."""
|
|
167
|
+
from . import roster
|
|
168
|
+
stats = _fresh()["stats"]
|
|
169
|
+
now = time.time()
|
|
170
|
+
return [w for w in roster.build(router)
|
|
171
|
+
if now - (stats.get(w.id) or {}).get("when", 0) > BENCH_EVERY]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def auto_bench(router, on_done=None) -> threading.Thread | None:
|
|
175
|
+
"""Test new models in the background (two tiny requests each), so their scores are measured."""
|
|
176
|
+
todo = untested(router)
|
|
177
|
+
if not todo:
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
def run():
|
|
181
|
+
from . import roster
|
|
182
|
+
try:
|
|
183
|
+
roster.bench(router, todo)
|
|
184
|
+
except Exception:
|
|
185
|
+
return
|
|
186
|
+
with _lock:
|
|
187
|
+
_cache["at"] = 0.0
|
|
188
|
+
if on_done:
|
|
189
|
+
on_done(len(todo))
|
|
190
|
+
t = threading.Thread(target=run, daemon=True, name="forge-auto-bench")
|
|
191
|
+
t.start()
|
|
192
|
+
return t
|
forgecli/repomap.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Bounded, dependency-free repository symbol map."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import pathlib
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", ".mypy_cache",
|
|
10
|
+
".next", "dist", "build", "target", "bin", "obj"}
|
|
11
|
+
EXTENSIONS = {
|
|
12
|
+
".py", ".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".go", ".rs", ".java", ".kt",
|
|
13
|
+
".c", ".cc", ".cpp", ".h", ".hpp", ".cs", ".rb", ".php", ".swift",
|
|
14
|
+
}
|
|
15
|
+
DECLARATION = re.compile(
|
|
16
|
+
r"^\s*(?:(?:export|pub|public|private|protected|static|abstract|async|default|final)\s+)*"
|
|
17
|
+
r"(?:"
|
|
18
|
+
r"(?:async\s+)?(?:def|function|class|interface|enum|struct|trait|type|fn|func|record)\s+"
|
|
19
|
+
r"([A-Za-z_$][\w$]*)"
|
|
20
|
+
r"|(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>"
|
|
21
|
+
r")"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build(root: pathlib.Path, max_files: int = 1500, max_symbols: int = 250,
|
|
26
|
+
max_dirs: int = 5000, max_seconds: float = 4.0) -> str:
|
|
27
|
+
root = root.resolve()
|
|
28
|
+
if not root.is_dir():
|
|
29
|
+
return f"Error: repository folder not found: {root}"
|
|
30
|
+
|
|
31
|
+
started = time.monotonic()
|
|
32
|
+
dirs_seen, files_seen, bytes_seen = 0, 0, 0
|
|
33
|
+
symbols: list[str] = []
|
|
34
|
+
limited = False
|
|
35
|
+
for current, dirs, names in os.walk(root):
|
|
36
|
+
dirs_seen += 1
|
|
37
|
+
if dirs_seen > max_dirs or time.monotonic() - started > max_seconds:
|
|
38
|
+
limited = True
|
|
39
|
+
break
|
|
40
|
+
dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS and not d.startswith("."))
|
|
41
|
+
for name in sorted(names):
|
|
42
|
+
file_path = pathlib.Path(current, name)
|
|
43
|
+
if file_path.suffix.lower() not in EXTENSIONS:
|
|
44
|
+
continue
|
|
45
|
+
files_seen += 1
|
|
46
|
+
if files_seen > max_files or time.monotonic() - started > max_seconds:
|
|
47
|
+
limited = True
|
|
48
|
+
break
|
|
49
|
+
try:
|
|
50
|
+
size = file_path.stat().st_size
|
|
51
|
+
if size > 1_000_000 or bytes_seen + size > 12_000_000:
|
|
52
|
+
continue
|
|
53
|
+
bytes_seen += size
|
|
54
|
+
content = file_path.read_text(encoding="utf-8", errors="replace")
|
|
55
|
+
except OSError:
|
|
56
|
+
continue
|
|
57
|
+
relative = file_path.relative_to(root).as_posix()
|
|
58
|
+
for line_number, line in enumerate(content.splitlines(), 1):
|
|
59
|
+
match = DECLARATION.match(line)
|
|
60
|
+
if match:
|
|
61
|
+
symbol = match.group(1) or match.group(2)
|
|
62
|
+
symbols.append(f"{relative}:{line_number} {symbol}")
|
|
63
|
+
if len(symbols) >= max_symbols:
|
|
64
|
+
limited = True
|
|
65
|
+
break
|
|
66
|
+
if limited:
|
|
67
|
+
break
|
|
68
|
+
if limited:
|
|
69
|
+
break
|
|
70
|
+
|
|
71
|
+
if not symbols:
|
|
72
|
+
summary = f"No symbols found in {files_seen} source files."
|
|
73
|
+
else:
|
|
74
|
+
summary = f"Repository map ({len(symbols)} symbols from {files_seen} source files):\n"
|
|
75
|
+
summary += "\n".join(symbols)
|
|
76
|
+
if limited:
|
|
77
|
+
summary += "\n… map truncated at a safety limit; narrow the folder or inspect specific files."
|
|
78
|
+
return summary
|
forgecli/roster.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""The roster: every model Forge can use, and what each one is good at.
|
|
2
|
+
|
|
3
|
+
Three layers, later ones win:
|
|
4
|
+
1. built-in profiles below (a starting guess from each model family's reputation)
|
|
5
|
+
2. your edits in ~/.forge/roster.json
|
|
6
|
+
3. measurements from /roster bench (speed, tool calling, a small reasoning check)
|
|
7
|
+
|
|
8
|
+
The coordinator (team mode) reads this to decide which model gets which task."""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
|
|
17
|
+
from .config import HOME_DIR
|
|
18
|
+
|
|
19
|
+
ROSTER_FILE = HOME_DIR / "roster.json"
|
|
20
|
+
STATS_FILE = HOME_DIR / "roster_stats.json"
|
|
21
|
+
|
|
22
|
+
STRENGTHS = ["coding", "reasoning", "math", "tool-use", "fast", "vision", "long-context",
|
|
23
|
+
"writing", "multilingual", "review", "general"]
|
|
24
|
+
|
|
25
|
+
# (substring of the model id, strengths, notes). First match wins, so specific patterns go first.
|
|
26
|
+
PROFILES: list[tuple[str, list[str], str]] = [
|
|
27
|
+
("gpt-oss-120b", ["reasoning", "coding", "tool-use"], "Open-weight 120B reasoning model. Strong all-rounder for code and multi-step tool work; fast on Groq and Cerebras."),
|
|
28
|
+
("gpt-oss-20b", ["fast", "tool-use"], "Small and fast. Simple edits, lookups, summaries."),
|
|
29
|
+
("nemotron-3-ultra", ["reasoning", "coding", "long-context", "review"], "NVIDIA's largest Nemotron. Slower; good for hard planning, reviews and long files."),
|
|
30
|
+
("bolt", ["fast"], "Very small and fast. Quick reads, summaries, simple questions; not for hard reasoning or big edits."),
|
|
31
|
+
("nemotron", ["reasoning", "coding"], "NVIDIA Nemotron family. Solid reasoning and agentic work."),
|
|
32
|
+
("minimax", ["coding", "tool-use", "vision"], "MiniMax agentic model that also accepts images. Good for coding and multi-step tool use."),
|
|
33
|
+
("glm", ["coding", "reasoning", "tool-use"], "Zhipu GLM. Strong at coding and agentic work."),
|
|
34
|
+
("kimi", ["coding", "long-context", "tool-use"], "Moonshot Kimi. Long context, agentic coding."),
|
|
35
|
+
("deepseek", ["coding", "reasoning", "math"], "DeepSeek. Strong code and math."),
|
|
36
|
+
("qwen", ["coding", "multilingual", "reasoning"], "Qwen. Good coder, many languages."),
|
|
37
|
+
("codestral", ["coding", "fast"], "Mistral's code model. Fast completions and edits."),
|
|
38
|
+
("mistral", ["writing", "multilingual"], "Mistral. Good writer; especially strong in French and other European languages."),
|
|
39
|
+
("command", ["writing", "multilingual", "long-context"], "Cohere Command. Writing, summarising long documents, many languages (incl. Arabic)."),
|
|
40
|
+
("gemma", ["general", "multilingual", "fast"], "Google Gemma. Light general model, many languages."),
|
|
41
|
+
("gemini", ["long-context", "vision", "reasoning"], "Gemini. Very long context and images."),
|
|
42
|
+
("gpt-4.1", ["coding", "long-context", "tool-use"], "GPT-4.1. Precise instruction following, long context, reliable tool calls."),
|
|
43
|
+
("gpt-5", ["reasoning", "coding", "tool-use"], "GPT-5 family. Strong reasoning and code."),
|
|
44
|
+
("llama", ["general", "writing"], "Llama. General chat and writing."),
|
|
45
|
+
]
|
|
46
|
+
UNPROFILED = (["general"], "No built-in profile yet. Run /roster bench, and describe it in ~/.forge/roster.json.")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Worker:
|
|
51
|
+
provider: str
|
|
52
|
+
model: str
|
|
53
|
+
strengths: list[str]
|
|
54
|
+
notes: str
|
|
55
|
+
source: str = "built-in" # built-in | roster.json | unknown
|
|
56
|
+
stats: dict = field(default_factory=dict)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def id(self) -> str:
|
|
60
|
+
return f"{self.provider}:{self.model}"
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def prefer(self) -> tuple[str, str]:
|
|
64
|
+
return (self.provider, self.model)
|
|
65
|
+
|
|
66
|
+
def measured(self) -> str:
|
|
67
|
+
s = self.stats
|
|
68
|
+
if not s:
|
|
69
|
+
return "not benched"
|
|
70
|
+
if not s.get("up"):
|
|
71
|
+
return f"DOWN at last check ({s.get('error', 'no answer')[:60]})"
|
|
72
|
+
bits = [f"{s.get('latency', 0):.1f}s",
|
|
73
|
+
"tools ✓" if s.get("tools") else "tools ✗",
|
|
74
|
+
"check ✓" if s.get("check") else "check ✗ (got a simple reasoning question wrong)"]
|
|
75
|
+
return ", ".join(bits)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _load(path) -> dict:
|
|
79
|
+
try:
|
|
80
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
81
|
+
except (OSError, ValueError):
|
|
82
|
+
return {}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def profile(model: str) -> tuple[list[str], str]:
|
|
86
|
+
low = model.lower()
|
|
87
|
+
for pat, strengths, notes in PROFILES:
|
|
88
|
+
if pat in low:
|
|
89
|
+
st = list(strengths)
|
|
90
|
+
if re.search(r"(^|[-/_])(vl|vision)([-/_.:]|$)", low) and "vision" not in st:
|
|
91
|
+
st.append("vision")
|
|
92
|
+
return st, notes
|
|
93
|
+
return list(UNPROFILED[0]), UNPROFILED[1]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def build(router) -> list[Worker]:
|
|
97
|
+
"""One worker per (provider, model) Forge can call."""
|
|
98
|
+
edits, stats = _load(ROSTER_FILE), _load(STATS_FILE)
|
|
99
|
+
out = []
|
|
100
|
+
for p in router.providers:
|
|
101
|
+
for m in p.all_models():
|
|
102
|
+
strengths, notes = profile(m)
|
|
103
|
+
source = "built-in" if notes != UNPROFILED[1] else "unknown"
|
|
104
|
+
own = edits.get(f"{p.name}:{m}") or edits.get(m)
|
|
105
|
+
if isinstance(own, dict):
|
|
106
|
+
strengths = [str(x) for x in own.get("strengths", strengths)]
|
|
107
|
+
notes = str(own.get("notes", notes))
|
|
108
|
+
source = "roster.json"
|
|
109
|
+
out.append(Worker(p.name, m, strengths, notes, source, stats.get(f"{p.name}:{m}", {})))
|
|
110
|
+
return out
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def resolve(workers: list[Worker], name: str) -> Worker | None:
|
|
114
|
+
"""Find a worker by id, model id, or an unambiguous part of either."""
|
|
115
|
+
name = (name or "").strip().lstrip("@").lower()
|
|
116
|
+
if not name:
|
|
117
|
+
return None
|
|
118
|
+
for w in workers:
|
|
119
|
+
if name in (w.id.lower(), w.model.lower()):
|
|
120
|
+
return w
|
|
121
|
+
hits = [w for w in workers if name in w.id.lower()]
|
|
122
|
+
return hits[0] if len(hits) == 1 else None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def usable(w: Worker, router) -> bool:
|
|
126
|
+
if w.stats and not w.stats.get("up"):
|
|
127
|
+
return False
|
|
128
|
+
provider = router.get(w.provider)
|
|
129
|
+
return bool(provider and provider.pool.any_available(w.model) and not router.is_down(*w.prefer))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def pick_coordinator(workers: list[Worker], router, wanted: str = "") -> Worker | None:
|
|
133
|
+
def qualified(w: Worker) -> bool:
|
|
134
|
+
measured = ("reasoning" in w.strengths and w.stats.get("tools") and w.stats.get("check"))
|
|
135
|
+
profiled = "reasoning" in w.strengths and "tool-use" in w.strengths
|
|
136
|
+
return bool(measured or profiled)
|
|
137
|
+
|
|
138
|
+
if wanted:
|
|
139
|
+
w = resolve(workers, wanted)
|
|
140
|
+
if w and usable(w, router) and qualified(w):
|
|
141
|
+
return w
|
|
142
|
+
ok = [w for w in workers if usable(w, router) and qualified(w)]
|
|
143
|
+
# Prefer a measured tool-capable reasoner; otherwise a profiled tool-capable reasoner.
|
|
144
|
+
for cond in (lambda w: "reasoning" in w.strengths and w.stats.get("tools") and w.stats.get("check"),
|
|
145
|
+
lambda w: "reasoning" in w.strengths and "tool-use" in w.strengths,
|
|
146
|
+
):
|
|
147
|
+
hit = [w for w in ok if cond(w)]
|
|
148
|
+
if hit:
|
|
149
|
+
return min(hit, key=lambda w: w.stats.get("latency", 99)) if hit[0].stats else hit[0]
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def describe(workers: list[Worker], router) -> str:
|
|
154
|
+
lines = []
|
|
155
|
+
for w in workers:
|
|
156
|
+
if not usable(w, router):
|
|
157
|
+
continue
|
|
158
|
+
lines.append(f"- {w.id} — good at: {', '.join(w.strengths)}. {w.notes} Measured: {w.measured()}.")
|
|
159
|
+
return "\n".join(lines)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def write_template(workers: list[Worker]) -> None:
|
|
163
|
+
"""Create ~/.forge/roster.json with the current profiles so it can be edited."""
|
|
164
|
+
if ROSTER_FILE.exists():
|
|
165
|
+
return
|
|
166
|
+
HOME_DIR.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
data = {"_help": ("Describe what each model is good at. Keys are provider:model (or just the model id). "
|
|
168
|
+
"strengths can use: " + ", ".join(STRENGTHS) + " (or your own words).")}
|
|
169
|
+
for w in workers:
|
|
170
|
+
data[w.id] = {"strengths": w.strengths, "notes": w.notes}
|
|
171
|
+
ROSTER_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# --- bench ----------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
CHECK = ("Which is larger, 9.11 or 9.8? And what is 17 * 23? "
|
|
177
|
+
"Reply with exactly two values separated by a comma, nothing else.")
|
|
178
|
+
TOOL_PROMPT = "Read the file notes.txt using the tool."
|
|
179
|
+
TOOL = [{"type": "function", "function": {
|
|
180
|
+
"name": "read_file", "description": "Read a text file",
|
|
181
|
+
"parameters": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}}}]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def bench_one(router, w: Worker, timeout: float = 60) -> dict:
|
|
185
|
+
p = router.get(w.provider)
|
|
186
|
+
key = p.pool.next_key() if p else None
|
|
187
|
+
if not key:
|
|
188
|
+
return {"up": False, "error": "no usable key", "when": time.time()}
|
|
189
|
+
t = time.time()
|
|
190
|
+
r = p.call(key, [{"role": "user", "content": CHECK}], None, w.model, timeout)
|
|
191
|
+
latency = time.time() - t
|
|
192
|
+
if not r:
|
|
193
|
+
return {"up": False, "error": p.last_error, "when": time.time()}
|
|
194
|
+
text = r["content"]
|
|
195
|
+
check = "391" in text and "9.8" in text and "9.11" not in text
|
|
196
|
+
r2 = p.call(p.pool.next_key() or key, [{"role": "user", "content": TOOL_PROMPT}], TOOL, w.model, timeout)
|
|
197
|
+
return {"up": True, "latency": round(latency, 2), "check": check,
|
|
198
|
+
"tools": bool(r2 and r2["tool_calls"]), "answer": text[:80], "when": time.time()}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def bench(router, workers: list[Worker], on_done=None) -> dict[str, dict]:
|
|
202
|
+
results: dict[str, dict] = {}
|
|
203
|
+
lock = threading.Lock()
|
|
204
|
+
|
|
205
|
+
def run(w: Worker):
|
|
206
|
+
res = bench_one(router, w)
|
|
207
|
+
with lock:
|
|
208
|
+
results[w.id] = res
|
|
209
|
+
w.stats = res
|
|
210
|
+
if on_done:
|
|
211
|
+
on_done(w, res)
|
|
212
|
+
|
|
213
|
+
# A model failing its bench must not push its whole provider to the back of the chain.
|
|
214
|
+
saved = {p.name: p.last_fail for p in router.providers}
|
|
215
|
+
threads = [threading.Thread(target=run, args=(w,), daemon=True) for w in workers]
|
|
216
|
+
for th in threads:
|
|
217
|
+
th.start()
|
|
218
|
+
for th in threads:
|
|
219
|
+
while th.is_alive():
|
|
220
|
+
th.join(0.2) # short joins keep ctrl+c working on Windows
|
|
221
|
+
for p in router.providers:
|
|
222
|
+
p.last_fail = saved.get(p.name, p.last_fail)
|
|
223
|
+
stats = _load(STATS_FILE)
|
|
224
|
+
stats.update(results)
|
|
225
|
+
HOME_DIR.mkdir(parents=True, exist_ok=True)
|
|
226
|
+
STATS_FILE.write_text(json.dumps(stats, indent=2), encoding="utf-8")
|
|
227
|
+
return results
|
forgecli/safety.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Which actions need the user's OK.
|
|
2
|
+
|
|
3
|
+
Approval modes:
|
|
4
|
+
smart (default) reading, and writing/editing files inside the project folder, just happen.
|
|
5
|
+
Shell commands run without asking unless they look risky: deleting, force-pushing,
|
|
6
|
+
installing system-wide, touching the registry or disks, shutting down, piping
|
|
7
|
+
downloads into a shell, or anything aimed outside the project folder.
|
|
8
|
+
ask every write, edit and shell command asks (the old behaviour).
|
|
9
|
+
auto nothing asks. Use with care.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import pathlib
|
|
14
|
+
import re
|
|
15
|
+
import fnmatch
|
|
16
|
+
import os
|
|
17
|
+
|
|
18
|
+
MODES = ("smart", "ask", "auto")
|
|
19
|
+
|
|
20
|
+
# Patterns that always ask in smart mode. Kept deliberately broad: a false alarm costs one keypress.
|
|
21
|
+
RISKY = [
|
|
22
|
+
(r"\b(rm|rmdir|del|erase|rd)\b", "deletes files"),
|
|
23
|
+
(r"\bremove-item\b|\bri\b\s", "deletes files"),
|
|
24
|
+
(r"\bclear-content\b|\bset-content\b.*-force", "overwrites files"),
|
|
25
|
+
(r"\bmove-item\b|\bmv\b|\bmove\b|\bren(ame)?\b|\brename-item\b", "moves or renames files"),
|
|
26
|
+
(r"\bgit\s+(push|reset\s+--hard|clean|checkout\s+--|restore|rebase|branch\s+-D|stash\s+drop)", "changes git history or discards work"),
|
|
27
|
+
(r"\bformat\b|\bdiskpart\b|\bmkfs\b|\bdd\s+if=", "touches disks"),
|
|
28
|
+
(r"\b(shutdown|restart-computer|stop-computer|logoff)\b", "shuts the machine down"),
|
|
29
|
+
(r"\breg(\.exe)?\s+(add|delete|import)|\bset-itemproperty\b|\bnew-itemproperty\b|hklm:|hkcu:", "edits the registry"),
|
|
30
|
+
(r"\b(stop-process|taskkill|kill|pkill|killall)\b", "kills processes"),
|
|
31
|
+
(r"\bset-executionpolicy\b|\bnetsh\b|\bsc(\.exe)?\s+(delete|config|stop)", "changes system settings"),
|
|
32
|
+
(r"\b(npm|pnpm|yarn)\s+(i|install|add)\s+(-g|--global)\b", "installs globally"),
|
|
33
|
+
(r"\bchoco\s+install|\bwinget\s+(install|uninstall)|\bscoop\s+install", "installs software"),
|
|
34
|
+
(r"\bpip\s+uninstall|\bnpm\s+uninstall|\bwinget\s+uninstall", "uninstalls software"),
|
|
35
|
+
(r"(curl|wget|iwr|invoke-webrequest|irm|invoke-restmethod)[^|]*\|\s*(sh|bash|iex|invoke-expression|python|powershell)", "runs a downloaded script"),
|
|
36
|
+
(r"\biex\b|\binvoke-expression\b", "runs generated code"),
|
|
37
|
+
(r"\bsudo\b|\brunas\b|-verb\s+runas", "asks for admin rights"),
|
|
38
|
+
(r">\s*[a-z]:\\(windows|program files)|\bc:\\windows\b", "writes into system folders"),
|
|
39
|
+
(r"\b(docker|kubectl)\s+(rm|rmi|delete|prune|system\s+prune)", "deletes containers or resources"),
|
|
40
|
+
(r"\bdrop\s+(table|database)\b|\btruncate\s+table\b", "deletes database data"),
|
|
41
|
+
]
|
|
42
|
+
_RISKY = [(re.compile(p, re.I), why) for p, why in RISKY]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def policy_denial(tool: str, command: str = "") -> str:
|
|
46
|
+
"""Return a hard deny from the user's explicit environment policy."""
|
|
47
|
+
blocked_tools = [item.strip().lower() for item in os.environ.get("FORGE_DENY_TOOLS", "").split(",")
|
|
48
|
+
if item.strip()]
|
|
49
|
+
if any(fnmatch.fnmatchcase(tool.lower(), pattern) for pattern in blocked_tools):
|
|
50
|
+
return f"blocked by FORGE_DENY_TOOLS policy ({tool})"
|
|
51
|
+
for pattern in (item.strip() for item in os.environ.get("FORGE_DENY_COMMANDS", "").split(";")
|
|
52
|
+
if item.strip()):
|
|
53
|
+
if not command and tool != "run_shell":
|
|
54
|
+
continue
|
|
55
|
+
try:
|
|
56
|
+
if re.search(pattern, command, re.I):
|
|
57
|
+
return f"blocked by FORGE_DENY_COMMANDS policy ({pattern})"
|
|
58
|
+
except re.error as exc:
|
|
59
|
+
if tool == "run_shell":
|
|
60
|
+
return f"invalid FORGE_DENY_COMMANDS pattern {pattern!r}: {exc}; shell commands are blocked"
|
|
61
|
+
return ""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def shell_risk(command: str) -> str:
|
|
65
|
+
"""'' if the command looks safe, otherwise a short reason it needs approval."""
|
|
66
|
+
for rx, why in _RISKY:
|
|
67
|
+
if rx.search(command):
|
|
68
|
+
return why
|
|
69
|
+
return ""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def inside(path: pathlib.Path, root: pathlib.Path) -> bool:
|
|
73
|
+
try:
|
|
74
|
+
path.resolve().relative_to(root.resolve())
|
|
75
|
+
return True
|
|
76
|
+
except ValueError:
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def needs_ok(mode: str, tool: str, root: pathlib.Path, path: pathlib.Path | None = None,
|
|
81
|
+
command: str = "") -> str:
|
|
82
|
+
"""'' when the action can go ahead, otherwise why it needs the user's OK."""
|
|
83
|
+
if mode == "auto":
|
|
84
|
+
return ""
|
|
85
|
+
if mode == "ask":
|
|
86
|
+
return "asks before every change (approval mode: ask)"
|
|
87
|
+
if tool in ("write_file", "edit_file"):
|
|
88
|
+
if path is not None and not inside(path, root):
|
|
89
|
+
return "outside the project folder"
|
|
90
|
+
return ""
|
|
91
|
+
if tool == "run_shell":
|
|
92
|
+
return shell_risk(command)
|
|
93
|
+
return ""
|
forgecli/sandbox.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Optional OS-level sandbox for shell commands. Forge's approval prompts decide *whether* a command
|
|
2
|
+
runs; a sandbox limits *what it can reach* once it does. With FORGE_SANDBOX=docker every run_shell
|
|
3
|
+
command runs in a throwaway Docker container that can see only the project folder (mounted at
|
|
4
|
+
/work), not your home folder, keys or other projects.
|
|
5
|
+
|
|
6
|
+
FORGE_SANDBOX=docker turn it on (needs Docker Desktop running)
|
|
7
|
+
FORGE_SANDBOX_IMAGE=node:22-bookworm image to use (this default has Node, npm, Python 3 and git)
|
|
8
|
+
FORGE_SANDBOX_NET=none cut network access too (installs and web requests then fail)
|
|
9
|
+
FORGE_SANDBOX_PORTS=5173,3000,8000,8080 ports published to 127.0.0.1 so dev servers stay reachable
|
|
10
|
+
|
|
11
|
+
Commands use Linux shell syntax inside the container. File tools (read/write/edit) still run in
|
|
12
|
+
Forge itself and are limited by the approval rules, not by the container."""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import pathlib
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
|
|
20
|
+
DEFAULT_IMAGE = "node:22-bookworm"
|
|
21
|
+
DEFAULT_PORTS = "5173,3000,8000,8080"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def mode() -> str:
|
|
25
|
+
return os.environ.get("FORGE_SANDBOX", "").strip().lower()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def active() -> bool:
|
|
29
|
+
return mode() == "docker"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def available() -> str | None:
|
|
33
|
+
"""None when Docker is ready, otherwise why not (in plain words)."""
|
|
34
|
+
if not shutil.which("docker"):
|
|
35
|
+
return "Docker isn't installed (install Docker Desktop, or unset FORGE_SANDBOX)."
|
|
36
|
+
try:
|
|
37
|
+
r = subprocess.run(["docker", "info", "--format", "{{.ServerVersion}}"], capture_output=True,
|
|
38
|
+
text=True, timeout=15)
|
|
39
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
40
|
+
return "Docker didn't answer (start Docker Desktop, or unset FORGE_SANDBOX)."
|
|
41
|
+
return None if r.returncode == 0 else "Docker isn't running (start Docker Desktop, or unset FORGE_SANDBOX)."
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _ports() -> list[str]:
|
|
45
|
+
out = []
|
|
46
|
+
for part in os.environ.get("FORGE_SANDBOX_PORTS", DEFAULT_PORTS).split(","):
|
|
47
|
+
part = part.strip()
|
|
48
|
+
if part.isdigit() and 0 < int(part) < 65536:
|
|
49
|
+
out += ["-p", f"127.0.0.1:{part}:{part}"]
|
|
50
|
+
return out
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def argv(root: pathlib.Path, where: pathlib.Path, command: str) -> list[str] | str:
|
|
54
|
+
"""The docker command line for `command` run in `where`, or an error string if `where` is outside
|
|
55
|
+
the project folder (the container can only see the project)."""
|
|
56
|
+
root, where = pathlib.Path(root).resolve(), pathlib.Path(where).resolve()
|
|
57
|
+
try:
|
|
58
|
+
rel = where.relative_to(root)
|
|
59
|
+
except ValueError:
|
|
60
|
+
return (f"Error: the sandbox can only run commands inside the project folder {root}; "
|
|
61
|
+
f"{where} is outside it.")
|
|
62
|
+
workdir = "/work" + ("/" + rel.as_posix() if str(rel) != "." else "")
|
|
63
|
+
net = os.environ.get("FORGE_SANDBOX_NET", "").strip().lower()
|
|
64
|
+
cmd = ["docker", "run", "--rm", "-i", "--init",
|
|
65
|
+
"-v", f"{root}:/work", "-w", workdir,
|
|
66
|
+
"--memory", os.environ.get("FORGE_SANDBOX_MEMORY", "4g"),
|
|
67
|
+
"--pids-limit", "512",
|
|
68
|
+
"--security-opt", "no-new-privileges",
|
|
69
|
+
"-e", "PYTHONUNBUFFERED=1", "-e", "CI=1"]
|
|
70
|
+
if net == "none":
|
|
71
|
+
cmd += ["--network", "none"]
|
|
72
|
+
else:
|
|
73
|
+
cmd += _ports()
|
|
74
|
+
cmd += [os.environ.get("FORGE_SANDBOX_IMAGE", DEFAULT_IMAGE) or DEFAULT_IMAGE, "sh", "-lc", command]
|
|
75
|
+
return cmd
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def describe() -> str:
|
|
79
|
+
if not active():
|
|
80
|
+
return "off — commands run directly on this computer (approval rules still apply)"
|
|
81
|
+
net = "no network" if os.environ.get("FORGE_SANDBOX_NET", "").lower() == "none" else "network on"
|
|
82
|
+
return (f"docker · {os.environ.get('FORGE_SANDBOX_IMAGE', DEFAULT_IMAGE)} · only the project folder "
|
|
83
|
+
f"is visible · {net}")
|