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/cli.py ADDED
@@ -0,0 +1,1668 @@
1
+ """Command-line entry point: styled interactive chat, or a one-shot prompt."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ import pathlib
8
+ import re
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ import threading
13
+ import time
14
+ from typing import Callable, Protocol
15
+
16
+ from rich.console import Console
17
+ from rich.text import Text
18
+
19
+ from . import __version__, keyvault, parallel, roster
20
+ from . import sessions as sess
21
+ from . import skills as skillmod
22
+ from .agent import Agent
23
+ from .config import APP_DIR, HOME_DIR, apply_prefs, load_prefs, load_settings, save_prefs
24
+ from .sessions import Session
25
+ from rich import box
26
+ from rich.padding import Padding
27
+ from rich.panel import Panel
28
+
29
+ from . import ui as uimod
30
+ from .ui import UI, console, ktok, logo
31
+
32
+ COMMANDS = {
33
+ "/help": "show commands and shortcuts",
34
+ "/new": "start a new conversation (the current one stays saved)",
35
+ "/undo": "restore the last file change and rewind its conversation step",
36
+ "/plan": "/plan <request> — plan read-only, then execute automatically",
37
+ "/sessions": "list saved conversations",
38
+ "/resume": "/resume <# or id> — continue a saved conversation",
39
+ "/rename": "/rename <title> — rename this conversation",
40
+ "/delete": "/delete <# or id> — delete a saved conversation",
41
+ "/model": "/model [provider] <model> — change model (no args: show current)",
42
+ "/models": "/models [provider] [filter] — list models a provider offers",
43
+ "/provider": "/provider <name|auto> — use only one provider, or auto failover",
44
+ "/skills": "list skills",
45
+ "/skills sync": "copy Claude Code skills into Forge",
46
+ "/skill": "/skill <name> [request] — run a skill · /skill new <name> — create one",
47
+ "/parallel": "/parallel <task> || @worker <task> — run tasks at once · /parallel on|off — let forge split work",
48
+ "/team": "/team on|off — a coordinator model hands work to the models best at it · /team lead <worker>",
49
+ "/city": "/city — watch the pixel city act out every state (/city off · /city on · /city calm)",
50
+ "/roster": "/roster — what each model is good at · /roster bench — measure them · /roster edit",
51
+ "/status": "providers, models and per-key state",
52
+ "/doctor": "MCP/API, model, key and skill usage; dormant connectors and token savings",
53
+ "/keys": "/keys — your API keys (masked) · /keys add|test|remove <provider> <n>|file <n> <provider>|provider <name> <url> <model>",
54
+ "/setup": "add API keys step by step (opens each provider's key page, tests every key)",
55
+ "/approve": "/approve smart|ask|auto — when to ask before changes (smart: only risky ones)",
56
+ "/balance": "tokens used, rate limits left and credits per provider",
57
+ "/cost": "tokens this session, and the money they would cost on paid plans",
58
+ "/ads": "sponsor line on the goodbye screen: /ads status · off · on · reset",
59
+ "/commands": "list project slash commands from .forge/commands/",
60
+ "/memory": "/memory [user|project] — show saved notes",
61
+ "/review": "review the current git diff for correctness issues",
62
+ "/github": "/github status · login · logout — GitHub sign-in (device code, no gh needed)",
63
+ "/yes": "toggle auto-approve for everything",
64
+ "/verbose": "toggle the key rotation / failover log",
65
+ "/exit": "quit",
66
+ }
67
+ BOM = chr(0xFEFF)
68
+ APPROVAL_TEXT = {"smart": "smart — edits in this folder and safe commands just run; risky ones ask",
69
+ "ask": "ask — every write, edit and command asks first",
70
+ "auto": "AUTO — nothing asks (careful)"}
71
+ ALIASES = {"/clear": "/new", "/quit": "/exit", "/q": "/exit", "/m": "/model", "/s": "/sessions",
72
+ "/r": "/resume", "/?": "/help"}
73
+
74
+ SETUP = f"""No API keys configured.
75
+
76
+ Create {APP_DIR / '.env'} (or {HOME_DIR / '.env'}) with, for example:
77
+
78
+ FORGE_PROVIDERS=groq,nvidia,openrouter
79
+ GROQ_API_KEYS=gsk_key1,gsk_key2
80
+ OPENROUTER_API_KEYS=sk-or-key1
81
+
82
+ See .env.example for every option."""
83
+
84
+
85
+ class App:
86
+ def __init__(self, agent: Agent, ui: UI):
87
+ self.agent = agent
88
+ self.ui = ui
89
+ self.router = agent.router
90
+ self.prefs = load_prefs()
91
+ self.router.pinned = self.prefs.get("pinned") if self.router.get(self.prefs.get("pinned") or "") else None
92
+ self.model_cache: dict[str, list[str]] = {}
93
+
94
+ # header / footer --------------------------------------------------------------
95
+ def model_label(self) -> str:
96
+ if self.agent.team and self.agent.coordinator:
97
+ return f"team · lead {self.agent.coordinator.model}"
98
+ lead = self.router.lead()
99
+ if not lead:
100
+ return "no provider"
101
+ return f"{lead.name} · {lead.model}"
102
+
103
+ def banner(self) -> None:
104
+ r = self.router
105
+ live = [p for p in r.providers if p.pool.keys]
106
+ chain = " → ".join(p.name for p in (r._ordered() if r.pinned else live))
107
+ mode = self.agent.tools.mode
108
+
109
+ def fact(label: str, value: str, style: str = "") -> Text:
110
+ return Text(f"{label:<9}", style="faint").append(value, style=style)
111
+
112
+ facts = [
113
+ fact("model", self.model_label(), "bold"),
114
+ fact("routing", ("pinned " if r.pinned else "") + chain, "muted"),
115
+ fact("folder", os.getcwd()),
116
+ fact("approval", mode, "warn" if mode == "auto" else "accent")
117
+ .append({"smart": " risky steps ask first", "ask": " every change asks first",
118
+ "auto": " nothing asks (careful)"}[mode], style="faint")]
119
+ if not self.agent.session.messages:
120
+ last = next((s for s in sess.list_sessions()
121
+ if os.path.normcase(s["cwd"]) == os.path.normcase(os.getcwd())), None)
122
+ if last:
123
+ title = re.sub(r"\s*\[Forge:.*", "", (last["title"] or "").splitlines()[0] if last["title"] else "").strip()
124
+ facts.append(fact("last", (title or "(untitled)")[:44])
125
+ .append(f" {sess.ago(last['updated'])} · ", style="faint")
126
+ .append("/resume", style="accent"))
127
+ console.print()
128
+ # High-resolution banner: a real sixel picture where the terminal supports it (Windows
129
+ # Terminal 1.22+, WezTerm, foot, mlterm…); half-block pixels everywhere else.
130
+ sixel = uimod.banner_sixel(min(console.width - 4, 110)) if console.is_terminal else None
131
+ if sixel:
132
+ sys.stdout.write(" " + sixel + "\n")
133
+ sys.stdout.flush()
134
+ console.print(Panel(logo(__version__, "multi-model coding agent · failover · team mode", facts,
135
+ picture=not sixel),
136
+ box=box.ROUNDED, border_style="#4a2a72", padding=(0, 2), expand=False))
137
+ hint = Text(" ")
138
+ for i, (key, what) in enumerate([("/help", "commands"), ("/model", "switch"), ("/balance", "limits"),
139
+ ("ctrl+j", "new line"), ("esc", "stop")]):
140
+ if i:
141
+ hint.append(" ")
142
+ hint.append(key, style="accent").append(" " + what, style="faint")
143
+ console.print(hint)
144
+ console.print()
145
+
146
+ def toolbar(self):
147
+ a = self.agent
148
+ title = (a.session.title or "new conversation")[:32]
149
+ mode = {"smart": "asks for risky only", "ask": "asks every change", "auto": "AUTO-APPROVE"}[a.tools.mode]
150
+ bits = [("class:tb.accent", f" ◆ {self.model_label()} "),
151
+ ("class:tb", f" ctx {ktok(a.ctx_tokens)} · used {ktok(a.used_in + a.used_out)} tok ")]
152
+ lead = self.router.lead()
153
+ if lead and lead.pool.keys:
154
+ left = lead.remaining()
155
+ if left.get("req"):
156
+ bits.append(("class:tb", f"· {left['req'][0]} req left "))
157
+ extra = (" · parallel" if a.parallel else "") + (" · team" if a.team else "")
158
+ bits.append(("class:tb.warn" if a.tools.mode == "auto" else "class:tb", f"│ {mode}{extra} "))
159
+ bits.append(("class:tb.dim", f"│ {title} "))
160
+ return bits
161
+
162
+ # commands ---------------------------------------------------------------------
163
+ def handle(self, text: str) -> bool:
164
+ """Run a slash command. Returns False to quit."""
165
+ parts = text.split(maxsplit=1)
166
+ cmd = ALIASES.get(parts[0].lower(), parts[0].lower())
167
+ arg = parts[1].strip() if len(parts) > 1 else ""
168
+ if cmd == "/exit":
169
+ return False
170
+ if cmd == "/review":
171
+ self.run("Review the current git diff for concrete bugs and regressions. "
172
+ "Start with git status and git diff, inspect relevant surrounding code, "
173
+ "and report findings only; do not modify files.")
174
+ return True
175
+ fn = getattr(self, "cmd_" + cmd[1:], None)
176
+ if fn:
177
+ fn(arg)
178
+ elif cmd[1:] in self.agent.skills:
179
+ self.run(self.agent.skill_prompt(cmd[1:], arg), echo=f"skill: {cmd[1:]}")
180
+ elif self._project_command(cmd[1:], arg):
181
+ pass
182
+ else:
183
+ self.ui.error(f"unknown command {cmd} — /help lists them")
184
+ return True
185
+
186
+ def cmd_help(self, _):
187
+ rows = [[k, v] for k, v in COMMANDS.items()]
188
+ if self.agent.skills:
189
+ rows.append(["/<skill>", "shortcut for /skill <skill> [request]"])
190
+ self.ui.table("commands", ["", ""], rows, ["accent", "muted"])
191
+ self.ui.table("keys", ["", ""], [
192
+ ["enter", "send"], ["ctrl+j (alt+enter)", "new line, or end a line with \\"],
193
+ ["tab", "complete commands, models, skills, sessions"], ["↑ / ↓", "input history"],
194
+ ["alt+v (ctrl+v)", "paste text, copied files (as paths) or a screenshot (saved, path inserted)"],
195
+ ["while Forge works", "keep typing: enter adds your message to the task"],
196
+ ["esc / ctrl+c", "stop the current task (ctrl+c with text clears it)"],
197
+ ["ctrl+d", "quit"]], ["accent", "muted"])
198
+
199
+ def cmd_new(self, _):
200
+ self.agent.reset()
201
+ console.clear()
202
+ self.banner()
203
+ self.ui.ok("new conversation")
204
+
205
+ def cmd_undo(self, _):
206
+ result = self.agent.undo_last()
207
+ (self.ui.error if result.startswith("Error:") else self.ui.ok)(result)
208
+
209
+ def cmd_plan(self, arg):
210
+ if not arg:
211
+ self.ui.error("Use /plan <request>.")
212
+ return
213
+ self.work(lambda: self.agent.run_plan(arg), stopped="⏹ planning stopped")
214
+
215
+ def cmd_sessions(self, arg):
216
+ items = sess.list_sessions()
217
+ if arg:
218
+ items = [s for s in items if arg.lower() in (s["title"] + s["cwd"]).lower()]
219
+ if not items:
220
+ self.ui.info("no saved conversations yet")
221
+ return
222
+ rows = []
223
+ for i, s in enumerate(items[:25], 1):
224
+ here = "●" if s["id"] == self.agent.session.id else ""
225
+ rows.append([f"{i}{here}", s["title"] or "(untitled)", s["count"], sess.ago(s["updated"]),
226
+ _short(s["cwd"])])
227
+ self.ui.table("sessions", ["#", "title", "msgs", "updated", "folder"], rows,
228
+ ["accent", None, "muted", "muted", "faint"])
229
+ self.ui.info("/resume <#> to continue one")
230
+
231
+ def pick_session(self) -> str | None:
232
+ """Numbered list of recent conversations (this folder's first); returns the chosen id."""
233
+ items = sess.list_sessions()
234
+ here = os.path.normcase(os.getcwd())
235
+ items = sorted(items, key=lambda s: os.path.normcase(s["cwd"]) != here)[:15] # stable: newest first
236
+ items = [s for s in items if s["id"] != self.agent.session.id]
237
+ if not items:
238
+ self.ui.info("no saved conversations yet")
239
+ return None
240
+ rows = [[str(i), s["title"] or "(untitled)", s["count"], sess.ago(s["updated"]),
241
+ "this folder" if os.path.normcase(s["cwd"]) == here else _short(s["cwd"])]
242
+ for i, s in enumerate(items, 1)]
243
+ self.ui.table("pick a conversation to continue", ["#", "title", "msgs", "updated", "folder"], rows,
244
+ ["accent", None, "muted", "muted", "faint"])
245
+ try:
246
+ ans = console.input(" [muted]number (enter = 1, q = cancel)[/] [accent]›[/] ").strip().lower()
247
+ except (EOFError, KeyboardInterrupt):
248
+ return None
249
+ if ans in ("q", "quit", "n", "no"):
250
+ return None
251
+ if not ans:
252
+ ans = "1"
253
+ if ans.isdigit() and 1 <= int(ans) <= len(items):
254
+ return items[int(ans) - 1]["id"]
255
+ return sess.find(ans)
256
+
257
+ def cmd_resume(self, arg):
258
+ if arg:
259
+ sid = sess.find(arg)
260
+ elif sys.stdin.isatty():
261
+ sid = self.pick_session()
262
+ if not sid:
263
+ return
264
+ else:
265
+ sid = sess.latest_for(os.getcwd())
266
+ if not sid:
267
+ self.ui.error("no matching session — see /sessions" if arg else "no saved session for this folder")
268
+ return
269
+ s = Session.load(sid)
270
+ self.agent.load(s)
271
+ console.clear()
272
+ self.banner()
273
+ self.ui.rule(s.title or sid)
274
+ self.ui.replay(s.messages)
275
+ if os.path.normcase(s.cwd) != os.path.normcase(os.getcwd()):
276
+ self.ui.warn(f"this conversation started in {s.cwd}; you are now in {os.getcwd()}")
277
+ self.ui.ok(f"resumed {s.title or sid}")
278
+
279
+ def cmd_rename(self, arg):
280
+ if not arg:
281
+ self.ui.error("usage: /rename <title>")
282
+ return
283
+ self.agent.session.title = arg[:80]
284
+ self.agent.save()
285
+ self.ui.ok(f"renamed to {arg[:80]}")
286
+
287
+ def cmd_delete(self, arg):
288
+ sid = sess.find(arg) if arg else None
289
+ if not sid:
290
+ self.ui.error("usage: /delete <# or id> (see /sessions)")
291
+ return
292
+ if sid == self.agent.session.id:
293
+ self.ui.error("that is the current conversation — /new first")
294
+ return
295
+ sess.delete(sid)
296
+ self.ui.ok(f"deleted {sid}")
297
+
298
+ def cmd_model(self, arg):
299
+ if not arg:
300
+ rows = [[p.name, p.model, "◆" if p is self.router.lead() else ""] for p in self.router.providers]
301
+ self.ui.table("models", ["provider", "model", "first"], rows, ["accent", None, "accent"])
302
+ self.ui.info("/model <model> sets the first provider's model · /model <provider> <model> · "
303
+ "/models <provider> lists choices")
304
+ return
305
+ bits = arg.split(maxsplit=1)
306
+ if len(bits) == 2 and self.router.get(bits[0].lower()):
307
+ prov, model = self.router.get(bits[0].lower()), bits[1].strip()
308
+ elif self.router.get(arg.lower()):
309
+ p = self.router.get(arg.lower())
310
+ self.ui.info(f"{p.name} uses {p.model}")
311
+ return
312
+ else:
313
+ prov, model = self.router.lead(), arg
314
+ if not prov:
315
+ self.ui.error("no provider configured")
316
+ return
317
+ known = self.model_cache.get(prov.name)
318
+ prov.model = model
319
+ self.prefs.setdefault("models", {})[prov.name] = model
320
+ save_prefs(self.prefs)
321
+ self.ui.ok(f"{prov.name} now uses {model}")
322
+ if known and model not in known:
323
+ self.ui.warn(f"{model!r} is not in {prov.name}'s model list — check the spelling with /models {prov.name}")
324
+ self.ui.info("the model must support tool calling, or Forge can't touch files or run commands")
325
+
326
+ def cmd_models(self, arg):
327
+ bits = arg.split(maxsplit=1)
328
+ prov = self.router.get(bits[0].lower()) if bits else None
329
+ flt = (bits[1] if prov and len(bits) > 1 else ("" if prov else arg)).lower()
330
+ prov = prov or self.router.lead()
331
+ if not prov:
332
+ self.ui.error("no provider configured")
333
+ return
334
+ self.ui.start_spinner(f"asking {prov.name} for its models")
335
+ try:
336
+ ids = prov.list_models()
337
+ except RuntimeError as e:
338
+ self.ui.error(f"could not list models: {e}")
339
+ return
340
+ finally:
341
+ self.ui.stop_spinner()
342
+ self.model_cache[prov.name] = ids
343
+ shown = [i for i in ids if flt in i.lower()]
344
+ rows = [["◆" if i == prov.model else "", i] for i in shown[:60]]
345
+ self.ui.table(f"{prov.name} models" + (f" matching '{flt}'" if flt else ""), ["", "id"], rows, ["accent", None])
346
+ more = f" (showing 60 of {len(shown)}; add a filter)" if len(shown) > 60 else ""
347
+ self.ui.info(f"/model {prov.name} <id> to switch · tab completes ids{more}")
348
+
349
+ def cmd_provider(self, arg):
350
+ name = arg.lower()
351
+ if not name:
352
+ self.ui.info(f"routing: {'pinned to ' + self.router.pinned if self.router.pinned else 'automatic failover'}"
353
+ f" · options: auto, {', '.join(p.name for p in self.router.providers)}")
354
+ return
355
+ if name == "auto":
356
+ self.router.pinned = None
357
+ elif self.router.get(name):
358
+ self.router.pinned = name
359
+ else:
360
+ self.ui.error(f"no provider {name!r} (configured: {', '.join(p.name for p in self.router.providers)})")
361
+ return
362
+ self.prefs["pinned"] = self.router.pinned
363
+ save_prefs(self.prefs)
364
+ self.ui.ok("automatic failover across all providers" if name == "auto"
365
+ else f"using only {name} ({self.router.get(name).model}); /provider auto to undo")
366
+
367
+ def cmd_skills(self, arg):
368
+ """/skills → Forge's own + a count per Claude plugin · /skills <words> → search all · /skills all"""
369
+ from . import skills as skillmod
370
+ self.agent.reload_skills()
371
+ sk = self.agent.skills
372
+ if not sk:
373
+ self.ui.info("no skills yet — /skill new <name> creates one in ~/.forge/skills")
374
+ return
375
+ arg = (arg or "").strip()
376
+ if arg == "sync":
377
+ self.ui.info(f"copying Claude Code's skills from {skillmod.CLAUDE_DIR} …")
378
+ try:
379
+ info = skillmod.sync()
380
+ except Exception as e:
381
+ self.ui.error(f"sync failed, kept the old copy: {e}")
382
+ return
383
+ self.agent.reload_skills()
384
+ n = len({s.path for s in self.agent.skills.values() if s.source == "claude"})
385
+ self.ui.info(f"{n} skills from {len(info['plugins'])} plugins copied to {skillmod.LIBRARY} — "
386
+ "they now work even without Claude Code")
387
+ return
388
+ if arg and arg != "all":
389
+ hits = skillmod.find(sk, arg, 15)
390
+ rows = [[s.name, _clip(s.description, 110), s.origin or s.source] for s in hits]
391
+ self.ui.table(f"skills matching '{arg}'", ["name", "description", "from"], rows,
392
+ ["accent", None, "faint"])
393
+ return
394
+ unique = {s.path: s for s in sk.values()}.values()
395
+ if arg == "all":
396
+ rows = sorted([s.name, _clip(s.description, 90), s.origin or s.source] for s in unique)
397
+ self.ui.table(f"all {len(rows)} skills", ["name", "description", "from"], rows,
398
+ ["accent", None, "faint"])
399
+ return
400
+ rows = [[s.name, _clip(s.description, 110), s.source] for s in skillmod.listed(sk)]
401
+ self.ui.table("Forge skills", ["name", "description", "source"], rows, ["accent", None, "faint"])
402
+ bundled = sorted(s.name for s in unique if s.source == "bundled")
403
+ if bundled:
404
+ self.ui.info(f"plus {len(bundled)} engineering skills built into Forge (no Claude Code needed): "
405
+ + ", ".join(bundled))
406
+ counts: dict[str, int] = {}
407
+ for s in unique:
408
+ if s.source == "claude":
409
+ counts[s.origin] = counts.get(s.origin, 0) + 1
410
+ if counts:
411
+ synced = skillmod.synced_info().get("synced", "")
412
+ where = (f"Forge's own copy in {skillmod.LIBRARY} (synced {synced.replace('T', ' ')[:16]})"
413
+ if synced else f"read from {skillmod.CLAUDE_DIR}")
414
+ self.ui.info(f"plus {sum(counts.values())} from Claude Code, {where}: " +
415
+ ", ".join(f"{k} {v}" for k, v in sorted(counts.items(), key=lambda x: -x[1])))
416
+ self.ui.info("/skills <words> searches · /skills all lists everything · /skill <name> [request] "
417
+ "runs one · /skills sync refreshes the copy · the model finds and uses them itself too")
418
+
419
+ def cmd_skill(self, arg):
420
+ bits = arg.split(maxsplit=1)
421
+ if not bits:
422
+ return self.cmd_skills("")
423
+ if bits[0] == "new":
424
+ name = bits[1].strip() if len(bits) > 1 else ""
425
+ project = name.endswith(" --project")
426
+ name = name.removesuffix(" --project").strip()
427
+ try:
428
+ path = skillmod.create(name, project=project)
429
+ except ValueError as e:
430
+ self.ui.error(f"could not create skill: {e}")
431
+ return
432
+ self.agent.reload_skills()
433
+ self.ui.ok(f"created {path}")
434
+ self.ui.info("edit that file, then run it with /" + name)
435
+ return
436
+ self.agent.reload_skills()
437
+ name = bits[0].lower()
438
+ if name not in self.agent.skills:
439
+ self.ui.error(f"no skill {name!r} — /skills lists them")
440
+ return
441
+ self.run(self.agent.skill_prompt(name, bits[1] if len(bits) > 1 else ""), echo=f"skill: {name}")
442
+
443
+ def cmd_status(self, _):
444
+ rows = []
445
+ for p in self.router.providers:
446
+ for i, (key, ready, state, ok, fail) in enumerate(p.pool.status_rows()):
447
+ rows.append([p.name if i == 0 else "", p.model if i == 0 else "", key,
448
+ Text(state, style="ok" if ready else "warn"), ok, fail])
449
+ self.ui.table("keys", ["provider", "model", "key", "state", "ok today", "failed"], rows,
450
+ ["accent", "muted", None, None, "muted", "muted"])
451
+ resting = []
452
+ now = time.time()
453
+ for p in self.router.providers:
454
+ for m, waits in p.pool.model_benches().items():
455
+ resting.append(f"{p.name} · {m.split('/')[-1]}: {len(waits)} key(s), back in {int(min(waits))}s")
456
+ for (pn, m), until in self.router.model_down.items():
457
+ if pn == p.name and until > now:
458
+ resting.append(f"{pn} · {m.split('/')[-1]}: resting (slow/failed), back in {int(until - now)}s")
459
+ if resting:
460
+ self.ui.info("models resting after limits:\n " + "\n ".join(resting))
461
+ self.ui.info("routing: " + (f"pinned to {self.router.pinned}" if self.router.pinned else "automatic failover"))
462
+
463
+ # keys (NEXT 7) ----------------------------------------------------------------
464
+ def _store(self) -> keyvault.KeyStore:
465
+ return keyvault.KeyStore(getattr(self.agent, "keystore_path", None))
466
+
467
+ def _secret(self, prompt: str) -> str:
468
+ reader = getattr(self, "secret_input", None)
469
+ if reader:
470
+ return reader(prompt).strip()
471
+ import getpass
472
+ try:
473
+ return getpass.getpass(f" {prompt}: ").strip()
474
+ except (EOFError, KeyboardInterrupt):
475
+ return ""
476
+
477
+ def _line(self, prompt: str) -> str:
478
+ reader = getattr(self, "line_input", None)
479
+ if reader:
480
+ return reader(prompt).strip()
481
+ try:
482
+ return console.input(f" [muted]{prompt}[/] [accent]›[/] ").strip()
483
+ except (EOFError, KeyboardInterrupt):
484
+ return ""
485
+
486
+ def key_health(self, provider: str, key: str) -> tuple[int, int]:
487
+ """(successes, failures) for one key from ~/.forge/usage.json."""
488
+ for name, row in self.agent.tools.usage.report():
489
+ if name == f"key:{provider}:{keyvault.fingerprint(key)}":
490
+ return row["calls"] - row["failures"], row["failures"]
491
+ return 0, 0
492
+
493
+ def failing_keys(self) -> list[str]:
494
+ out = []
495
+ for p in self.router.providers:
496
+ for k in p.pool.keys:
497
+ ok, fail = self.key_health(p.name, k)
498
+ if fail >= 3 and ok == 0:
499
+ out.append(f"{p.name} {keyvault.mask(k)} ({fail} failures, 0 ok)")
500
+ return out
501
+
502
+ def add_key(self, key: str, provider: str | None = None) -> bool:
503
+ """Identify, live-test and save one key, then use it right away. Returns True if saved."""
504
+ store, a = self._store(), self.agent
505
+ tester = getattr(a, "key_tester", None) or keyvault.test_key
506
+ probe = getattr(a, "key_probe", None) or keyvault.identify
507
+ eps = a.key_endpoints(store)
508
+ guesses = keyvault.candidates(key)
509
+ provider = provider or keyvault.detect_provider(key)
510
+ if not provider:
511
+ self.ui.info("checking which provider accepts this key…")
512
+ provider = probe(key, eps, guesses or None)
513
+ if not provider:
514
+ ans = self._line("which provider is it for? (name, or the base URL of an OpenAI-compatible API · "
515
+ "enter = keep it unfiled)")
516
+ if ans.lower().startswith(("http://", "https://")):
517
+ name = self._line("short name for this provider").lower().replace(" ", "-")
518
+ model = self._line("model id to use")
519
+ if not (name and model):
520
+ self.ui.error("need a name and a model; key not saved")
521
+ return False
522
+ store.add_custom(name, ans, model)
523
+ eps[name] = (ans.rstrip("/"), model)
524
+ provider = name
525
+ elif ans:
526
+ provider = ans.lower()
527
+ else:
528
+ store.add(key, None, guesses)
529
+ self.ui.warn(f"saved unidentified key {keyvault.mask(key)} · /keys file <n> <provider> to use it")
530
+ return True
531
+ if provider in eps:
532
+ ok, why = tester(eps[provider][0], key, eps[provider][1])
533
+ if not ok and ("rejected" in why or "no credit" in why):
534
+ self.ui.error(f"✗ {provider} key {keyvault.mask(key)}: {why} — not saved")
535
+ return False
536
+ else:
537
+ ok, why = False, (f"no endpoint known for {provider!r} · /keys provider {provider} <base_url> <model>")
538
+ store.add(key, provider)
539
+ base, model = eps.get(provider, ("", ""))
540
+ self.router.add_key(provider, key, base, model)
541
+ (self.ui.ok if ok else self.ui.warn)(f"{'✓' if ok else '•'} {provider} key {keyvault.mask(key)} saved · {why}")
542
+ return True
543
+
544
+ def cmd_keys(self, arg):
545
+ store = self._store()
546
+ words = arg.split()
547
+ sub = words[0].lower() if words else ""
548
+ if sub == "add":
549
+ key = " ".join(words[1:]) or self._secret("paste the key (hidden, enter = cancel)")
550
+ if key:
551
+ self.add_key(key)
552
+ return
553
+ if sub == "remove":
554
+ if len(words) != 3 or not words[2].isdigit():
555
+ self.ui.error("usage: /keys remove <provider> <n> (numbers from /keys)")
556
+ return
557
+ prov = keyvault.UNFILED if words[1].lower() == "unfiled" else words[1].lower()
558
+ keys = [k for k, _ in store.unfiled()] if prov == keyvault.UNFILED else store.load().get(prov, [])
559
+ n = int(words[2])
560
+ if not 0 <= n < len(keys):
561
+ self.ui.error(f"no stored key {prov} #{n} (keys set in .env are removed by editing .env)")
562
+ return
563
+ store.remove(prov, n)
564
+ self.router.remove_key(prov, keys[n])
565
+ self.ui.ok(f"removed {prov} key {keyvault.mask(keys[n])}")
566
+ return
567
+ if sub == "file":
568
+ if len(words) != 3 or not words[1].isdigit():
569
+ self.ui.error("usage: /keys file <n> <provider> (n from the unfiled list in /keys)")
570
+ return
571
+ key = store.file(int(words[1]), words[2].lower())
572
+ if not key:
573
+ self.ui.error(f"no unfiled key #{words[1]}")
574
+ return
575
+ c = store.custom().get(words[2].lower(), {})
576
+ used = self.router.add_key(words[2].lower(), key, c.get("base_url", ""), c.get("model", ""))
577
+ self.ui.ok(f"filed {keyvault.mask(key)} under {words[2].lower()}"
578
+ + ("" if used else " · no endpoint yet: /keys provider <name> <base_url> <model>"))
579
+ return
580
+ if sub == "provider":
581
+ if len(words) != 4:
582
+ self.ui.error("usage: /keys provider <name> <base_url> <model>")
583
+ return
584
+ name, url, model = words[1].lower(), words[2], words[3]
585
+ store.add_custom(name, url, model)
586
+ for k in store.load().get(name, []):
587
+ self.router.add_key(name, k, url, model)
588
+ self.ui.ok(f"provider {name} → {url} · {model}")
589
+ return
590
+ if sub == "test":
591
+ tester = getattr(self.agent, "key_tester", None) or keyvault.test_key
592
+ want = words[1].lower() if len(words) > 1 else ""
593
+ for p in self.router.providers:
594
+ if p.pool.keys == [keyvault.ANON] or (want and p.name != want):
595
+ continue
596
+ for k in p.pool.keys:
597
+ ok, why = tester(p.base_url, k, p.model)
598
+ (self.ui.ok if ok else self.ui.error)(f"{'✓' if ok else '✗'} {p.name} {keyvault.mask(k)}: {why}")
599
+ probe = getattr(self.agent, "key_probe", None) or keyvault.identify
600
+ for i, (k, guesses) in reversed(list(enumerate(store.unfiled()))):
601
+ found = probe(k, self.agent.key_endpoints(store), guesses or None)
602
+ if found:
603
+ store.file(i, found)
604
+ self.router.add_key(found, k)
605
+ self.ui.ok(f"✓ unfiled key {keyvault.mask(k)} is a {found} key; filed and in use")
606
+ return
607
+ if sub:
608
+ self.ui.error("unknown /keys option — /help lists them")
609
+ return
610
+ stored = store.load()
611
+ rows = []
612
+ for p in self.router.providers:
613
+ if p.pool.keys == [keyvault.ANON]:
614
+ continue
615
+ for k in p.pool.keys:
616
+ ok, fail = self.key_health(p.name, k)
617
+ where = f"stored #{stored[p.name].index(k)}" if k in stored.get(p.name, []) else ".env"
618
+ state = "ready" if p.pool.usable(k) else "resting"
619
+ if fail >= 3 and ok == 0:
620
+ state = "failing: /keys test"
621
+ rows.append([p.name, keyvault.mask(k), where, Text(state, style="ok" if state == "ready" else "warn"),
622
+ ok, fail])
623
+ for name, keys in stored.items():
624
+ if not self.router.get(name):
625
+ for i, k in enumerate(keys):
626
+ rows.append([name, keyvault.mask(k), f"stored #{i}", Text("no endpoint", style="warn"), 0, 0])
627
+ for i, (k, guesses) in enumerate(store.unfiled()):
628
+ rows.append(["unfiled", keyvault.mask(k), f"#{i}", Text("maybe " + "/".join(guesses) if guesses
629
+ else "unknown", style="warn"), 0, 0])
630
+ if not rows:
631
+ self.ui.info("no API keys yet — Forge is using the free keyless models · /setup or /keys add")
632
+ return
633
+ self.ui.table("api keys", ["provider", "key", "from", "state", "ok", "failed"], rows,
634
+ ["accent", None, "muted", None, "muted", "muted"])
635
+ self.ui.info(f"stored in {'the OS keychain' if store.backend == 'keyring' else store.path} · "
636
+ "/keys add · /keys test · /keys remove <provider> <n>")
637
+
638
+ def cmd_setup(self, _):
639
+ names = list(keyvault.SIGNUP)
640
+ have = {p.name for p in self.router.providers if p.pool.keys != [keyvault.ANON]}
641
+ self.ui.info("Forge already works with free keyless models. Your own keys give faster, stronger ones.")
642
+ self.ui.table("providers", ["#", "provider", "cost", "good at", "you have"],
643
+ [[i + 1, n, *keyvault.SIGNUP[n][:2], "✓" if n in have else ""] for i, n in enumerate(names)],
644
+ ["muted", "accent", None, "muted", "ok"])
645
+ while True:
646
+ ans = self._line("number = open its key page · k = paste a key · enter = done")
647
+ if not ans:
648
+ return
649
+ if keyvault.find_keys(ans): # pasted straight in: still accepted
650
+ for k in keyvault.find_keys(ans):
651
+ self.add_key(k)
652
+ continue
653
+ provider = None
654
+ if ans.isdigit() and 1 <= int(ans) <= len(names):
655
+ provider = names[int(ans) - 1]
656
+ url = keyvault.SIGNUP[provider][2]
657
+ self.ui.info(f"opening {url}")
658
+ try:
659
+ import webbrowser
660
+ webbrowser.open(url)
661
+ except Exception:
662
+ pass
663
+ elif ans.lower() != "k":
664
+ continue
665
+ key = self._secret(f"paste the {provider or 'API'} key (hidden, enter = skip)")
666
+ if key:
667
+ found = keyvault.detect_provider(key)
668
+ self.add_key(key, provider if not found or found == provider else found)
669
+
670
+ def cmd_doctor(self, _):
671
+ a = self.agent
672
+ lines = a.tools.doctor_report().splitlines()
673
+ now = time.time()
674
+ for provider in a.router.providers:
675
+ for key, ready, state, ok, fail in provider.pool.status_rows():
676
+ if fail:
677
+ lines.append(f"Key {provider.name}:{key}: {fail} recorded failure(s), {state}")
678
+ for (provider, model), until in a.router.model_down.items():
679
+ if until > now:
680
+ lines.append(f"Model {provider}:{model}: resting for {int(until - now)}s after a failure")
681
+ used_skills = {key.split(":", 1)[1] for key, _ in a.tools.usage.report()
682
+ if key.startswith("skill:")}
683
+ unique_skills = {skill.path: skill for skill in a.skills.values()}
684
+ unused = sorted(skill.name for skill in unique_skills.values() if skill.name not in used_skills)
685
+ if unused:
686
+ preview = ", ".join(unused[:10]) + ("…" if len(unused) > 10 else "")
687
+ lines.append(f"Skills never used in recorded sessions: {len(unused)} ({preview})")
688
+ for line in lines:
689
+ self.ui.info(line)
690
+
691
+ def cmd_yes(self, _):
692
+ t = self.agent.tools
693
+ self.cmd_approve("smart" if t.mode == "auto" else "auto")
694
+
695
+ def cmd_approve(self, arg):
696
+ t = self.agent.tools
697
+ arg = arg.strip().lower()
698
+ if arg not in ("smart", "ask", "auto"):
699
+ self.ui.info(f"approval is {t.mode}: {APPROVAL_TEXT[t.mode]}")
700
+ self.ui.info("change it with /approve smart | ask | auto")
701
+ return
702
+ t.mode = arg
703
+ t.always.clear()
704
+ (self.ui.warn if arg == "auto" else self.ui.ok)(f"approval: {APPROVAL_TEXT[arg]}")
705
+
706
+ def cmd_balance(self, _):
707
+ a = self.agent
708
+ self.ui.info(f"this session: {a.used_in:,} tokens sent · {a.used_out:,} received · "
709
+ f"current context ~{a.ctx_tokens:,} tokens")
710
+ rows = []
711
+ for p in self.router.providers:
712
+ if not p.pool.keys:
713
+ continue
714
+ left = p.remaining()
715
+ req = f"{left['req'][0]}/{left['req'][1]}" if left.get("req") else "—"
716
+ tok = f"{ktok(left['tok'][0])}/{ktok(left['tok'][1])}" if left.get("tok") else "—"
717
+ ready = sum(1 for r in p.pool.status_rows() if r[1])
718
+ rows.append([p.name, p.model, f"{ready}/{len(p.pool.keys)}", req, tok])
719
+ self.ui.table("rate limits (from the last reply of each provider)",
720
+ ["provider", "model", "keys ready", "requests left", "tokens left"], rows,
721
+ ["accent", "muted", "", "", ""])
722
+ or_ = self.router.get("openrouter")
723
+ if or_ and or_.pool.keys:
724
+ self.ui.start_spinner("Checking OpenRouter credits")
725
+ try:
726
+ lines = or_.credits()
727
+ finally:
728
+ self.ui.stop_spinner()
729
+ for ln in lines:
730
+ self.ui.info(" openrouter " + ln)
731
+ self.ui.info("Most free providers don't publish a money balance; '—' means no reply from them yet.")
732
+
733
+ def cmd_cost(self, _):
734
+ self.ui.info(f"this session: {self.agent.used_in:,} input tokens · "
735
+ f"{self.agent.used_out:,} output tokens")
736
+ from . import prices
737
+ lines, total, unknown = prices.summary(getattr(self.agent, "by_model", {}))
738
+ for ln in lines:
739
+ self.ui.info(" " + ln)
740
+ if lines:
741
+ self.ui.info(f"money: about ${total:,.4f}" + (" plus models with unknown prices" if unknown else "") +
742
+ " (list prices, cached-input discounts not counted; free tiers cost $0; "
743
+ "your own prices go in ~/.forge/prices.json)")
744
+
745
+ def cmd_ads(self, arg):
746
+ from . import ads as adsmod
747
+ word = arg.strip().lower()
748
+ st = adsmod.load_state()
749
+ if word == "off":
750
+ st["off"] = True
751
+ adsmod.save_state(st)
752
+ self.ads = None
753
+ self.ui.info("Sponsor lines are off for good on this computer (/ads on brings them back).")
754
+ elif word == "on":
755
+ st["off"] = False
756
+ adsmod.save_state(st)
757
+ self.ui.info("Sponsor lines allowed again: at most one, on the goodbye screen.")
758
+ elif word == "reset":
759
+ st.pop("id", None)
760
+ st.pop("cached", None)
761
+ adsmod.install_id(st)
762
+ self.ui.info("New random install id made; the old one is forgotten here.")
763
+ elif word in ("", "status"):
764
+ ok, why = adsmod.allowed(st)
765
+ self.ui.info(f"sponsor line: {'on' if ok else 'not shown — ' + why}")
766
+ self.ui.info("Shown only on the goodbye screen, never in pipes, scripts, CI or forge -p. The ad request "
767
+ "sends only Forge's version and a random install id — no prompts, code, paths or keys. "
768
+ "Ads must be signed by Forge's key, are cleaned to plain text and never reach the model.")
769
+ self.ui.info("Turn off: /ads off, or FORGE_ADS=0. New install id: /ads reset.")
770
+ else:
771
+ self.ui.error("use /ads status, /ads off, /ads on or /ads reset")
772
+
773
+ def _github_login(self):
774
+ from . import ghlogin, keyvault
775
+ cid = ghlogin.client_id()
776
+ if not cid:
777
+ self.ui.warn("GitHub sign-in isn't set up in this build (no OAuth client id). Set "
778
+ "FORGE_GITHUB_CLIENT_ID, run `gh auth login`, or add a token with /keys.")
779
+ return
780
+ try:
781
+ device = ghlogin.start(cid)
782
+ except Exception as e:
783
+ self.ui.error(f"couldn't start GitHub sign-in: {e}")
784
+ return
785
+ url = device.get("verification_uri", "https://github.com/login/device")
786
+ self.ui.info(f"Open {url} and enter this code: {device['user_code']}")
787
+ try:
788
+ import webbrowser
789
+ webbrowser.open(url)
790
+ except Exception:
791
+ pass
792
+ self.ui.start_spinner("waiting for you to approve on GitHub (ctrl+c cancels)")
793
+ try:
794
+ token = ghlogin.poll(cid, device)
795
+ except KeyboardInterrupt:
796
+ self.ui.stop_spinner()
797
+ self.ui.warn("sign-in cancelled")
798
+ return
799
+ except Exception as e:
800
+ self.ui.stop_spinner()
801
+ self.ui.error(f"GitHub sign-in failed: {e}")
802
+ return
803
+ self.ui.stop_spinner()
804
+ keyvault.KeyStore().add(token, "github")
805
+ who = ghlogin.whoami(token)
806
+ self.ui.info(f"signed in to GitHub{' as ' + who if who else ''}; the token is saved in Forge's key store")
807
+
808
+ def cmd_github(self, arg):
809
+ sub = arg.strip().lower()
810
+ if sub == "login":
811
+ return self._github_login()
812
+ if sub == "logout":
813
+ from . import keyvault
814
+ store, n = keyvault.KeyStore(), 0
815
+ while store.remove("github", 0):
816
+ n += 1
817
+ self.ui.info(f"removed {n} saved GitHub token(s)" if n else "no GitHub token was saved in Forge")
818
+ return
819
+ if sub not in ("", "status"):
820
+ self.ui.error("use /github status, /github login or /github logout")
821
+ return
822
+ if not shutil.which("gh"):
823
+ configured = bool(self.agent.tools.github_token())
824
+ self.ui.info("GitHub token is configured." if configured else
825
+ "GitHub is not connected. Install gh and run `gh auth login` in a terminal, "
826
+ "or add a GitHub token with /keys.")
827
+ return
828
+ try:
829
+ result = subprocess.run(["gh", "auth", "status"], capture_output=True, text=True,
830
+ timeout=8, shell=False)
831
+ except (OSError, subprocess.TimeoutExpired) as exc:
832
+ self.ui.error(f"could not check gh authentication: {exc}")
833
+ return
834
+ output = (result.stdout + result.stderr).strip()
835
+ from . import keyvault
836
+ output = keyvault.redact(output)
837
+ if result.returncode:
838
+ if self.agent.tools.github_token():
839
+ self.ui.warn((output + "\n" if output else "") +
840
+ "The gh CLI is not connected, but a token is configured for GitHub API calls.")
841
+ else:
842
+ self.ui.warn((output + "\n" if output else "") +
843
+ "Connect with `gh auth login` in a separate terminal, or add a GitHub token with /keys.")
844
+ else:
845
+ self.ui.info(output or "gh reports an authenticated GitHub account.")
846
+
847
+ def cmd_memory(self, arg):
848
+ from . import memory as memorymod
849
+ scope = arg.strip().lower() or "project"
850
+ if scope not in ("user", "project"):
851
+ self.ui.error("use /memory [user|project]")
852
+ return
853
+ try:
854
+ note = memorymod.read(scope, str(self.agent.session.cwd))
855
+ except OSError as exc:
856
+ self.ui.error(f"could not read memory: {exc}")
857
+ return
858
+ self.ui.info(note or f"No {scope} memory saved yet.")
859
+
860
+ def _project_command(self, name: str, arguments: str) -> bool:
861
+ if name == "commands":
862
+ directory = pathlib.Path.cwd() / ".forge" / "commands"
863
+ commands = sorted(directory.glob("*.md")) if directory.is_dir() else []
864
+ if commands:
865
+ self.ui.table("project commands", ["command", "file"],
866
+ [[f"/{p.stem}", str(p)] for p in commands], ["accent", "muted"])
867
+ else:
868
+ self.ui.info("No project commands. Add Markdown files to .forge/commands/.")
869
+ return True
870
+ if not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", name):
871
+ return False
872
+ path = pathlib.Path.cwd() / ".forge" / "commands" / f"{name}.md"
873
+ if not path.is_file():
874
+ return False
875
+ try:
876
+ with path.open(encoding="utf-8") as command_file:
877
+ content = command_file.read(12_001)
878
+ except OSError as exc:
879
+ self.ui.error(f"could not read {path}: {exc}")
880
+ return True
881
+ if len(content) > 12_000:
882
+ self.ui.error(f"{path} exceeds the 12,000-character command limit")
883
+ return True
884
+ self.run(content.replace("$ARGUMENTS", arguments), echo=f"command: {name}")
885
+ return True
886
+
887
+ def cmd_parallel(self, arg):
888
+ low = arg.lower()
889
+ if low in ("", "on", "off"):
890
+ self.agent.parallel = (not self.agent.parallel) if not low else low == "on"
891
+ if self.agent.parallel:
892
+ self.ui.ok(f"parallel ON — forge may split independent work across up to "
893
+ f"{self.agent.settings.max_agents} agents at once")
894
+ else:
895
+ self.ui.ok("parallel OFF — one agent at a time")
896
+ return
897
+ tasks = parallel.split_tasks(arg)
898
+ assigned = any(t.startswith("@") for t in tasks)
899
+ if len(tasks) < 2 and not assigned:
900
+ self.ui.error("give at least two tasks separated by ||, e.g. "
901
+ "/parallel write tests for a.py || @minimax add docstrings to b.py")
902
+ return
903
+ report = parallel.run_tasks(self.agent, tasks)
904
+ # Put the reports into the main conversation so every later reply knows what happened.
905
+ self.run("I ran these tasks in parallel with sub-agents. Their reports:\n\n" + report +
906
+ "\n\nGive me a short combined summary, and flag any conflicts or follow-ups.")
907
+
908
+ def cmd_team(self, arg):
909
+ bits = arg.split(maxsplit=1)
910
+ low = bits[0].lower() if bits else ""
911
+ if low == "lead":
912
+ want = bits[1] if len(bits) > 1 else ""
913
+ w = roster.resolve(roster.build(self.router), want)
914
+ if not w:
915
+ self.ui.error(f"no worker matches {want!r}; see /roster")
916
+ return
917
+ self.agent.settings.coordinator = w.id
918
+ low = "on"
919
+ if low not in ("", "on", "off"):
920
+ self.ui.error("use /team, /team on, /team off, or /team lead <worker>")
921
+ return
922
+ on = (not self.agent.team) if not low else low == "on"
923
+ lead = self.agent.set_team(on)
924
+ if on and not lead:
925
+ self.ui.error("no usable model to coordinate; check /status")
926
+ elif on:
927
+ self.ui.ok(f"team ON — {lead} coordinates and hands work to the models best at it "
928
+ f"(up to {self.agent.settings.max_agents} at once)")
929
+ self.ui.info("change the lead with /team lead <worker>; see who's on the team with /roster")
930
+ else:
931
+ self.ui.ok("team OFF — one model at a time with normal failover")
932
+
933
+ def cmd_city(self, arg):
934
+ """Turn the pixel city on or off, calm its motion, or play a short tour of every state."""
935
+ a = (arg or "").strip().lower()
936
+ if a in ("off", "on"):
937
+ self.ui.scene = a == "on"
938
+ self.ui.ok(f"pixel city {a}" + (" (FORGE_SCENE=city in .env turns it on at start; the wheelbarrow stays)"
939
+ if a == "on" else " (the small wheelbarrow still shows progress)"))
940
+ return
941
+ if a in ("calm", "still", "reduce"):
942
+ self.ui.city.motion = not self.ui.city.motion
943
+ self.ui.barrow.motion = self.ui.city.motion
944
+ self.ui.ok("reduced motion " + ("off" if self.ui.city.motion else "on"))
945
+ return
946
+ from rich.live import Live
947
+ from .city import City
948
+ tour = [("processing", "wheelbarrow deliveries: commands and tool runs", 7.0),
949
+ ("coding", "typing at the desk: writing and editing files", 3.0),
950
+ ("thinking", "thinking at the desk, pacing now and then: a model is answering", 4.0),
951
+ ("researching", "pulling documents from the archive: reading, searching, fetching", 5.0),
952
+ ("delegating", "handing parcels to helper cubs: parallel agents", 6.0),
953
+ ("waiting", "coffee on the bench: every model is rate-limited", 3.5),
954
+ ("queued", "tapping a foot: your message is queued", 2.0),
955
+ ("completed", "the task finished", 2.5), ("failed", "something broke", 2.5),
956
+ ("cancelled", "you pressed esc / ctrl+c", 2.0)]
957
+ c = City()
958
+ c.motion = self.ui.city.motion
959
+ width = max(20, console.width - 2)
960
+ try:
961
+ with Live(console=console, refresh_per_second=20, transient=True) as live:
962
+ for state, what, secs in tour:
963
+ if state == "delegating":
964
+ c.set_helpers(2)
965
+ elif state == "waiting":
966
+ c.set_helpers(0)
967
+ c.set(state, wait=secs)
968
+ if state == "processing":
969
+ c.blip()
970
+ end = time.monotonic() + secs
971
+ while time.monotonic() < end:
972
+ live.update(Text.from_ansi(c.frame(width).rstrip("\n") + "\n")
973
+ .append(f" {state:<12}", style="ember").append(what, style="muted"))
974
+ time.sleep(0.05)
975
+ except KeyboardInterrupt:
976
+ pass
977
+ self.ui.info("the city plays automatically while Forge works · /city off hides it · /city calm stills it")
978
+
979
+ def cmd_roster(self, arg):
980
+ low = arg.strip().lower()
981
+ workers = roster.build(self.router)
982
+ if low == "edit":
983
+ roster.write_template(workers)
984
+ self.ui.ok(f"edit {roster.ROSTER_FILE} to describe what each model is good at, then run /roster")
985
+ return
986
+ if low == "bench":
987
+ self.ui.info(f"benching {len(workers)} models (a reasoning check + a tool-call check each)…")
988
+
989
+ def done(w, res):
990
+ self.ui.info(f" {w.id}: {w.measured()}")
991
+ try:
992
+ roster.bench(self.router, workers, on_done=done)
993
+ except KeyboardInterrupt:
994
+ self.ui.warn("bench stopped")
995
+ return
996
+ if self.agent.team: # results can change who should lead
997
+ self.agent.set_team(True)
998
+ self.ui.ok(f"saved to {roster.STATS_FILE}")
999
+ return
1000
+ lead = self.agent.coordinator.id if self.agent.team and self.agent.coordinator else ""
1001
+ from . import quality as qualitymod
1002
+ rows = []
1003
+ workers = sorted(workers, key=lambda w: -qualitymod.score(w.model, w.provider))
1004
+ for w in workers:
1005
+ ok = roster.usable(w, self.router)
1006
+ name = Text(w.id + (" ★ lead" if w.id == lead else ""), style="accent" if ok else "muted")
1007
+ sc = qualitymod.score(w.model, w.provider)
1008
+ rows.append([name, Text(f"{sc}/10", style="ok" if sc >= 8 else "warn" if sc <= 4 else ""),
1009
+ qualitymod.explain(w.model, w.provider), ", ".join(w.strengths),
1010
+ Text(w.measured(), style="ok" if ok else "warn"), w.notes])
1011
+ self.ui.table("team roster · best first", ["worker", "score", "why", "good at", "measured", "notes"],
1012
+ rows, [None, None, "muted", None, None, "muted"])
1013
+ self.ui.info("assign one yourself: /parallel @<worker> <task> || @<worker> <task> · "
1014
+ "describe models: /roster edit · measure: /roster bench")
1015
+
1016
+ def cmd_verbose(self, _):
1017
+ self.ui.verbose = not self.ui.verbose
1018
+ self.ui.ok(f"failover log {'ON' if self.ui.verbose else 'OFF'}")
1019
+
1020
+ # chat ---------------------------------------------------------------------------
1021
+ def run(self, text: str, echo: str = "") -> None:
1022
+ if echo:
1023
+ self.ui.info(f" ↳ {echo}")
1024
+ self.work(lambda: self.agent.run_turn(text))
1025
+
1026
+ STOPPED = "⏹ stopped — say what to do next, or press ↑ to edit your last message"
1027
+ worker: threading.Thread | None = None
1028
+
1029
+ def work(self, fn, stopped: str = STOPPED) -> None:
1030
+ """Run a task. In the chat (docked) it runs in the background so the message box stays
1031
+ open: you can type more for it, answer its questions, or stop it. Otherwise it blocks."""
1032
+ if not self.ui.docked:
1033
+ self._guarded(fn, stopped)
1034
+ return
1035
+ self.worker = threading.Thread(target=self._background, args=(fn, stopped), daemon=True,
1036
+ name="forge-turn")
1037
+ self.worker.start()
1038
+
1039
+ def _guarded(self, fn, stopped: str) -> None:
1040
+ self.ui.act("queued")
1041
+ try:
1042
+ fn()
1043
+ # the city acts out how the turn really ended
1044
+ self.ui.act("completed" if getattr(self.agent, "last_turn_ok", True) else "failed")
1045
+ except KeyboardInterrupt:
1046
+ self.ui.act("cancelled")
1047
+ self.ui.pending = None
1048
+ self.ui.stop_spinner()
1049
+ self.ui.flush()
1050
+ self.ui.warn(stopped)
1051
+
1052
+ def _background(self, fn, stopped: str) -> None:
1053
+ try:
1054
+ self._guarded(fn, stopped)
1055
+ # Messages typed after the model's last step start a follow-up turn.
1056
+ while self.agent.inbox and not self._stopping:
1057
+ text = "\n\n".join(self.agent.inbox)
1058
+ self.agent.inbox.clear()
1059
+ self._guarded(lambda: self.agent.run_turn(text), self.STOPPED)
1060
+ except KeyboardInterrupt:
1061
+ pass # a stop that landed just as the task was finishing
1062
+ except Exception as exc: # never let a crash kill the chat silently
1063
+ self.ui.stop_spinner()
1064
+ self.ui.error(f"internal error: {type(exc).__name__}: {exc}")
1065
+ finally:
1066
+ self.ui.busy = False
1067
+ self._stopping = False
1068
+ self.worker = None
1069
+ if self._redraw:
1070
+ self._redraw()
1071
+
1072
+ _stopping = False
1073
+ _redraw = None
1074
+
1075
+ def busy(self) -> bool:
1076
+ return self.worker is not None and self.worker.is_alive()
1077
+
1078
+ def stop(self) -> None:
1079
+ """Stop the running task, as ctrl+c did when the task ran in the foreground."""
1080
+ w = self.worker
1081
+ if not (w and w.is_alive()):
1082
+ return
1083
+ self._stopping = True
1084
+ self.agent.inbox.clear()
1085
+ # Raise KeyboardInterrupt inside the task's thread, so every existing ctrl+c path runs
1086
+ # (shell commands are killed, unanswered tool calls get results, the turn is saved).
1087
+ import ctypes
1088
+ ctypes.pythonapi.PyThreadState_SetAsyncExc(ctypes.c_ulong(w.ident), ctypes.py_object(KeyboardInterrupt))
1089
+ p = self.ui.pending
1090
+ if p:
1091
+ p["stopped"] = True
1092
+ p["event"].set()
1093
+
1094
+ def goodbye(self) -> int:
1095
+ """Clear the screen and show the waving mascot, with how to pick this conversation up again."""
1096
+ s = self.agent.session
1097
+ lines: list[tuple[str, str]] = []
1098
+ if s.messages:
1099
+ lines.append((f"saved: {s.title or s.id}", "muted"))
1100
+ lines.append((f"forge -c continues it here · forge -r {s.id[:8]} from anywhere", "faint"))
1101
+ else:
1102
+ lines.append(("forge -r picks up any saved conversation", "faint"))
1103
+ ad = getattr(self, "ads", None)
1104
+ sponsor = ad.line() if ad else None
1105
+ if sponsor:
1106
+ lines.append((sponsor, "faint"))
1107
+ try:
1108
+ uimod.goodbye(lines, animate=os.environ.get("FORGE_BYE_ANIMATION", "1") != "0")
1109
+ except Exception:
1110
+ console.print("bye")
1111
+ return 0
1112
+
1113
+ def loop(self, replay: Session | None = None, pick: bool = False) -> int:
1114
+ try:
1115
+ from . import ads as adsmod
1116
+ self.ads = adsmod.Ads(__version__) # fetches quietly in the background, if allowed at all
1117
+ except Exception:
1118
+ self.ads = None
1119
+ prompt = make_prompt(self)
1120
+ self.banner()
1121
+ if os.environ.get("FORGE_AUTO_BENCH", "1").strip().lower() not in ("0", "off", "false", "no"):
1122
+ # New models get a quick test in the background (two tiny requests each) so their
1123
+ # capability scores are measured, not just guessed from the name.
1124
+ from . import quality as qualitymod
1125
+ qualitymod.auto_bench(self.router)
1126
+ if pick:
1127
+ self.cmd_resume("")
1128
+ if replay:
1129
+ self.ui.rule(replay.title or replay.id)
1130
+ self.ui.replay(replay.messages)
1131
+ docked = getattr(prompt, "docked", False)
1132
+ if docked:
1133
+ from prompt_toolkit.patch_stdout import patch_stdout
1134
+ self.ui.docked = True
1135
+ with patch_stdout(raw=True): # output from the task thread prints above the box
1136
+ return self._docked_loop(prompt)
1137
+ while True:
1138
+ try:
1139
+ text = prompt()
1140
+ except KeyboardInterrupt:
1141
+ self.ui.info("(ctrl+d or /exit to quit)")
1142
+ continue
1143
+ except EOFError:
1144
+ return self.goodbye()
1145
+ text = text.replace(BOM, "").strip() # PowerShell pipes can prepend one
1146
+ if not text:
1147
+ continue
1148
+ if text.startswith("/") and not text.startswith("//"):
1149
+ if not self.handle(text):
1150
+ return self.goodbye()
1151
+ console.print()
1152
+ continue
1153
+ self.run(text.removeprefix("/"))
1154
+
1155
+ def _docked_loop(self, prompt) -> int:
1156
+ """The chat with the message box always open, even while a task runs."""
1157
+ while True:
1158
+ try:
1159
+ text = prompt()
1160
+ except KeyboardInterrupt:
1161
+ if self.busy():
1162
+ self.stop()
1163
+ else:
1164
+ self.ui.info("(ctrl+d or /exit to quit)")
1165
+ continue
1166
+ except EOFError:
1167
+ if self.busy():
1168
+ self.stop()
1169
+ return self.goodbye()
1170
+ text = text.replace(BOM, "").strip() # PowerShell pipes can prepend one
1171
+ if self.ui.pending: # Forge asked something: this is the answer
1172
+ self.ui.answer(text)
1173
+ continue
1174
+ if not text:
1175
+ continue
1176
+ command = text.startswith("/") and not text.startswith("//")
1177
+ if self.busy():
1178
+ if command and text.split()[0].lower() in ("/stop", "/cancel"):
1179
+ self.stop()
1180
+ elif command and text.split()[0].lower() in ("/exit", "/quit"):
1181
+ self.stop()
1182
+ return self.goodbye()
1183
+ elif command:
1184
+ self.ui.info("Forge is working — type a message to add it to the task, "
1185
+ "or stop it first (esc, ctrl+c or /stop)")
1186
+ else:
1187
+ self.agent.inbox.append(text.removeprefix("/"))
1188
+ self.ui.queued()
1189
+ continue
1190
+ if command:
1191
+ if not self.handle(text):
1192
+ return self.goodbye()
1193
+ console.print()
1194
+ continue
1195
+ self.run(text.removeprefix("/"))
1196
+
1197
+
1198
+ def _clip(text: str, n: int) -> str:
1199
+ """Shorten at a word boundary with an ellipsis instead of cutting mid-word."""
1200
+ text = " ".join(text.split())
1201
+ if len(text) <= n:
1202
+ return text
1203
+ return text[:n - 1].rsplit(" ", 1)[0].rstrip(" ,;:-") + "…"
1204
+
1205
+
1206
+ def _short(path: str) -> str:
1207
+ home = os.path.expanduser("~")
1208
+ return "~" + path[len(home):] if path.lower().startswith(home.lower()) else path
1209
+
1210
+
1211
+ class _PromptBuffer(Protocol):
1212
+ def insert_text(self, text: str) -> None: ...
1213
+
1214
+
1215
+ class _PromptEvent(Protocol):
1216
+ current_buffer: _PromptBuffer
1217
+
1218
+
1219
+ def _windows_clipboard_text() -> str:
1220
+ """Read plain text from the Windows clipboard without an optional package."""
1221
+ if os.name != "nt":
1222
+ return ""
1223
+ import ctypes
1224
+ from ctypes import wintypes
1225
+
1226
+ user32 = ctypes.WinDLL("user32", use_last_error=True)
1227
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
1228
+ user32.OpenClipboard.argtypes = [wintypes.HWND]
1229
+ user32.OpenClipboard.restype = wintypes.BOOL
1230
+ user32.GetClipboardData.argtypes = [wintypes.UINT]
1231
+ user32.GetClipboardData.restype = wintypes.HANDLE
1232
+ user32.CloseClipboard.argtypes = []
1233
+ user32.CloseClipboard.restype = wintypes.BOOL
1234
+ kernel32.GlobalLock.argtypes = [wintypes.HGLOBAL]
1235
+ kernel32.GlobalLock.restype = ctypes.c_void_p
1236
+ kernel32.GlobalUnlock.argtypes = [wintypes.HGLOBAL]
1237
+ kernel32.GlobalUnlock.restype = wintypes.BOOL
1238
+
1239
+ opened = False
1240
+ for _ in range(5):
1241
+ if user32.OpenClipboard(None):
1242
+ opened = True
1243
+ break
1244
+ time.sleep(0.02)
1245
+ if not opened:
1246
+ raise OSError(ctypes.get_last_error(), "could not open the Windows clipboard")
1247
+ try:
1248
+ handle = user32.GetClipboardData(13) # CF_UNICODETEXT
1249
+ if not handle:
1250
+ return ""
1251
+ pointer = kernel32.GlobalLock(handle)
1252
+ if not pointer:
1253
+ raise OSError(ctypes.get_last_error(), "could not read text from the Windows clipboard")
1254
+ try:
1255
+ return ctypes.wstring_at(pointer)
1256
+ finally:
1257
+ kernel32.GlobalUnlock(handle)
1258
+ finally:
1259
+ user32.CloseClipboard()
1260
+
1261
+
1262
+ def _windows_clipboard_files() -> list[str]:
1263
+ """Paths of files copied in Explorer (CF_HDROP), or []."""
1264
+ if os.name != "nt":
1265
+ return []
1266
+ import ctypes
1267
+ from ctypes import wintypes
1268
+
1269
+ user32 = ctypes.WinDLL("user32", use_last_error=True)
1270
+ shell32 = ctypes.WinDLL("shell32", use_last_error=True)
1271
+ user32.OpenClipboard.argtypes = [wintypes.HWND]
1272
+ user32.IsClipboardFormatAvailable.argtypes = [wintypes.UINT]
1273
+ user32.GetClipboardData.argtypes = [wintypes.UINT]
1274
+ user32.GetClipboardData.restype = wintypes.HANDLE
1275
+ shell32.DragQueryFileW.argtypes = [wintypes.HANDLE, wintypes.UINT, wintypes.LPWSTR, wintypes.UINT]
1276
+ shell32.DragQueryFileW.restype = wintypes.UINT
1277
+ if not user32.IsClipboardFormatAvailable(15) or not user32.OpenClipboard(None): # CF_HDROP
1278
+ return []
1279
+ try:
1280
+ handle = user32.GetClipboardData(15)
1281
+ if not handle:
1282
+ return []
1283
+ out = []
1284
+ for i in range(shell32.DragQueryFileW(handle, 0xFFFFFFFF, None, 0)):
1285
+ buf = ctypes.create_unicode_buffer(shell32.DragQueryFileW(handle, i, None, 0) + 1)
1286
+ shell32.DragQueryFileW(handle, i, buf, len(buf))
1287
+ out.append(buf.value)
1288
+ return out
1289
+ finally:
1290
+ user32.CloseClipboard()
1291
+
1292
+
1293
+ def _windows_clipboard_image() -> str:
1294
+ """Save a clipboard image (screenshot, copied picture) as a PNG in ~/.forge/pastes; return its path."""
1295
+ if os.name != "nt":
1296
+ return ""
1297
+ import ctypes
1298
+ user32 = ctypes.WinDLL("user32")
1299
+ if not (user32.IsClipboardFormatAvailable(8) or user32.IsClipboardFormatAvailable(17)): # CF_DIB / CF_DIBV5
1300
+ return ""
1301
+ folder = HOME_DIR / "pastes"
1302
+ folder.mkdir(parents=True, exist_ok=True)
1303
+ path = folder / time.strftime("paste-%Y%m%d-%H%M%S.png")
1304
+ script = ("Add-Type -AssemblyName System.Windows.Forms,System.Drawing;"
1305
+ "$i=[System.Windows.Forms.Clipboard]::GetImage();"
1306
+ f"if($i){{$i.Save('{path}',[System.Drawing.Imaging.ImageFormat]::Png)}}")
1307
+ try:
1308
+ subprocess.run(["powershell", "-NoProfile", "-STA", "-Command", script],
1309
+ capture_output=True, timeout=15)
1310
+ except (OSError, subprocess.TimeoutExpired):
1311
+ return ""
1312
+ return str(path) if path.exists() else ""
1313
+
1314
+
1315
+ def _quote_path(p: str) -> str:
1316
+ return f'"{p}"' if " " in p else p
1317
+
1318
+
1319
+ def _paste_clipboard(event: _PromptEvent, read_text: Callable[[], str] | None = None,
1320
+ read_files: Callable[[], list[str]] | None = None,
1321
+ read_image: Callable[[], str] | None = None) -> str:
1322
+ """Insert what is on the clipboard: text as is, files copied in Explorer as their paths,
1323
+ an image (screenshot) saved as a PNG and inserted as its path. Returns what was inserted."""
1324
+ text = (read_text or _windows_clipboard_text)()
1325
+ if not text:
1326
+ text = " ".join(_quote_path(f) for f in (read_files or _windows_clipboard_files)())
1327
+ if not text:
1328
+ image = (read_image or _windows_clipboard_image)()
1329
+ text = _quote_path(image) if image else ""
1330
+ if text:
1331
+ event.current_buffer.insert_text(text.replace("\r\n", "\n").replace("\r", "\n"))
1332
+ return text
1333
+
1334
+
1335
+ # --- input -----------------------------------------------------------------------
1336
+
1337
+ def make_prompt(app: App):
1338
+ """Fancy prompt_toolkit input when attached to a terminal, plain input() otherwise."""
1339
+ if not (sys.stdin.isatty() and sys.stdout.isatty()):
1340
+ def plain():
1341
+ lines = []
1342
+ while True:
1343
+ line = input("› " if not lines else " ")
1344
+ if line.endswith("\\"):
1345
+ lines.append(line[:-1])
1346
+ continue
1347
+ lines.append(line)
1348
+ return "\n".join(lines)
1349
+ return plain
1350
+
1351
+ from prompt_toolkit import PromptSession
1352
+ from prompt_toolkit.completion import Completer, Completion
1353
+ from prompt_toolkit.formatted_text import ANSI, FormattedText, to_formatted_text
1354
+ from prompt_toolkit.history import FileHistory
1355
+ from prompt_toolkit.key_binding import KeyBindings
1356
+ from prompt_toolkit.styles import Style
1357
+
1358
+ class SlashCompleter(Completer):
1359
+ def get_completions(self, document, complete_event):
1360
+ text = document.text_before_cursor
1361
+ if not text.startswith("/"):
1362
+ return
1363
+ words = text.split(" ")
1364
+ if len(words) == 1:
1365
+ cands = [(k, v) for k, v in COMMANDS.items()]
1366
+ cands += [("/" + n, "skill · " + s.description) for n, s in app.agent.skills.items()]
1367
+ for name, meta in cands:
1368
+ if name.startswith(words[0].lower()):
1369
+ yield Completion(name, -len(words[0]), display_meta=meta[:60])
1370
+ return
1371
+ cmd, cur = ALIASES.get(words[0].lower(), words[0].lower()), words[-1]
1372
+ pos = len(words) - 1
1373
+ if cmd in ("/model", "/models", "/provider") and pos == 1:
1374
+ opts = [(p.name, p.model) for p in app.router.providers]
1375
+ if cmd == "/provider":
1376
+ opts.append(("auto", "automatic failover"))
1377
+ if cmd == "/model":
1378
+ lead = app.router.lead()
1379
+ opts += [(m, lead.name) for m in app.model_cache.get(lead.name, [])] if lead else []
1380
+ for name, meta in opts:
1381
+ if cur.lower() in name.lower():
1382
+ yield Completion(name, -len(cur), display_meta=meta)
1383
+ elif cmd == "/model" and pos == 2 and app.router.get(words[1].lower()):
1384
+ for m in app.model_cache.get(words[1].lower(), []):
1385
+ if cur.lower() in m.lower():
1386
+ yield Completion(m, -len(cur))
1387
+ elif cmd in ("/resume", "/delete") and pos == 1:
1388
+ for i, s in enumerate(sess.list_sessions()[:30], 1):
1389
+ if str(i).startswith(cur) or s["id"].startswith(cur):
1390
+ yield Completion(str(i), -len(cur), display=f"{i} {s['title'][:40]}",
1391
+ display_meta=sess.ago(s["updated"]))
1392
+ elif cmd == "/approve" and pos == 1:
1393
+ for o, m in APPROVAL_TEXT.items():
1394
+ if o.startswith(cur.lower()):
1395
+ yield Completion(o, -len(cur), display_meta=m[:60])
1396
+ elif cmd in ("/team", "/roster") and pos == 1:
1397
+ opts = ["on", "off", "lead"] if cmd == "/team" else ["bench", "edit"]
1398
+ for o in opts:
1399
+ if o.startswith(cur.lower()):
1400
+ yield Completion(o, -len(cur))
1401
+ elif (cmd == "/team" and pos == 2 and words[1].lower() == "lead") or \
1402
+ (cmd == "/parallel" and cur.startswith("@")):
1403
+ at = "@" if cur.startswith("@") else ""
1404
+ for w in roster.build(app.router):
1405
+ if cur.lstrip("@").lower() in w.id.lower():
1406
+ yield Completion(at + w.id, -len(cur), display_meta=", ".join(w.strengths)[:50])
1407
+ elif cmd == "/skill" and pos == 1:
1408
+ for n, s in [("new", "create a skill")] + [(n, s.description) for n, s in app.agent.skills.items()]:
1409
+ if n.startswith(cur.lower()):
1410
+ yield Completion(n, -len(cur), display_meta=s[:60])
1411
+
1412
+ kb = KeyBindings()
1413
+
1414
+ @kb.add("escape", "enter")
1415
+ @kb.add("c-j")
1416
+ def _(event):
1417
+ event.current_buffer.insert_text("\n")
1418
+
1419
+ @kb.add("enter")
1420
+ def _(event):
1421
+ buf = event.current_buffer
1422
+ if buf.document.text_before_cursor.endswith("\\"):
1423
+ buf.delete_before_cursor(1)
1424
+ buf.insert_text("\n")
1425
+ elif buf.complete_state and buf.complete_state.current_completion:
1426
+ buf.apply_completion(buf.complete_state.current_completion)
1427
+ else:
1428
+ buf.validate_and_handle()
1429
+
1430
+ @kb.add("c-c")
1431
+ def _(event):
1432
+ buf = event.current_buffer
1433
+ if buf.text:
1434
+ buf.reset() # first ctrl+c clears what you typed
1435
+ elif app.busy():
1436
+ app.stop() # stops the running task; the box stays open
1437
+ else:
1438
+ event.app.exit(exception=KeyboardInterrupt)
1439
+
1440
+ if os.name == "nt":
1441
+ # Windows Terminal handles ctrl+v itself and sends text as a bracketed paste (which
1442
+ # prompt_toolkit inserts in one go), but it sends nothing for images or copied files.
1443
+ # alt+v (and ctrl+v / shift+insert where the terminal passes them through) reads the
1444
+ # clipboard directly: text, Explorer files -> paths, screenshots -> saved PNG path.
1445
+ @kb.add("c-v")
1446
+ @kb.add("s-insert")
1447
+ @kb.add("escape", "v")
1448
+ def _(event):
1449
+ try:
1450
+ if not _paste_clipboard(event):
1451
+ app.ui.info("nothing to paste: the clipboard has no text, files or image")
1452
+ except OSError as exc:
1453
+ app.ui.warn(f"could not paste from the Windows clipboard: {exc}")
1454
+
1455
+ # Not eager: an eager escape fired on the first byte of every escape sequence, so alt+enter
1456
+ # (new line) and alt+v (paste) could never reach their bindings.
1457
+ @kb.add("escape")
1458
+ def _(event):
1459
+ buf = event.current_buffer
1460
+ if not buf.text and app.busy():
1461
+ app.stop() # esc on an empty box stops the running task
1462
+ else:
1463
+ buf.reset()
1464
+
1465
+ style = Style.from_dict({
1466
+ "prompt": "#c084fc bold",
1467
+ "ask": "#fbbf24 bold",
1468
+ "frame": "#4c3a6b",
1469
+ "placeholder": "#6b6480 italic",
1470
+ "continuation": "#6b7280",
1471
+ "bottom-toolbar": "noreverse bg:#1f1b2e #9ca3af",
1472
+ "tb": "bg:#1f1b2e #9ca3af",
1473
+ "tb.dim": "bg:#1f1b2e #6b7280",
1474
+ "tb.warn": "bg:#422006 #fbbf24 bold",
1475
+ "tb.accent": "bg:#2e2547 #d8b4fe bold",
1476
+ "completion-menu.completion": "bg:#1f1b2e #e5e7eb",
1477
+ "completion-menu.completion.current": "bg:#6d28d9 #ffffff",
1478
+ "completion-menu.meta.completion": "bg:#1f1b2e #9ca3af",
1479
+ "completion-menu.meta.completion.current": "bg:#6d28d9 #e9d5ff",
1480
+ })
1481
+ class RedactingHistory(FileHistory):
1482
+ """Input history never keeps a pasted API key (NEXT 7)."""
1483
+ def append_string(self, string: str) -> None:
1484
+ super().append_string(keyvault.redact(string)[0])
1485
+
1486
+ HOME_DIR.mkdir(parents=True, exist_ok=True)
1487
+ session = PromptSession(
1488
+ history=RedactingHistory(str(HOME_DIR / "history")),
1489
+ completer=SlashCompleter(), complete_while_typing=True,
1490
+ key_bindings=kb, multiline=True, style=style,
1491
+ prompt_continuation=lambda width, line, wrap: FormattedText([("class:continuation", " ")]),
1492
+ bottom_toolbar=lambda: FormattedText(app.toolbar()),
1493
+ placeholder=FormattedText([("class:placeholder",
1494
+ "Ask Forge to build, fix or explain something… / for commands · tab completes")]),
1495
+ reserve_space_for_menu=6,
1496
+ refresh_interval=0.1, # keeps the pixel city and live status above the box moving
1497
+ )
1498
+
1499
+ def message():
1500
+ """What sits above and in front of the box: the live status while Forge works, and the
1501
+ question being asked when it waits on you."""
1502
+ parts = []
1503
+ status = app.ui.status_ansi()
1504
+ if status:
1505
+ parts += to_formatted_text(ANSI(status))
1506
+ p = app.ui.pending
1507
+ if p:
1508
+ parts += [("class:ask", f"{p['label']}\n"), ("class:prompt", "› ")]
1509
+ else:
1510
+ parts.append(("class:prompt", "❯ "))
1511
+ return parts
1512
+
1513
+ def ask():
1514
+ # Plain prompt drawn entirely by prompt_toolkit. The old box had its top and bottom lines
1515
+ # printed by rich outside the prompt, so they went out of step with multi-line input,
1516
+ # pastes, the completion menu and window resizes, and littered the scrollback.
1517
+ if not app.busy():
1518
+ console.print()
1519
+ return session.prompt(message)
1520
+
1521
+ ask.docked = True
1522
+ app._redraw = lambda: session.app.invalidate() if session.app.is_running else None
1523
+ try:
1524
+ from . import courier_hd
1525
+ courier_hd.attach(app.ui, session.app) # the courier in real pixels where the terminal can
1526
+ except Exception:
1527
+ pass
1528
+ return ask
1529
+
1530
+
1531
+ # --- main ------------------------------------------------------------------------
1532
+
1533
+ def setup_without_app(ui: UI) -> None:
1534
+ """Minimal key walkthrough for when no provider is usable yet (so no App exists)."""
1535
+ from .config import KNOWN_PROVIDERS
1536
+ import getpass
1537
+ store = keyvault.KeyStore()
1538
+ ui.info("No usable provider. Paste an API key to start (" +
1539
+ ", ".join(f"{n}: {keyvault.SIGNUP[n][2]}" for n in ("groq", "gemini", "openrouter")) + ")")
1540
+ while True:
1541
+ try:
1542
+ key = getpass.getpass(" key (hidden, enter = done): ").strip()
1543
+ except (EOFError, KeyboardInterrupt):
1544
+ return
1545
+ if not key:
1546
+ return
1547
+ provider = keyvault.detect_provider(key) or keyvault.identify(
1548
+ key, {n: v for n, v in KNOWN_PROVIDERS.items()}, keyvault.candidates(key) or None)
1549
+ if not provider:
1550
+ ui.error("couldn't tell which provider that key is for — start forge and use /keys add")
1551
+ continue
1552
+ base, model = KNOWN_PROVIDERS.get(provider, ("", ""))
1553
+ ok, why = keyvault.test_key(base, key, model) if base else (True, "")
1554
+ if not ok and "rejected" in why:
1555
+ ui.error(f"✗ {provider} {keyvault.mask(key)}: {why}")
1556
+ continue
1557
+ store.add(key, provider)
1558
+ ui.ok(f"✓ {provider} key {keyvault.mask(key)} saved · {why}")
1559
+
1560
+
1561
+ def main(argv: list[str] | None = None) -> int:
1562
+ ap = argparse.ArgumentParser(prog="forge", description="Terminal coding agent with key rotation and provider failover.")
1563
+ ap.add_argument("prompt", nargs="*", help="run one prompt and exit (otherwise starts the chat)")
1564
+ ap.add_argument("-y", "--yes", action="store_true", help="auto-approve file writes and shell commands")
1565
+ ap.add_argument("-v", "--verbose", action="store_true", help="show key rotation / failover log")
1566
+ ap.add_argument("-m", "--model", help="model for this run (for the first provider, or provider:model)")
1567
+ ap.add_argument("-p", "--provider", help="use only this provider for this run")
1568
+ ap.add_argument("-c", "--continue", dest="cont", action="store_true",
1569
+ help="continue the most recent conversation in this folder")
1570
+ ap.add_argument("-r", "--resume", metavar="ID", nargs="?", const="",
1571
+ help="continue a saved conversation (# from --sessions, or id); no ID shows a picker")
1572
+ ap.add_argument("--sessions", action="store_true", help="list saved conversations and exit")
1573
+ ap.add_argument("--status", action="store_true", help="print key status and exit")
1574
+ ap.add_argument("-t", "--team", action="store_true", help="start in team mode (a coordinator delegates by strength)")
1575
+ ap.add_argument("--roster", action="store_true", help="show what each model is good at and exit")
1576
+ ap.add_argument("--json", action="store_true", help="emit one-shot prompt results as JSON on stdout")
1577
+ ap.add_argument("--version", action="version", version=f"forge {__version__}")
1578
+ args = ap.parse_args(argv)
1579
+ if args.json and not args.prompt:
1580
+ ap.error("--json requires a one-shot prompt")
1581
+ if args.json:
1582
+ global console
1583
+ console = Console(file=sys.stderr, theme=uimod.THEME, highlight=False, soft_wrap=False)
1584
+ uimod.console = console
1585
+
1586
+ settings = load_settings()
1587
+ settings.auto_approve = args.yes
1588
+ settings.verbose = args.verbose
1589
+ apply_prefs(settings, load_prefs())
1590
+ ui = UI(verbose=args.verbose)
1591
+
1592
+ if not settings.providers and sys.stdin.isatty():
1593
+ # Nothing usable at all (keyless gateways turned off): add a key before the app starts.
1594
+ setup_without_app(ui)
1595
+ settings = load_settings()
1596
+ settings.auto_approve, settings.verbose = args.yes, args.verbose
1597
+ apply_prefs(settings, load_prefs())
1598
+ if not settings.providers:
1599
+ if args.json:
1600
+ print(json.dumps({"ok": False, "error": "No providers configured."}))
1601
+ else:
1602
+ console.print(SETUP)
1603
+ return 1
1604
+
1605
+ session = None
1606
+ pick = args.resume == "" # `forge -r` with no ID → choose from a list once the app is up
1607
+ if args.cont or args.resume:
1608
+ sid = sess.find(args.resume) if args.resume else sess.latest_for(os.getcwd())
1609
+ if not sid:
1610
+ ui.error("no matching saved conversation")
1611
+ return 1
1612
+ session = Session.load(sid)
1613
+
1614
+ agent = Agent(settings, ui, session=session)
1615
+ app = App(agent, ui)
1616
+
1617
+ if args.provider:
1618
+ if not agent.router.get(args.provider.lower()):
1619
+ ui.error(f"no provider {args.provider!r} configured")
1620
+ return 1
1621
+ agent.router.pinned = args.provider.lower()
1622
+ if args.model:
1623
+ prov_name, _, model = args.model.partition(":") if ":" in args.model and \
1624
+ agent.router.get(args.model.split(":", 1)[0].lower()) else ("", "", args.model)
1625
+ prov = agent.router.get(prov_name.lower()) if prov_name else agent.router.lead()
1626
+ prov.model = model # this run only; /model saves a choice
1627
+
1628
+ if args.status:
1629
+ app.cmd_status("")
1630
+ return 0
1631
+ if args.roster:
1632
+ app.cmd_roster("")
1633
+ return 0
1634
+ if args.team and not agent.team:
1635
+ if not agent.set_team(True):
1636
+ ui.warn("team mode: no usable coordinator, continuing without it")
1637
+ if args.sessions:
1638
+ app.cmd_sessions("")
1639
+ return 0
1640
+
1641
+ if args.prompt:
1642
+ try:
1643
+ agent.run_turn(" ".join(args.prompt))
1644
+ except KeyboardInterrupt:
1645
+ ui.warn("interrupted")
1646
+ return 130
1647
+ if args.json:
1648
+ answer = next((str(message.get("content") or "") for message in reversed(agent.messages)
1649
+ if message.get("role") == "assistant" and not message.get("tool_calls")), "")
1650
+ print(json.dumps({"ok": agent.last_turn_ok, "answer": answer,
1651
+ "model": agent.last_answer, "session_id": agent.session.id},
1652
+ ensure_ascii=False))
1653
+ return 0 if agent.last_turn_ok else 1
1654
+
1655
+ # NEXT 7: first run with no keys of your own → the key walkthrough (enter skips it; asked once).
1656
+ if sys.stdin.isatty() and not app.prefs.get("setup_done") and \
1657
+ all(p.pool.keys == [keyvault.ANON] for p in agent.router.providers):
1658
+ app.cmd_setup("")
1659
+ app.prefs["setup_done"] = True
1660
+ save_prefs(app.prefs)
1661
+ failing = app.failing_keys()
1662
+ if failing:
1663
+ ui.warn("keys failing: " + ", ".join(failing[:4]) + (" …" if len(failing) > 4 else "") + " · /keys test")
1664
+ return app.loop(replay=session, pick=pick)
1665
+
1666
+
1667
+ if __name__ == "__main__":
1668
+ sys.exit(main())