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/parallel.py ADDED
@@ -0,0 +1,308 @@
1
+ """Parallel sub-agents. Off unless the user asks: /parallel <task> || <task>, or
2
+ /parallel on to let the main agent split work itself with the run_parallel tool.
3
+
4
+ Each sub-agent gets a copy of the whole conversation so far (so it knows what
5
+ was already done), works on its own task in a thread, and starts its failover
6
+ chain at a different provider so the load spreads across keys. Their reports
7
+ go back into the main conversation, so every later reply knows what they did."""
8
+ from __future__ import annotations
9
+
10
+ import copy
11
+ import threading
12
+ import time
13
+ from concurrent.futures import ThreadPoolExecutor
14
+
15
+ from rich.markdown import Markdown
16
+ from rich.text import Text
17
+
18
+ from .ui import UI, console
19
+
20
+ CONFIRM_LOCK = threading.Lock() # one approval prompt on screen at a time
21
+ COLORS = ["#67e8f9", "#fbbf24", "#4ade80", "#f472b6", "#93c5fd", "#fb923c", "#a3e635", "#e879f9"]
22
+
23
+ SCHEMA = {
24
+ "type": "function",
25
+ "function": {
26
+ "name": "run_parallel",
27
+ "description": (
28
+ "Run 2 or more independent tasks at the same time, each by its own sub-agent with a copy of "
29
+ "this conversation. Use only when the work splits into parts that don't depend on each other "
30
+ "and don't edit the same files. You get back each agent's report."),
31
+ "parameters": {
32
+ "type": "object",
33
+ "properties": {
34
+ "tasks": {
35
+ "type": "array", "minItems": 2,
36
+ "items": {"type": "string", "description": "Full, self-contained instructions for one agent"},
37
+ },
38
+ "isolate": {
39
+ "type": "boolean",
40
+ "description": ("Give each agent its own git worktree and branch so their edits can't "
41
+ "collide (git projects only). Use when agents might touch the same files."),
42
+ },
43
+ },
44
+ "required": ["tasks"],
45
+ },
46
+ },
47
+ }
48
+
49
+
50
+ DELEGATE_SCHEMA = {
51
+ "type": "function",
52
+ "function": {
53
+ "name": "delegate",
54
+ "description": (
55
+ "Hand tasks to team members (other models) that run at the same time, each with a copy of this "
56
+ "conversation. Pick the worker whose strengths fit each task, from the team list in your "
57
+ "instructions. Tasks must be independent and must not edit the same files. You get back each "
58
+ "worker's report; then check, combine, and finish the job."),
59
+ "parameters": {
60
+ "type": "object",
61
+ "properties": {
62
+ "tasks": {
63
+ "type": "array", "minItems": 1,
64
+ "items": {
65
+ "type": "object",
66
+ "properties": {
67
+ "task": {"type": "string", "description": "Full, self-contained instructions"},
68
+ "worker": {"type": "string", "description": "Worker id from the team list, e.g. nvidia:minimaxai/minimax-m3.1-flash"},
69
+ "why": {"type": "string", "description": "One short reason this worker fits"},
70
+ },
71
+ "required": ["task", "worker"],
72
+ },
73
+ },
74
+ },
75
+ "required": ["tasks"],
76
+ },
77
+ },
78
+ }
79
+
80
+
81
+ def split_tasks(text: str) -> list[str]:
82
+ return [t.strip() for t in text.split("||") if t.strip()]
83
+
84
+
85
+ def parse_tasks(items) -> list[dict]:
86
+ """Normalise tasks to {task, worker, why}. Accepts plain strings, '@worker task'
87
+ strings, or dicts from the delegate tool."""
88
+ out = []
89
+ for it in items or []:
90
+ if isinstance(it, dict):
91
+ task = str(it.get("task", "")).strip()
92
+ worker, why = str(it.get("worker", "")).strip(), str(it.get("why", "")).strip()
93
+ else:
94
+ task, worker, why = str(it).strip(), "", ""
95
+ if task.startswith("@"):
96
+ head, _, rest = task.partition(" ")
97
+ worker, task = head[1:], rest.strip()
98
+ if task:
99
+ out.append({"task": task, "worker": worker, "why": why})
100
+ return out
101
+
102
+
103
+ class SubUI(UI):
104
+ """Quiet, labelled output for a sub-agent running in a thread."""
105
+
106
+ def __init__(self, parent: UI, n: int):
107
+ super().__init__(parent.verbose)
108
+ self.n = n
109
+ self.color = COLORS[(n - 1) % len(COLORS)]
110
+ self.final = ""
111
+
112
+ def _tag(self) -> Text:
113
+ return Text(f" [{self.n}] ", style=f"bold {self.color}")
114
+
115
+ def start_spinner(self, label: str = "thinking") -> None: ...
116
+ def stop_spinner(self) -> None: ...
117
+ def tool_result(self, text: str) -> None: ...
118
+
119
+ def log(self, msg: str) -> None:
120
+ if self.verbose:
121
+ console.print(self._tag().append(f"↳ {msg}", style="faint"))
122
+
123
+ def tool(self, name: str, summary: str) -> None:
124
+ console.print(self._tag().append(name, style="tool").append(" ").append(summary[:120], style="muted"))
125
+
126
+ def assistant(self, text: str, meta: str = "") -> None:
127
+ self.final = text # shown when the agent finishes
128
+
129
+ def info(self, msg: str) -> None:
130
+ console.print(self._tag().append(msg, style="muted"))
131
+
132
+ def warn(self, msg: str) -> None:
133
+ console.print(self._tag().append(msg, style="warn"))
134
+
135
+ def error(self, msg: str) -> None:
136
+ console.print(self._tag().append("✗ " + msg, style="err"))
137
+
138
+ def confirm(self, title: str, detail: str, why: str = ""):
139
+ with CONFIRM_LOCK:
140
+ return super().confirm(f"[agent {self.n}] {title}", detail, why)
141
+
142
+ def action(self, verb: str, target: str, note: str = "") -> None:
143
+ console.print(self._tag().append(verb, style="tool").append(f"({target[:110]})", style="muted"))
144
+
145
+ def result(self, first: str, body=None) -> None:
146
+ console.print(self._tag().append(" ⎿ " + first, style="faint"))
147
+
148
+ def peek(self, kind: str, target: str) -> None: ...
149
+ def flush(self) -> None: ...
150
+ def failover(self, msg: str) -> None:
151
+ console.print(self._tag().append("⚠ " + msg, style="warn"))
152
+
153
+
154
+ def _handoff_packet(parent, task: str, reason: str) -> str:
155
+ goal = parent._goal() if hasattr(parent, "_goal") else ""
156
+ if not goal:
157
+ goal = next((str(m.get("content") or "") for m in reversed(parent.messages)
158
+ if m.get("role") == "user"), "")
159
+ lines = [f"Original user goal: {goal}",
160
+ f"Why this task was handed off: {reason or 'independent tasks requested'}"]
161
+
162
+ reads, changes, attempts = [], [], []
163
+ for step in parent.tools.ledger.steps:
164
+ path = str(step.args.get("path", "")).strip()
165
+ if step.name == "read_file" and path:
166
+ reads.append(path)
167
+ elif step.name in ("write_file", "edit_file") and path:
168
+ (changes if step.ok else attempts).append(path)
169
+ if step.name == "run_shell":
170
+ command = str(step.args.get("command", "")).strip()
171
+ if command:
172
+ status = "succeeded" if step.ok else "failed"
173
+ detail = " ".join(step.out.split())[:220]
174
+ attempts.append(f"command {command!r} ({status})" +
175
+ (f": {detail}" if detail else ""))
176
+ elif not step.ok:
177
+ detail = " ".join(step.out.split())[:220]
178
+ attempts.append(f"{step.name} failed" + (f": {detail}" if detail else ""))
179
+
180
+ if reads:
181
+ lines.append("Files already read:\n" + "\n".join(f"- {p}" for p in dict.fromkeys(reads)))
182
+ if changes:
183
+ lines.append("Files already changed:\n" + "\n".join(f"- {p}" for p in dict.fromkeys(changes)))
184
+ if attempts:
185
+ lines.append("What was tried:\n" + "\n".join(f"- {item}" for item in dict.fromkeys(attempts)))
186
+
187
+ skill_results = {m.get("tool_call_id"): m.get("content", "") for m in parent.messages
188
+ if m.get("role") == "tool"}
189
+ skills = []
190
+ for message in parent.messages:
191
+ if message.get("role") != "assistant":
192
+ continue
193
+ for call in message.get("tool_calls") or []:
194
+ fn = call.get("function") or {}
195
+ if fn.get("name") == "use_skill" and call.get("id") in skill_results:
196
+ skills.append(skill_results[call["id"]])
197
+ if skills:
198
+ lines.append("Loaded skill instructions:\n" + "\n\n".join(dict.fromkeys(skills)))
199
+
200
+ lines.append(
201
+ f"Assigned task: {task}\n\nDo only this task. Avoid files changed above unless necessary. "
202
+ "Return a short report of actions, changed files, verification, and anything unresolved.")
203
+ return "\n\n".join(lines)
204
+
205
+
206
+ def coordinator_packet(parent, reason: str) -> str:
207
+ return "[Coordinator hand-off packet]\n" + _handoff_packet(
208
+ parent, "Continue the original user goal without repeating completed work.", reason)
209
+
210
+
211
+ def run_tasks(parent, tasks: list, reason: str = "", isolate: bool | None = None) -> str:
212
+ """Run tasks concurrently. Tasks are strings ('@worker task' allowed) or
213
+ {task, worker, why} dicts. Returns a combined report (also used as a tool result)."""
214
+ from .agent import Agent # local import: agent imports this module
215
+ from . import roster
216
+
217
+ ui: UI = parent.ui
218
+ tasks = parse_tasks(tasks)
219
+ if any(t["worker"] for t in tasks):
220
+ workers = roster.build(parent.router)
221
+ for t in tasks:
222
+ selected = roster.resolve(workers, t["worker"]) if t["worker"] else None
223
+ t["w"] = selected if selected and roster.usable(selected, parent.router) else None
224
+ limit = parent.settings.max_agents
225
+ was_limited = len(tasks) > limit
226
+ if was_limited:
227
+ tasks = tasks[:limit]
228
+ cancel = threading.Event()
229
+ from . import worktrees
230
+ repo = worktrees.repo_root(parent.tools.root) if worktrees.wanted(isolate) and len(tasks) > 1 else None
231
+ stamp = time.strftime("%Y%m%d-%H%M%S")
232
+
233
+ ui.stop_spinner()
234
+ if not getattr(parent, "_handoff_status_shown", False):
235
+ targets = list(dict.fromkeys(t["w"].id for t in tasks if t.get("w")))
236
+ target = ", ".join(targets[:3]) if targets else "automatic worker routing"
237
+ if len(targets) > 3:
238
+ target += f" +{len(targets) - 3}"
239
+ status = f"◆ Handed to {target} · {reason or 'independent tasks requested'}"
240
+ if was_limited:
241
+ status += f" · capped at {limit} tasks"
242
+ ui.info(status)
243
+ parent._handoff_status_shown = True
244
+
245
+ def work(i: int, t: dict) -> dict:
246
+ task = t["task"]
247
+ sub_ui = SubUI(ui, i)
248
+ sub = Agent(parent.settings, sub_ui, router=parent.router, ephemeral=True)
249
+ sub.tools.auto_approve = parent.tools.auto_approve
250
+ sub.tools.always = parent.tools.always # "always" answers are shared
251
+ sub.offset = i - 1 # start at a different provider
252
+ sub.prefer = t["w"].prefer if t.get("w") else None # assigned model goes first
253
+ sub.cancel = cancel
254
+ tree = worktrees.create(repo, i, stamp) if repo else None
255
+ packet = _handoff_packet(parent, task, reason or t.get("why", ""))
256
+ if tree:
257
+ sub.tools.root = tree[0]
258
+ sub.messages[0] = dict(sub.messages[0], content=str(sub.messages[0]["content"]).replace(
259
+ str(parent.tools.root), str(tree[0])))
260
+ packet += (f"\n\nYou work in your own git worktree: {tree[0]} (branch {tree[1]}). Use relative "
261
+ "paths or paths inside it and pass cwd for shell commands; other agents work in their "
262
+ "own copies. Don't commit: Forge commits your changes when you finish.")
263
+ sub.messages = [sub.messages[0], {"role": "user", "content": packet}]
264
+ started = time.time()
265
+ try:
266
+ sub._loop()
267
+ except Exception as e: # one agent failing must not take down the others
268
+ sub_ui.final = sub_ui.final or f"Agent crashed: {e}"
269
+ if tree:
270
+ sub_ui.final = (sub_ui.final or "") + "\n\nWorktree: " + worktrees.finish(repo, tree[0], tree[1], task)
271
+ who = sub.last_answer or "no provider answered"
272
+ assigned = t["w"].id if t.get("w") else ""
273
+ if assigned and sub.last_answer and sub.last_answer.replace(" · ", ":") != assigned:
274
+ who += f" (assigned {assigned}, which failed, so failover answered)"
275
+ return {"n": i, "task": task, "report": sub_ui.final or "(no report — it may have failed or been stopped)",
276
+ "who": who, "assigned": assigned, "secs": time.time() - started,
277
+ "evidence": copy.deepcopy(sub.tools.ledger.steps)}
278
+
279
+ results: list[dict] = []
280
+ city = getattr(ui, "city", None)
281
+ if city is not None: # helper cubs join the delivery run, one per agent
282
+ city.set_helpers(len(tasks))
283
+ ui.act("delegating")
284
+ pool = ThreadPoolExecutor(max_workers=len(tasks), thread_name_prefix="forge-agent")
285
+ futures = [pool.submit(work, i, t) for i, t in enumerate(tasks, 1)]
286
+ try:
287
+ for f in futures:
288
+ while not f.done(): # short sleeps keep ctrl+c responsive on Windows
289
+ time.sleep(0.2)
290
+ results.append(f.result())
291
+ except KeyboardInterrupt:
292
+ cancel.set()
293
+ ui.warn("stopping agents after their current step…")
294
+ results = [f.result() for f in futures]
295
+ finally:
296
+ pool.shutdown(wait=True)
297
+ if city is not None:
298
+ city.set_helpers(0)
299
+ ui.act("thinking") # back to the main agent, which reads the reports
300
+
301
+ for result in results:
302
+ parent.tools.ledger.steps.extend(result["evidence"])
303
+
304
+ for r in results:
305
+ ui.panel(Markdown(r["report"].strip(), code_theme="monokai"),
306
+ title=f"agent {r['n']} · {r['task'][:60]}", subtitle=f"{r['who'][:90]} · {r['secs']:.0f}s")
307
+ return "\n\n".join(f"### Agent {r['n']}: {r['task']}\n(answered by {r['who']})\n\n{r['report']}"
308
+ for r in results)
forgecli/prices.py ADDED
@@ -0,0 +1,72 @@
1
+ """What a session's tokens would cost. Most of Forge's providers are free tiers (cost $0); for paid
2
+ ones this is an estimate from public list prices per million tokens, which change - put your own in
3
+ ~/.forge/prices.json as {"provider:model" or "model": [input_per_million, output_per_million]}.
4
+ Cached-input discounts are not counted, so real bills can be lower."""
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import os
9
+ import pathlib
10
+
11
+ # (input $, output $) per 1M tokens. Matched against "provider:model", then the model name, then a
12
+ # substring of the model name. Unknown paid models show "price unknown" rather than a guess.
13
+ LIST = {
14
+ "gpt-4o-mini": (0.15, 0.60),
15
+ "gpt-4o": (2.50, 10.00),
16
+ "gpt-4.1-mini": (0.40, 1.60),
17
+ "gpt-4.1": (2.00, 8.00),
18
+ "o4-mini": (1.10, 4.40),
19
+ "claude-haiku": (1.00, 5.00),
20
+ "claude-sonnet": (3.00, 15.00),
21
+ "claude-opus": (15.00, 75.00),
22
+ "gemini-2.5-flash": (0.30, 2.50),
23
+ "gemini-2.5-pro": (1.25, 10.00),
24
+ "deepseek-chat": (0.27, 1.10),
25
+ "deepseek-reasoner": (0.55, 2.19),
26
+ }
27
+ # Providers whose keys in Forge are free tiers: no money changes hands.
28
+ FREE_PROVIDERS = {"groq", "cerebras", "nvidia", "llm7", "ovh", "kilo", "github", "cloudflare", "sambanova",
29
+ "mistral", "cohere", "chutes", "pollinations"}
30
+
31
+
32
+ def _user_prices() -> dict:
33
+ p = pathlib.Path(os.environ.get("FORGE_HOME", pathlib.Path.home() / ".forge")) / "prices.json"
34
+ try:
35
+ data = json.loads(p.read_text(encoding="utf-8"))
36
+ return {str(k).lower(): (float(v[0]), float(v[1])) for k, v in data.items()}
37
+ except (OSError, ValueError, TypeError, IndexError, AttributeError):
38
+ return {}
39
+
40
+
41
+ def price(provider: str, model: str) -> tuple[float, float] | None:
42
+ """(input, output) dollars per million tokens; (0, 0) for free; None when unknown."""
43
+ prov, mod = (provider or "").lower(), (model or "").lower()
44
+ user = _user_prices()
45
+ for key in (f"{prov}:{mod}", mod):
46
+ if key in user:
47
+ return user[key]
48
+ if prov in FREE_PROVIDERS or mod.endswith(":free") or "/free" in mod or mod == "free":
49
+ return (0.0, 0.0)
50
+ if prov == "openrouter" and mod.endswith(":free"):
51
+ return (0.0, 0.0)
52
+ for name in sorted(LIST, key=len, reverse=True):
53
+ if name in mod:
54
+ return LIST[name]
55
+ return None
56
+
57
+
58
+ def summary(by_model: dict) -> tuple[list[str], float, bool]:
59
+ """Lines for /cost, the total in dollars, and whether any model's price was unknown."""
60
+ lines, total, unknown = [], 0.0, False
61
+ for key, (tin, tout) in sorted(by_model.items(), key=lambda kv: -(kv[1][0] + kv[1][1])):
62
+ prov, _, mod = key.partition(":")
63
+ p = price(prov, mod)
64
+ if p is None:
65
+ unknown = True
66
+ cost = "price unknown"
67
+ else:
68
+ dollars = (tin * p[0] + tout * p[1]) / 1_000_000
69
+ total += dollars
70
+ cost = "free" if p == (0.0, 0.0) else f"${dollars:,.4f}"
71
+ lines.append(f"{prov} · {mod}: {tin:,} in / {tout:,} out — {cost}")
72
+ return lines, total, unknown
forgecli/procs.py ADDED
@@ -0,0 +1,144 @@
1
+ """Running shell commands without ever hanging the chat.
2
+
3
+ Output goes to a log file instead of pipes. (On Windows, a pipe stays open while any
4
+ grandchild holds it, so a server started through powershell made subprocess.run wait
5
+ forever even after its timeout.) A command that turns out to be a server (it prints a
6
+ local URL and keeps running) is moved to the background automatically, so the agent can
7
+ carry on and open it in the browser. Background processes are stopped when Forge exits."""
8
+ from __future__ import annotations
9
+
10
+ import atexit
11
+ import os
12
+ import re
13
+ import subprocess
14
+ import tempfile
15
+ import threading
16
+ import time
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+
20
+ LOG_DIR = Path(tempfile.gettempdir()) / "forge-cli-logs"
21
+ URL_RE = re.compile(r"https?://(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\]|[\w.-]+):\d{2,5}[^\s'\"<>]*", re.I)
22
+ SERVER_HINT = re.compile(r"running on|listening|serving|server (?:is )?(?:running|started)|"
23
+ r"press ctrl\+c|ready in|local:|started server|development server", re.I)
24
+ WIN = os.name == "nt"
25
+
26
+
27
+ @dataclass
28
+ class Proc:
29
+ id: int
30
+ command: str
31
+ cwd: str
32
+ popen: subprocess.Popen
33
+ log: Path
34
+ started: float = field(default_factory=time.time)
35
+
36
+ def alive(self) -> bool:
37
+ return self.popen.poll() is None
38
+
39
+ def output(self, tail: int = 8000) -> str:
40
+ try:
41
+ data = self.log.read_text(encoding="utf-8", errors="replace")
42
+ except OSError:
43
+ return ""
44
+ return data[-tail:]
45
+
46
+ def urls(self) -> list[str]:
47
+ seen = []
48
+ for u in URL_RE.findall(self.output(200_000)):
49
+ u = u.rstrip(".,);]").replace("0.0.0.0", "127.0.0.1")
50
+ if u not in seen:
51
+ seen.append(u)
52
+ return seen
53
+
54
+ def state(self) -> str:
55
+ return "running" if self.alive() else f"exited ({self.popen.returncode})"
56
+
57
+
58
+ _PROCS: dict[int, Proc] = {}
59
+ _LOCK = threading.Lock()
60
+ _NEXT = [1]
61
+
62
+
63
+ def argv_for(shell: str, command: str) -> list[str]:
64
+ if shell == "powershell":
65
+ return ["powershell", "-NoProfile", "-NonInteractive", "-Command", command]
66
+ if shell == "pwsh":
67
+ return ["pwsh", "-NoProfile", "-NonInteractive", "-Command", command]
68
+ if shell == "cmd":
69
+ return ["cmd", "/c", command]
70
+ return [shell, "-lc", command]
71
+
72
+
73
+ def start(shell: str, command: str, cwd: str, argv: list[str] | None = None) -> Proc:
74
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
75
+ with _LOCK:
76
+ pid_n = _NEXT[0]
77
+ _NEXT[0] += 1
78
+ log = LOG_DIR / f"{os.getpid()}-{pid_n}.log"
79
+ fh = open(log, "wb")
80
+ env = dict(os.environ, PYTHONUNBUFFERED="1", PYTHONIOENCODING="utf-8")
81
+ kw = {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} if WIN else {"start_new_session": True}
82
+ try:
83
+ popen = subprocess.Popen(argv or argv_for(shell, command), cwd=cwd, stdout=fh, stderr=subprocess.STDOUT,
84
+ stdin=subprocess.DEVNULL, env=env, **kw)
85
+ finally:
86
+ fh.close() # the child has its own handle
87
+ p = Proc(pid_n, command, cwd, popen, log)
88
+ with _LOCK:
89
+ _PROCS[pid_n] = p
90
+ return p
91
+
92
+
93
+ def kill(p: Proc) -> None:
94
+ """Stop the process and everything it started."""
95
+ if not p.alive():
96
+ return
97
+ try:
98
+ if WIN:
99
+ subprocess.run(["taskkill", "/T", "/F", "/PID", str(p.popen.pid)],
100
+ capture_output=True, timeout=15)
101
+ else:
102
+ import signal
103
+ os.killpg(p.popen.pid, signal.SIGTERM)
104
+ except Exception:
105
+ pass
106
+ try:
107
+ p.popen.wait(timeout=5)
108
+ except Exception:
109
+ try:
110
+ p.popen.kill()
111
+ except Exception:
112
+ pass
113
+
114
+
115
+ def forget(p: Proc) -> None:
116
+ with _LOCK:
117
+ _PROCS.pop(p.id, None)
118
+ try:
119
+ p.log.unlink()
120
+ except OSError:
121
+ pass
122
+
123
+
124
+ def get(n) -> Proc | None:
125
+ try:
126
+ return _PROCS.get(int(n))
127
+ except (TypeError, ValueError):
128
+ return None
129
+
130
+
131
+ def all_procs() -> list[Proc]:
132
+ return list(_PROCS.values())
133
+
134
+
135
+ def looks_like_server(p: Proc) -> bool:
136
+ out = p.output(20_000)
137
+ return bool(URL_RE.search(out)) or bool(SERVER_HINT.search(out))
138
+
139
+
140
+ @atexit.register
141
+ def _cleanup() -> None:
142
+ for p in all_procs():
143
+ kill(p)
144
+ forget(p)