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/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}")