forgecommandline 0.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- forgecli/__init__.py +2 -0
- forgecli/ads.py +322 -0
- forgecli/agent.py +1093 -0
- forgecli/art.py +574 -0
- forgecli/attach.py +246 -0
- forgecli/brief.py +215 -0
- forgecli/browsercheck.py +99 -0
- forgecli/checkpoints.py +81 -0
- forgecli/city.py +833 -0
- forgecli/cli.py +1668 -0
- forgecli/config.py +194 -0
- forgecli/courier.py +177 -0
- forgecli/courier_data.py +164 -0
- forgecli/courier_hd.py +431 -0
- forgecli/courier_hd_data.bin +0 -0
- forgecli/courier_hd_data.py +19 -0
- forgecli/ghlogin.py +76 -0
- forgecli/github.py +54 -0
- forgecli/guards.py +403 -0
- forgecli/hooks.py +50 -0
- forgecli/httpapi.py +434 -0
- forgecli/images.py +110 -0
- forgecli/keys.py +152 -0
- forgecli/keyvault.py +320 -0
- forgecli/mcp.py +674 -0
- forgecli/memory.py +42 -0
- forgecli/openapi.py +331 -0
- forgecli/parallel.py +308 -0
- forgecli/prices.py +72 -0
- forgecli/procs.py +144 -0
- forgecli/providers.py +758 -0
- forgecli/quality.py +192 -0
- forgecli/repomap.py +78 -0
- forgecli/roster.py +227 -0
- forgecli/safety.py +93 -0
- forgecli/sandbox.py +83 -0
- forgecli/sessions.py +97 -0
- forgecli/skills.py +446 -0
- forgecli/tools.py +1184 -0
- forgecli/ui.py +860 -0
- forgecli/worktrees.py +79 -0
- forgecommandline-0.8.0.data/data/skills/commit/SKILL.md +14 -0
- forgecommandline-0.8.0.data/data/skills/debug/SKILL.md +17 -0
- forgecommandline-0.8.0.data/data/skills/explain/SKILL.md +14 -0
- forgecommandline-0.8.0.data/data/skills/init/SKILL.md +19 -0
- forgecommandline-0.8.0.data/data/skills/library/LICENSES/agent-skills-MIT.txt +21 -0
- forgecommandline-0.8.0.data/data/skills/library/LICENSES/wondelai-MIT.txt +23 -0
- forgecommandline-0.8.0.data/data/skills/library/README.md +9 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/accessibility-checklist.md +160 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/definition-of-done.md +67 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/observability-checklist.md +92 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/orchestration-patterns.md +370 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/performance-checklist.md +238 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/security-checklist.md +248 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/testing-patterns.md +235 -0
- forgecommandline-0.8.0.data/data/skills/library/api-and-interface-design/SKILL.md +367 -0
- forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/SKILL.md +279 -0
- forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/references/artifact-templates.md +157 -0
- forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/references/methods.md +87 -0
- forgecommandline-0.8.0.data/data/skills/library/awwwards-tier-web-application/SKILL.md +877 -0
- forgecommandline-0.8.0.data/data/skills/library/browser-testing-with-devtools/SKILL.md +317 -0
- forgecommandline-0.8.0.data/data/skills/library/ci-cd-and-automation/SKILL.md +390 -0
- forgecommandline-0.8.0.data/data/skills/library/code-review-and-quality/SKILL.md +398 -0
- forgecommandline-0.8.0.data/data/skills/library/code-simplification/SKILL.md +331 -0
- forgecommandline-0.8.0.data/data/skills/library/constraint-driven-development/SKILL.md +311 -0
- forgecommandline-0.8.0.data/data/skills/library/constraint-driven-development/references/floor-guard.md +182 -0
- forgecommandline-0.8.0.data/data/skills/library/context-engineering/SKILL.md +353 -0
- forgecommandline-0.8.0.data/data/skills/library/debugging-and-error-recovery/SKILL.md +300 -0
- forgecommandline-0.8.0.data/data/skills/library/deprecation-and-migration/SKILL.md +247 -0
- forgecommandline-0.8.0.data/data/skills/library/documentation-and-adrs/SKILL.md +297 -0
- forgecommandline-0.8.0.data/data/skills/library/doubt-driven-development/SKILL.md +243 -0
- forgecommandline-0.8.0.data/data/skills/library/frontend-ui-engineering/SKILL.md +340 -0
- forgecommandline-0.8.0.data/data/skills/library/git-workflow-and-versioning/SKILL.md +355 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/SKILL.md +193 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/examples.md +238 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/frameworks.md +99 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/refinement-criteria.md +113 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/scripts/idea-refine.sh +15 -0
- forgecommandline-0.8.0.data/data/skills/library/improve-code-quality/SKILL.md +275 -0
- forgecommandline-0.8.0.data/data/skills/library/improve-code-quality/references/artifact-templates.md +101 -0
- forgecommandline-0.8.0.data/data/skills/library/incremental-implementation/SKILL.md +249 -0
- forgecommandline-0.8.0.data/data/skills/library/interview-me/SKILL.md +235 -0
- forgecommandline-0.8.0.data/data/skills/library/observability-and-instrumentation/SKILL.md +238 -0
- forgecommandline-0.8.0.data/data/skills/library/performance-optimization/SKILL.md +267 -0
- forgecommandline-0.8.0.data/data/skills/library/performance-optimization/references/optimization-patterns.md +250 -0
- forgecommandline-0.8.0.data/data/skills/library/planning-and-task-breakdown/SKILL.md +257 -0
- forgecommandline-0.8.0.data/data/skills/library/remove-technical-debt/SKILL.md +203 -0
- forgecommandline-0.8.0.data/data/skills/library/remove-technical-debt/references/artifact-templates.md +79 -0
- forgecommandline-0.8.0.data/data/skills/library/security-and-hardening/SKILL.md +216 -0
- forgecommandline-0.8.0.data/data/skills/library/security-and-hardening/references/hardening-patterns.md +324 -0
- forgecommandline-0.8.0.data/data/skills/library/shipping-and-launch/SKILL.md +336 -0
- forgecommandline-0.8.0.data/data/skills/library/source-driven-development/SKILL.md +216 -0
- forgecommandline-0.8.0.data/data/skills/library/spec-driven-development/SKILL.md +259 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/SKILL.md +207 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/building-blocks.md +272 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/common-designs.md +257 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/database-scaling.md +295 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/estimation-numbers.md +250 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/four-step-process.md +286 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/reliability-operations.md +373 -0
- forgecommandline-0.8.0.data/data/skills/library/test-driven-development/SKILL.md +398 -0
- forgecommandline-0.8.0.data/data/skills/library/using-agent-skills/SKILL.md +192 -0
- forgecommandline-0.8.0.data/data/skills/review/SKILL.md +16 -0
- forgecommandline-0.8.0.data/data/skills/run/SKILL.md +17 -0
- forgecommandline-0.8.0.data/data/skills/security-review/SKILL.md +20 -0
- forgecommandline-0.8.0.data/data/skills/simplify/SKILL.md +18 -0
- forgecommandline-0.8.0.data/data/skills/test/SKILL.md +16 -0
- forgecommandline-0.8.0.data/data/skills/web-build/SKILL.md +25 -0
- forgecommandline-0.8.0.dist-info/METADATA +357 -0
- forgecommandline-0.8.0.dist-info/RECORD +113 -0
- forgecommandline-0.8.0.dist-info/WHEEL +5 -0
- forgecommandline-0.8.0.dist-info/entry_points.txt +2 -0
- forgecommandline-0.8.0.dist-info/top_level.txt +1 -0
forgecli/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())
|