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/tools.py
ADDED
|
@@ -0,0 +1,1184 @@
|
|
|
1
|
+
"""File and shell tools the model can call."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import difflib
|
|
5
|
+
import fnmatch
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import pathlib
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import stat
|
|
12
|
+
import subprocess
|
|
13
|
+
import tempfile
|
|
14
|
+
import time
|
|
15
|
+
import urllib.parse
|
|
16
|
+
|
|
17
|
+
from . import browsercheck
|
|
18
|
+
from . import guards, procs, safety
|
|
19
|
+
from . import memory as memorymod
|
|
20
|
+
from .checkpoints import Checkpoints
|
|
21
|
+
from .hooks import Hooks
|
|
22
|
+
from .httpapi import HTTP_SCHEMA, HttpAPI, OPENAPI_IMPORT_SCHEMA, OPENAPI_SEARCH_SCHEMA
|
|
23
|
+
from .mcp import MCPManager, MCP_SEARCH_SCHEMA, UsageStore
|
|
24
|
+
from .openapi import OpenAPIs
|
|
25
|
+
from .ui import UI
|
|
26
|
+
|
|
27
|
+
MAX_OUT = 16_000 # characters of tool output sent back to the model
|
|
28
|
+
SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", ".mypy_cache", ".next", "dist", "build"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _fn(name, desc, props, required):
|
|
32
|
+
return {"type": "function", "function": {"name": name, "description": desc, "parameters": {
|
|
33
|
+
"type": "object", "properties": props, "required": required}}}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
SCHEMAS = [
|
|
37
|
+
_fn("read_file", "Read a text file. Returns numbered lines (500 by default; use offset/limit for more). "
|
|
38
|
+
"Don't re-read a file you already read unless it changed.",
|
|
39
|
+
{"path": {"type": "string"}, "offset": {"type": "integer", "description": "1-based start line"},
|
|
40
|
+
"limit": {"type": "integer", "description": "max lines (default 500)"}}, ["path"]),
|
|
41
|
+
_fn("write_file", "Create or overwrite a file with the given full content.",
|
|
42
|
+
{"path": {"type": "string"}, "content": {"type": "string"}}, ["path", "content"]),
|
|
43
|
+
_fn("edit_file", "Replace an exact string in a file. old_string must match exactly (including "
|
|
44
|
+
"whitespace) and be unique unless replace_all is true. Read the file first.",
|
|
45
|
+
{"path": {"type": "string"}, "old_string": {"type": "string"}, "new_string": {"type": "string"},
|
|
46
|
+
"replace_all": {"type": "boolean"}}, ["path", "old_string", "new_string"]),
|
|
47
|
+
_fn("apply_patch", "Apply a multi-file patch. Use *** Begin Patch, *** Update File: path, @@ hunks "
|
|
48
|
+
"with context lines prefixed by space, removals by -, additions by +, and *** End Patch. "
|
|
49
|
+
"Use *** End of File to anchor a hunk at the file end. Context matching tolerates whitespace "
|
|
50
|
+
"drift. Read files first.",
|
|
51
|
+
{"patch": {"type": "string"}}, ["patch"]),
|
|
52
|
+
_fn("list_dir", "List a directory (dirs end with /).", {"path": {"type": "string"}}, []),
|
|
53
|
+
_fn("glob", "Find files by glob pattern, e.g. '**/*.py'.",
|
|
54
|
+
{"pattern": {"type": "string"}, "path": {"type": "string"}}, ["pattern"]),
|
|
55
|
+
_fn("grep", "Search file contents with a regular expression. Returns path:line: text.",
|
|
56
|
+
{"pattern": {"type": "string"}, "path": {"type": "string"},
|
|
57
|
+
"glob": {"type": "string", "description": "filename filter, e.g. '*.js'"},
|
|
58
|
+
"ignore_case": {"type": "boolean"}}, ["pattern"]),
|
|
59
|
+
_fn("repo_map", "List definitions and source locations in a project folder. Use for codebase structure.",
|
|
60
|
+
{"path": {"type": "string"}}, []),
|
|
61
|
+
_fn("run_shell", "Run a shell command and return its output and exit code. For anything that keeps "
|
|
62
|
+
"running (web servers, dev servers, watchers, GUI apps) set background=true: it starts, you get the "
|
|
63
|
+
"first output and any local URL, and you can carry on. A command that turns out to be a server is "
|
|
64
|
+
"moved to the background automatically.",
|
|
65
|
+
{"command": {"type": "string"}, "cwd": {"type": "string"},
|
|
66
|
+
"timeout": {"type": "integer", "description": "seconds, default 120, max 600 (foreground only)"},
|
|
67
|
+
"background": {"type": "boolean", "description": "start it and return right away"}}, ["command"]),
|
|
68
|
+
_fn("process", "Manage background processes started by run_shell: list them, read one's latest "
|
|
69
|
+
"output, wait for one to finish (use this for a long install or build instead of polling "
|
|
70
|
+
"output again and again), or stop one.",
|
|
71
|
+
{"action": {"type": "string", "enum": ["list", "output", "wait", "stop"]},
|
|
72
|
+
"id": {"type": "integer", "description": "process id from run_shell (for output/wait/stop)"},
|
|
73
|
+
"timeout": {"type": "integer", "description": "seconds to wait, default 300, max 600 (wait only)"}},
|
|
74
|
+
["action"]),
|
|
75
|
+
_fn("open_url", "Open a web address (e.g. a local server's URL) in the user's default browser.",
|
|
76
|
+
{"url": {"type": "string"}}, ["url"]),
|
|
77
|
+
_fn("browser_check", "Check a locally running web page in an isolated headless browser: HTTP status, "
|
|
78
|
+
"title, console errors, uncaught page errors (with Playwright) and a screenshot (Playwright, or "
|
|
79
|
+
"Edge/Chrome when Playwright isn't installed). Vision models are shown the screenshot so they can "
|
|
80
|
+
"judge how the page actually looks.",
|
|
81
|
+
{"url": {"type": "string", "description": "local http(s) URL only"}}, ["url"]),
|
|
82
|
+
_fn("web_fetch", "Download a web page or file over http(s) and return it as readable text "
|
|
83
|
+
"(HTML is converted to text). Use for docs, APIs, checking a local server's page.",
|
|
84
|
+
{"url": {"type": "string"},
|
|
85
|
+
"max_chars": {"type": "integer", "description": "default 12000"}}, ["url"]),
|
|
86
|
+
HTTP_SCHEMA,
|
|
87
|
+
_fn("github_request", "Call the GitHub REST API with the connected GitHub account. Use only verified routes.",
|
|
88
|
+
{"method": {"type": "string"}, "path": {"type": "string", "description": "API path beginning with /"},
|
|
89
|
+
"query": {"type": "object"}, "json_body": {},
|
|
90
|
+
"timeout": {"type": "integer"}}, ["method", "path"]),
|
|
91
|
+
_fn("web_search", "Search the web. Returns titles, links and snippets; web_fetch a link to read it.",
|
|
92
|
+
{"query": {"type": "string"}, "count": {"type": "integer", "description": "default 6"}}, ["query"]),
|
|
93
|
+
OPENAPI_IMPORT_SCHEMA,
|
|
94
|
+
OPENAPI_SEARCH_SCHEMA,
|
|
95
|
+
_fn("todo", "Keep a visible checklist for multi-step work. Send the whole list each time, "
|
|
96
|
+
"marking items todo / doing / done.",
|
|
97
|
+
{"items": {"type": "array", "items": {"type": "object", "properties": {
|
|
98
|
+
"task": {"type": "string"}, "status": {"type": "string", "enum": ["todo", "doing", "done"]}},
|
|
99
|
+
"required": ["task", "status"]}}}, ["items"]),
|
|
100
|
+
_fn("ask_user", "Ask the user a question when you need a decision only they can make. "
|
|
101
|
+
"Offer 2-4 short options when you can.",
|
|
102
|
+
{"question": {"type": "string"}, "options": {"type": "array", "items": {"type": "string"}}},
|
|
103
|
+
["question"]),
|
|
104
|
+
_fn("memory", "Read or save an explicit persistent note. Use project scope for this codebase and user "
|
|
105
|
+
"scope for cross-project preferences. Save only useful facts the user wants remembered.",
|
|
106
|
+
{"action": {"type": "string", "enum": ["read", "remember"]},
|
|
107
|
+
"scope": {"type": "string", "enum": ["project", "user"]},
|
|
108
|
+
"note": {"type": "string"}}, ["action", "scope"]),
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
_PS_PIPES = [
|
|
115
|
+
(re.compile(r"\|\s*head\s+-n\s*(\d+)\b"), r"| Select-Object -First \1"),
|
|
116
|
+
(re.compile(r"\|\s*head\s+-(\d+)\b"), r"| Select-Object -First \1"),
|
|
117
|
+
(re.compile(r"\|\s*head\s*(?=$|[;|&])"), "| Select-Object -First 10"),
|
|
118
|
+
(re.compile(r"\|\s*tail\s+-n\s*(\d+)\b"), r"| Select-Object -Last \1"),
|
|
119
|
+
(re.compile(r"\|\s*tail\s+-(\d+)\b"), r"| Select-Object -Last \1"),
|
|
120
|
+
(re.compile(r"\|\s*wc\s+-l\b"), "| Measure-Object -Line"),
|
|
121
|
+
(re.compile(r"\|\s*grep\s+-i\s+-([AC])\s*(\d+)\s+"), r"| Select-String -Context \2 "),
|
|
122
|
+
(re.compile(r"\|\s*grep\s+-([AC])\s*(\d+)\s+(?:-i\s+)?"), r"| Select-String -Context \2 "),
|
|
123
|
+
(re.compile(r"\|\s*grep\s+(?:-i\s+)?-B\s*(\d+)\s+(?:-i\s+)?"), r"| Select-String -Context \1,0 "),
|
|
124
|
+
(re.compile(r"\|\s*grep\s+-i\s+"), "| Select-String "),
|
|
125
|
+
(re.compile(r"\|\s*grep\s+(?!-)"), "| Select-String "),
|
|
126
|
+
(re.compile(r"^\s*ls\s+-R\b"), "Get-ChildItem -Recurse -Name"),
|
|
127
|
+
(re.compile(r"^\s*ls\s+-(?:la|al|l|a)\b"), "Get-ChildItem -Force"),
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def unixy_to_powershell(cmd: str) -> str:
|
|
132
|
+
"""Models often write Linux pipes; rewrite the common ones so they work in PowerShell."""
|
|
133
|
+
for pat, rep in _PS_PIPES:
|
|
134
|
+
cmd = pat.sub(rep, cmd)
|
|
135
|
+
# Windows PowerShell 5.1 has no `&&` / `||`; models write them constantly and the command
|
|
136
|
+
# fails with "The token '&&' is not a valid statement separator", wasting a whole step.
|
|
137
|
+
if "&&" in cmd or "||" in cmd:
|
|
138
|
+
parts = _split_outside_quotes(cmd)
|
|
139
|
+
if len(parts) > 1:
|
|
140
|
+
out = parts[0]
|
|
141
|
+
for op, nxt in zip(parts[1::2], parts[2::2]):
|
|
142
|
+
out = f"{out}; if ({'$?' if op == '&&' else '-not $?'}) {{ {nxt} }}"
|
|
143
|
+
cmd = out
|
|
144
|
+
return windows_python(cmd)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _binary(p: pathlib.Path) -> bool:
|
|
148
|
+
"""True for files that aren't text (a NUL byte in the first 4 KB)."""
|
|
149
|
+
try:
|
|
150
|
+
with open(p, "rb") as f:
|
|
151
|
+
return b"\0" in f.read(4096)
|
|
152
|
+
except OSError:
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _split_outside_quotes(cmd: str) -> list[str]:
|
|
157
|
+
"""Split on && / || that sit outside quotes: `cd "C:\\My App" && npm i` used to be left alone
|
|
158
|
+
(any quote disabled the rewrite) and failed with "'&&' is not a valid statement separator"."""
|
|
159
|
+
parts, buf, quote, i = [], [], None, 0
|
|
160
|
+
while i < len(cmd):
|
|
161
|
+
ch = cmd[i]
|
|
162
|
+
if quote:
|
|
163
|
+
if ch == quote:
|
|
164
|
+
quote = None
|
|
165
|
+
buf.append(ch)
|
|
166
|
+
elif ch in "\"'":
|
|
167
|
+
quote = ch
|
|
168
|
+
buf.append(ch)
|
|
169
|
+
elif cmd[i:i + 2] in ("&&", "||"):
|
|
170
|
+
parts += ["".join(buf).strip(), cmd[i:i + 2]]
|
|
171
|
+
buf = []
|
|
172
|
+
i += 2
|
|
173
|
+
continue
|
|
174
|
+
else:
|
|
175
|
+
buf.append(ch)
|
|
176
|
+
i += 1
|
|
177
|
+
if quote: # unbalanced quotes: don't guess
|
|
178
|
+
return [cmd]
|
|
179
|
+
parts.append("".join(buf).strip())
|
|
180
|
+
return parts if all(parts[::2]) else [cmd]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
_PY3 = re.compile(r"(^|[;&|{(]\s*)python3(?:\.exe)?(?=\s|$)", re.M)
|
|
184
|
+
_PIP = re.compile(r"(^|[;&|{(]\s*)pip3?(?=\s+(?:install|uninstall|show|list|freeze)\b)", re.M)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def windows_python(cmd: str) -> str:
|
|
188
|
+
"""On Windows `python3` is usually the Microsoft Store stub or a different install than
|
|
189
|
+
`python`, so packages installed with `pip` were "missing" (ModuleNotFoundError: docx) and the
|
|
190
|
+
model looped installing them. Use one interpreter: `python`, and `python -m pip`."""
|
|
191
|
+
if os.name != "nt":
|
|
192
|
+
return cmd
|
|
193
|
+
cmd = _PY3.sub(r"\1python", cmd)
|
|
194
|
+
return _PIP.sub(r"\1python -m pip", cmd)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def local_only_server(cmd: str) -> str:
|
|
198
|
+
"""`python -m http.server` listens on every network interface by default, which shares the
|
|
199
|
+
served folder (often the whole home directory) with anyone on the same Wi-Fi. Bind it to
|
|
200
|
+
this machine unless the command already chose an address."""
|
|
201
|
+
if re.search(r"http\.server", cmd) and not re.search(r"(--bind|-b)\s", cmd):
|
|
202
|
+
cmd = re.sub(r"(-m\s+http\.server)", lambda m: m.group(1) + " --bind 127.0.0.1", cmd, count=1)
|
|
203
|
+
return cmd
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def html_to_text(html: str) -> str:
|
|
207
|
+
"""Rough but dependency-free HTML → text: drops scripts/styles/nav, keeps links and headings."""
|
|
208
|
+
import html as htmlmod
|
|
209
|
+
html = re.sub(r"(?is)<(script|style|noscript|svg|head|nav|footer)\b.*?</\1>", " ", html)
|
|
210
|
+
html = re.sub(r"(?is)<a\b[^>]*href=[\"']([^\"'#]+)[\"'][^>]*>(.*?)</a>",
|
|
211
|
+
lambda m: f"{m.group(2)} ({m.group(1)})" if m.group(1).startswith("http") else m.group(2), html)
|
|
212
|
+
html = re.sub(r"(?i)<h([1-6])\b[^>]*>", lambda m: "\n\n" + "#" * int(m.group(1)) + " ", html)
|
|
213
|
+
html = re.sub(r"(?i)<(br|/p|/div|/li|/tr|/h[1-6]|/section|/article)\b[^>]*>", "\n", html)
|
|
214
|
+
html = re.sub(r"(?i)<li\b[^>]*>", "\n- ", html)
|
|
215
|
+
text = htmlmod.unescape(re.sub(r"(?s)<[^>]+>", " ", html))
|
|
216
|
+
text = re.sub(r"[ \t\r\f\v]+", " ", text)
|
|
217
|
+
return re.sub(r"\n\s*\n\s*(\n\s*)+", "\n\n", text).strip()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
_LONG_COMMAND = re.compile(
|
|
221
|
+
r"\b(?:(?:npm|pnpm|yarn|bun)\s+(?:install|i|add|ci|update|upgrade)\b|(?:pnpm|yarn|bun)\s*$|"
|
|
222
|
+
r"(?:pip3?|uv\s+pip|poetry|pipenv|conda|mamba)\s+(?:install|sync|lock)\b|uv\s+sync\b|"
|
|
223
|
+
r"cargo\s+(?:build|install)\b|go\s+(?:build|mod\s+download|install)\b|git\s+clone\b|"
|
|
224
|
+
r"docker\s+(?:build|pull|compose\s+(?:build|pull|up))\b|winget\s+install\b|choco\s+install\b|"
|
|
225
|
+
r"(?:mvn|gradle|gradlew|dotnet)\s+(?:install|build|restore)\b|npx\s+create-|"
|
|
226
|
+
r"(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?build\b)", re.I | re.M)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _still_progressing(p, within: float = 30) -> bool:
|
|
230
|
+
"""True when a running command wrote output in the last `within` seconds."""
|
|
231
|
+
try:
|
|
232
|
+
return time.time() - p.log.stat().st_mtime < within
|
|
233
|
+
except (OSError, AttributeError):
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def http_get(url: str, timeout: float = 20) -> tuple[int, str, str]:
|
|
238
|
+
"""(status, content-type, text). Never raises for HTTP errors."""
|
|
239
|
+
import urllib.error
|
|
240
|
+
import urllib.request
|
|
241
|
+
req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "text/html,application/json,*/*"})
|
|
242
|
+
try:
|
|
243
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
244
|
+
raw, status, ctype = r.read(3_000_000), r.status, r.headers.get("Content-Type", "")
|
|
245
|
+
except urllib.error.HTTPError as e:
|
|
246
|
+
raw, status, ctype = e.read(200_000), e.code, e.headers.get("Content-Type", "")
|
|
247
|
+
except urllib.error.URLError as e:
|
|
248
|
+
if "CERTIFICATE" not in str(e) or not shutil.which("curl"):
|
|
249
|
+
raise
|
|
250
|
+
# Python lacks an intermediate certificate Windows has; curl uses the Windows store.
|
|
251
|
+
r = subprocess.run(["curl", "-sSL", "--max-time", str(int(timeout)), "-A", UA,
|
|
252
|
+
"-w", "\n%{http_code} %{content_type}", url], capture_output=True)
|
|
253
|
+
raw, _, tail = r.stdout.rpartition(b"\n")
|
|
254
|
+
code, _, ctype = tail.decode("latin-1").partition(" ")
|
|
255
|
+
status = int(code) if code.isdigit() else 0
|
|
256
|
+
if r.returncode:
|
|
257
|
+
return 0, "", f"Error: {r.stderr.decode('utf-8', 'replace').strip()}"
|
|
258
|
+
charset = (re.search(r"charset=([\w-]+)", ctype) or [None, "utf-8"])[1]
|
|
259
|
+
return status, ctype, raw.decode(charset, "replace")
|
|
260
|
+
|
|
261
|
+
NEEDS_APPROVAL = {"write_file", "edit_file", "run_shell"}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _clip(text: str, limit: int = MAX_OUT) -> str:
|
|
265
|
+
if len(text) <= limit:
|
|
266
|
+
return text
|
|
267
|
+
half = limit // 2
|
|
268
|
+
return text[:half] + f"\n\n… [{len(text) - limit} characters truncated] …\n\n" + text[-half:]
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _broad_search_root(path: pathlib.Path) -> bool:
|
|
272
|
+
try:
|
|
273
|
+
return path == pathlib.Path.home() or path.parent == path
|
|
274
|
+
except OSError:
|
|
275
|
+
return False
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class Tools:
|
|
279
|
+
def __init__(self, ui: UI, shell: str, auto_approve: bool = False, approval: str = "smart"):
|
|
280
|
+
self.ui = ui
|
|
281
|
+
self.shell = shell
|
|
282
|
+
self.mode = "auto" if auto_approve else (approval if approval in safety.MODES else "smart")
|
|
283
|
+
self.root = pathlib.Path.cwd()
|
|
284
|
+
self.pending_images: list[str] = [] # screenshots to show the model after this step
|
|
285
|
+
self.hooks = Hooks(self.root, ui)
|
|
286
|
+
self.always: set[str] = set()
|
|
287
|
+
self.seen: dict[tuple, tuple] = {} # (path, offset, limit) -> (mtime, call number)
|
|
288
|
+
self.calls = 0 # tool calls made, to tell recent reads from old ones
|
|
289
|
+
self.recent_only = False # set once history is being trimmed (see Agent._compact)
|
|
290
|
+
# What this turn produced that the user would want to look at (Agent opens it when the turn ends).
|
|
291
|
+
self.pages: list[pathlib.Path] = [] # .html files written or edited
|
|
292
|
+
self.urls: list[str] = [] # local URLs of servers started
|
|
293
|
+
self.opened: set[str] = set() # everything opened this session (never opened twice)
|
|
294
|
+
self._stop_warned: set[int] = set() # servers the model was told not to stop
|
|
295
|
+
self.opened_now = False # open_url was called this turn
|
|
296
|
+
self.checkpoints = Checkpoints()
|
|
297
|
+
self.ledger = guards.Ledger() # what tools really did this turn (checked against the answer)
|
|
298
|
+
self.github_api_base = "https://api.github.com"
|
|
299
|
+
self.github_account: str | None = None
|
|
300
|
+
self.usage = UsageStore(warn=ui.warn)
|
|
301
|
+
self.http = HttpAPI(self)
|
|
302
|
+
self.openapi = OpenAPIs(self.http, self.root)
|
|
303
|
+
self.mcp = MCPManager(self, self.root, self.usage)
|
|
304
|
+
|
|
305
|
+
@property
|
|
306
|
+
def auto_approve(self) -> bool:
|
|
307
|
+
return self.mode == "auto"
|
|
308
|
+
|
|
309
|
+
@auto_approve.setter
|
|
310
|
+
def auto_approve(self, on: bool) -> None:
|
|
311
|
+
self.mode = "auto" if on else ("smart" if self.mode == "auto" else self.mode)
|
|
312
|
+
|
|
313
|
+
def _path(self, p: str | None) -> pathlib.Path:
|
|
314
|
+
q = pathlib.Path(os.path.expandvars(os.path.expanduser(p or ".")))
|
|
315
|
+
if not q.is_absolute():
|
|
316
|
+
q = self.root / q # each agent's own folder (a worktree for parallel agents)
|
|
317
|
+
return q.resolve()
|
|
318
|
+
|
|
319
|
+
def _approve(self, name: str, title: str, detail: str, path: pathlib.Path | None = None,
|
|
320
|
+
command: str = "", why: str = "") -> str | None:
|
|
321
|
+
"""None if approved, otherwise the denial message for the model."""
|
|
322
|
+
reason = safety.needs_ok(self.mode, name, self.root, path, command)
|
|
323
|
+
if self.mode == "smart" and why:
|
|
324
|
+
reason = why
|
|
325
|
+
if not reason or name in self.always:
|
|
326
|
+
return None
|
|
327
|
+
choice, response = self.ui.confirm(title, detail, reason)
|
|
328
|
+
if choice == "a":
|
|
329
|
+
self.always.add(name)
|
|
330
|
+
if choice in ("y", "a"):
|
|
331
|
+
return None
|
|
332
|
+
return "The user denied this action." + (f" They said: {response}" if response else "")
|
|
333
|
+
|
|
334
|
+
def _confirm_destructive(self, title: str, detail: str) -> str | None:
|
|
335
|
+
choice, response = self.ui.confirm(
|
|
336
|
+
title, detail, "destructive GitHub action; always requires approval")
|
|
337
|
+
if choice == "y":
|
|
338
|
+
return None
|
|
339
|
+
return "The user denied this destructive action." + (f" They said: {response}" if response else "")
|
|
340
|
+
|
|
341
|
+
def github_token(self) -> str:
|
|
342
|
+
"""Resolve GitHub credentials without sending them to the UI or model."""
|
|
343
|
+
token = os.environ.get("GITHUB_TOKEN", "").strip()
|
|
344
|
+
if token:
|
|
345
|
+
return token if not any(ch.isspace() for ch in token) else ""
|
|
346
|
+
from .keyvault import KeyStore
|
|
347
|
+
token = next(iter(KeyStore().load().get("github", [])), "").strip()
|
|
348
|
+
if token:
|
|
349
|
+
return token if not any(ch.isspace() for ch in token) else ""
|
|
350
|
+
gh = shutil.which("gh")
|
|
351
|
+
if not gh:
|
|
352
|
+
return ""
|
|
353
|
+
try:
|
|
354
|
+
result = subprocess.run([gh, "auth", "token", "--hostname", "github.com"],
|
|
355
|
+
capture_output=True, text=True, timeout=8, shell=False)
|
|
356
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
357
|
+
return ""
|
|
358
|
+
if result.returncode:
|
|
359
|
+
return ""
|
|
360
|
+
token = result.stdout.strip()
|
|
361
|
+
return token if token and not any(ch.isspace() for ch in token) else ""
|
|
362
|
+
|
|
363
|
+
# dispatch ---------------------------------------------------------------
|
|
364
|
+
def execute(self, name: str, raw_args: str) -> str:
|
|
365
|
+
self.calls += 1
|
|
366
|
+
try:
|
|
367
|
+
args = json.loads(raw_args or "{}")
|
|
368
|
+
except ValueError:
|
|
369
|
+
return "Error: arguments were not valid JSON."
|
|
370
|
+
if not isinstance(args, dict):
|
|
371
|
+
return "Error: arguments must be a JSON object."
|
|
372
|
+
denial = safety.policy_denial(name, str(args.get("command", "")))
|
|
373
|
+
if denial:
|
|
374
|
+
result = "Error: " + denial
|
|
375
|
+
else:
|
|
376
|
+
pre_error = self.hooks.run("pre_tool", name, args)
|
|
377
|
+
if pre_error:
|
|
378
|
+
result = "Error: " + pre_error
|
|
379
|
+
else:
|
|
380
|
+
result = self._dispatch(name, args)
|
|
381
|
+
post_error = self.hooks.run("post_tool", name, args)
|
|
382
|
+
if post_error:
|
|
383
|
+
self.ui.warn(post_error)
|
|
384
|
+
self.ledger.add(name, args, result)
|
|
385
|
+
return result
|
|
386
|
+
|
|
387
|
+
def _dispatch(self, name: str, args: dict) -> str:
|
|
388
|
+
if not isinstance(args, dict):
|
|
389
|
+
return "Error: arguments must be a JSON object."
|
|
390
|
+
if name.startswith("mcp__"):
|
|
391
|
+
return _clip(self.mcp.execute(name, args))
|
|
392
|
+
if name.startswith("openapi__"):
|
|
393
|
+
return _clip(self.openapi.execute(name, args))
|
|
394
|
+
fn = getattr(self, f"t_{name}", None)
|
|
395
|
+
if fn is None:
|
|
396
|
+
return f"Error: unknown tool '{name}'."
|
|
397
|
+
try:
|
|
398
|
+
return _clip(fn(**args))
|
|
399
|
+
except TypeError as e:
|
|
400
|
+
return f"Error: bad arguments for {name}: {e}"
|
|
401
|
+
except Exception as e:
|
|
402
|
+
return f"Error: {type(e).__name__}: {e}"
|
|
403
|
+
|
|
404
|
+
# tools ------------------------------------------------------------------
|
|
405
|
+
def t_read_file(self, path, offset=1, limit=500):
|
|
406
|
+
p = self._path(path)
|
|
407
|
+
self.ui.peek("read", str(p))
|
|
408
|
+
sig = (str(p), int(offset or 1), int(limit or 500))
|
|
409
|
+
mtime = p.stat().st_mtime
|
|
410
|
+
prev = self.seen.get(sig)
|
|
411
|
+
# Only point back to a copy the model can still see: once history is trimmed, that is just
|
|
412
|
+
# the last few calls (Agent._compact always keeps them).
|
|
413
|
+
if prev and prev[0] == mtime and (not self.recent_only or self.calls - prev[1] <= 6):
|
|
414
|
+
return ("(Unchanged since you last read it in this conversation; use that copy instead of "
|
|
415
|
+
"reading it again.)")
|
|
416
|
+
self.seen[sig] = (mtime, self.calls)
|
|
417
|
+
from . import attach
|
|
418
|
+
what = attach.kind(p)
|
|
419
|
+
if what in ("document", "spreadsheet") or p.suffix.lower() == ".pdf":
|
|
420
|
+
# Word/PowerPoint/PDF are zip or binary files: reading them as text gave the model
|
|
421
|
+
# pages of garbage, so it fell back to guessing python3 + python-docx one-liners.
|
|
422
|
+
text = attach.extract(p)
|
|
423
|
+
if text:
|
|
424
|
+
lines = text.splitlines()
|
|
425
|
+
else:
|
|
426
|
+
return (f"{p.name} is a {what} file whose text Forge couldn't extract here. "
|
|
427
|
+
"Ask the user to paste the parts you need, or try a converter with run_shell.")
|
|
428
|
+
elif what in ("image", "video", "audio", "font", "archive") or _binary(p):
|
|
429
|
+
return (f"{p.name} is a binary {what} file ({attach._size(p)}), so it has no text to read. "
|
|
430
|
+
"Use it by path: copy it into the project (for example assets/) and reference it "
|
|
431
|
+
"from the code" + ("; images the user attaches are shown to vision models directly."
|
|
432
|
+
if what == "image" else "."))
|
|
433
|
+
else:
|
|
434
|
+
lines = p.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
435
|
+
start = max(1, int(offset or 1))
|
|
436
|
+
chunk = lines[start - 1:start - 1 + int(limit or 500)]
|
|
437
|
+
if not chunk:
|
|
438
|
+
return f"(empty or offset past end; file has {len(lines)} lines)"
|
|
439
|
+
body = "\n".join(f"{i:>6}\t{ln}" for i, ln in enumerate(chunk, start))
|
|
440
|
+
end = start + len(chunk) - 1
|
|
441
|
+
return body + (f"\n(showing lines {start}-{end} of {len(lines)})" if end < len(lines) or start > 1 else "")
|
|
442
|
+
|
|
443
|
+
def t_write_file(self, path, content):
|
|
444
|
+
p = self._path(path)
|
|
445
|
+
exists = p.exists()
|
|
446
|
+
old = p.read_text(encoding="utf-8", errors="replace") if exists and p.is_file() else ""
|
|
447
|
+
if exists and old == content:
|
|
448
|
+
return f"Error: {p} already has exactly this content; nothing was changed."
|
|
449
|
+
lazy = self._lazy(p, content, old)
|
|
450
|
+
if lazy:
|
|
451
|
+
return lazy
|
|
452
|
+
diff = "\n".join(difflib.unified_diff(old.splitlines(), content.splitlines(), lineterm="", n=1)) \
|
|
453
|
+
if exists else "\n".join("+" + ln for ln in content.splitlines()[:40])
|
|
454
|
+
denied = self._approve("write_file", f"{'Overwrite' if exists else 'Create'} {p}", diff, p)
|
|
455
|
+
if denied:
|
|
456
|
+
return denied
|
|
457
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
458
|
+
checkpoint = self.checkpoints.capture([p])
|
|
459
|
+
self.checkpoints.add(checkpoint)
|
|
460
|
+
p.write_text(content, encoding="utf-8")
|
|
461
|
+
if not self._landed(p, content):
|
|
462
|
+
return f"Error: wrote {p} but reading it back gave different content; check the file."
|
|
463
|
+
self.ui.wrote(str(p), content, old if exists else None)
|
|
464
|
+
self._produced(p)
|
|
465
|
+
return f"Wrote {len(content)} characters to {p}." + self._syntax_note(p)
|
|
466
|
+
|
|
467
|
+
def t_edit_file(self, path, old_string, new_string, replace_all=False):
|
|
468
|
+
p = self._path(path)
|
|
469
|
+
if old_string == new_string:
|
|
470
|
+
return "Error: old_string and new_string are the same, so this edit would change nothing."
|
|
471
|
+
if not old_string:
|
|
472
|
+
return "Error: old_string is empty. Copy the exact text to replace from the file."
|
|
473
|
+
text = p.read_text(encoding="utf-8")
|
|
474
|
+
lazy = self._lazy(p, new_string, old_string)
|
|
475
|
+
if lazy:
|
|
476
|
+
return lazy
|
|
477
|
+
n = text.count(old_string)
|
|
478
|
+
if n == 0:
|
|
479
|
+
return "Error: old_string not found. Read the file and copy the text exactly."
|
|
480
|
+
if n > 1 and not replace_all:
|
|
481
|
+
return f"Error: old_string occurs {n} times. Add surrounding context or set replace_all."
|
|
482
|
+
new = text.replace(old_string, new_string) if replace_all else text.replace(old_string, new_string, 1)
|
|
483
|
+
diff = "\n".join(difflib.unified_diff(text.splitlines(), new.splitlines(), lineterm="", n=2))
|
|
484
|
+
denied = self._approve("edit_file", f"Edit {p}", diff, p)
|
|
485
|
+
if denied:
|
|
486
|
+
return denied
|
|
487
|
+
checkpoint = self.checkpoints.capture([p])
|
|
488
|
+
self.checkpoints.add(checkpoint)
|
|
489
|
+
p.write_text(new, encoding="utf-8")
|
|
490
|
+
if not self._landed(p, new):
|
|
491
|
+
return f"Error: edited {p} but reading it back gave different content; check the file."
|
|
492
|
+
self.ui.edited(str(p), text, new)
|
|
493
|
+
self._produced(p)
|
|
494
|
+
return (f"Edited {p} ({n if replace_all else 1} replacement{'s' if replace_all and n > 1 else ''})."
|
|
495
|
+
+ self._syntax_note(p))
|
|
496
|
+
|
|
497
|
+
def t_apply_patch(self, patch: str) -> str:
|
|
498
|
+
try:
|
|
499
|
+
operations = self._parse_patch(patch)
|
|
500
|
+
changes = self._prepare_patch(operations)
|
|
501
|
+
except (OSError, UnicodeError, ValueError) as e:
|
|
502
|
+
return f"Error: patch not applied: {e}"
|
|
503
|
+
if not changes:
|
|
504
|
+
return "Error: patch contains no file changes."
|
|
505
|
+
for path, old, new, kind in changes:
|
|
506
|
+
label = {"add": "Create", "delete": "Delete"}.get(kind, "Edit")
|
|
507
|
+
diff = "\n".join(difflib.unified_diff(
|
|
508
|
+
old.splitlines(), new.splitlines(), fromfile=str(path), tofile=str(path), lineterm=""))
|
|
509
|
+
denied = self._approve("edit_file", f"{label} {path}", diff, path)
|
|
510
|
+
if denied:
|
|
511
|
+
return denied
|
|
512
|
+
checkpoint = self.checkpoints.capture([path for path, _, _, _ in changes])
|
|
513
|
+
self.checkpoints.add(checkpoint)
|
|
514
|
+
try:
|
|
515
|
+
for path, _, new, kind in changes:
|
|
516
|
+
if kind == "delete":
|
|
517
|
+
path.unlink()
|
|
518
|
+
elif kind == "add":
|
|
519
|
+
self._atomic_write(path, new.encode("utf-8"))
|
|
520
|
+
else:
|
|
521
|
+
self._atomic_write(path, new.encode("utf-8"))
|
|
522
|
+
for path, old, new, kind in changes:
|
|
523
|
+
if kind == "add":
|
|
524
|
+
self.ui.wrote(str(path), new, None)
|
|
525
|
+
self._produced(path)
|
|
526
|
+
elif kind == "delete":
|
|
527
|
+
self.ui.edited(str(path), old, new)
|
|
528
|
+
else:
|
|
529
|
+
self.ui.edited(str(path), old, new)
|
|
530
|
+
self._produced(path)
|
|
531
|
+
except OSError as e:
|
|
532
|
+
try:
|
|
533
|
+
self.checkpoints.restore(checkpoint)
|
|
534
|
+
self.checkpoints.entries.remove(checkpoint)
|
|
535
|
+
except OSError as restore_error:
|
|
536
|
+
return (f"Error: patch partially applied ({e}); rollback failed ({restore_error}). "
|
|
537
|
+
"Use /undo to restore the captured files.")
|
|
538
|
+
return f"Error: patch not applied; changes rolled back: {e}"
|
|
539
|
+
notes = "".join(self._syntax_note(path) for path, _, _, kind in changes if kind != "delete")
|
|
540
|
+
return f"Patched {len(changes)} file(s)." + notes
|
|
541
|
+
|
|
542
|
+
_SCRIPT_TAG = re.compile(r"<script\b([^>]*)>(.*?)</script\s*>", re.S | re.I)
|
|
543
|
+
|
|
544
|
+
def _syntax_note(self, p) -> str:
|
|
545
|
+
"""Syntax-check JavaScript that was just written (a .js/.mjs/.cjs file or the inline scripts of
|
|
546
|
+
an .html page) with `node --check`. A page whose script doesn't parse shows nothing but its
|
|
547
|
+
placeholder, and an HTTP 200 won't reveal that, so the error goes straight back to the model.
|
|
548
|
+
Empty when it parses, when Node isn't installed, or for any other kind of file."""
|
|
549
|
+
p = pathlib.Path(p)
|
|
550
|
+
ext = p.suffix.lower()
|
|
551
|
+
if ext in (".py", ".json"):
|
|
552
|
+
# Parse only - nothing from the project runs, so this is safe after every edit.
|
|
553
|
+
try:
|
|
554
|
+
src = p.read_text(encoding="utf-8", errors="replace")
|
|
555
|
+
if ext == ".py":
|
|
556
|
+
compile(src, str(p), "exec", dont_inherit=True)
|
|
557
|
+
else:
|
|
558
|
+
json.loads(src)
|
|
559
|
+
except SyntaxError as e:
|
|
560
|
+
return (f"\nSyntax check FAILED: {p.name} line {e.lineno}: {e.msg}. "
|
|
561
|
+
"The file is saved but won't run; fix it now.")
|
|
562
|
+
except ValueError as e:
|
|
563
|
+
return f"\nJSON check FAILED: {p.name}: {e}. The file is saved but isn't valid JSON; fix it now."
|
|
564
|
+
except OSError:
|
|
565
|
+
pass
|
|
566
|
+
return ""
|
|
567
|
+
if ext not in (".html", ".htm", ".js", ".mjs", ".cjs") or not shutil.which("node"):
|
|
568
|
+
return ""
|
|
569
|
+
try:
|
|
570
|
+
text = p.read_text(encoding="utf-8", errors="replace")
|
|
571
|
+
except OSError:
|
|
572
|
+
return ""
|
|
573
|
+
if ext in (".js", ".mjs", ".cjs"):
|
|
574
|
+
scripts = [(1, text, ext)]
|
|
575
|
+
else:
|
|
576
|
+
scripts = []
|
|
577
|
+
for m in self._SCRIPT_TAG.finditer(text):
|
|
578
|
+
attrs, body = m.group(1), m.group(2)
|
|
579
|
+
if re.search(r"\bsrc\s*=", attrs, re.I) or not body.strip():
|
|
580
|
+
continue
|
|
581
|
+
kind = re.search(r"\btype\s*=\s*[\"']?([^\"'\s>]+)", attrs, re.I)
|
|
582
|
+
kind = (kind.group(1).lower() if kind else "text/javascript")
|
|
583
|
+
if kind == "module":
|
|
584
|
+
suffix = ".mjs"
|
|
585
|
+
elif kind in ("text/javascript", "application/javascript"):
|
|
586
|
+
suffix = ".js"
|
|
587
|
+
else:
|
|
588
|
+
continue # importmap, JSON, text/babel (Babel compiles it in the page)
|
|
589
|
+
scripts.append((text.count("\n", 0, m.start(2)) + 1, body, suffix))
|
|
590
|
+
problems = []
|
|
591
|
+
for first_line, body, suffix in scripts[:8]:
|
|
592
|
+
fd, tmp = tempfile.mkstemp(suffix=suffix)
|
|
593
|
+
try:
|
|
594
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
595
|
+
f.write(body)
|
|
596
|
+
r = subprocess.run(["node", "--check", tmp], capture_output=True, text=True,
|
|
597
|
+
encoding="utf-8", errors="replace", timeout=15)
|
|
598
|
+
except (OSError, subprocess.SubprocessError):
|
|
599
|
+
continue
|
|
600
|
+
finally:
|
|
601
|
+
try:
|
|
602
|
+
os.unlink(tmp)
|
|
603
|
+
except OSError:
|
|
604
|
+
pass
|
|
605
|
+
if r.returncode == 0:
|
|
606
|
+
continue
|
|
607
|
+
err = r.stderr or r.stdout
|
|
608
|
+
where = re.search(r":(\d+)\s*$", err.splitlines()[0] if err else "")
|
|
609
|
+
line = first_line + int(where.group(1)) - 1 if where else first_line
|
|
610
|
+
msg = next((ln.strip() for ln in err.splitlines() if "Error" in ln), "syntax error")
|
|
611
|
+
snippet = next((ln.rstrip() for ln in err.splitlines()[1:] if ln.strip()), "")
|
|
612
|
+
note = f"line {line}: {msg}" + (f" -> {snippet.strip()}" if snippet else "")
|
|
613
|
+
if "Unexpected token '<'" in msg and ext in (".html", ".htm"):
|
|
614
|
+
note += ("\n That is JSX. A browser can't run JSX without a build step: write the "
|
|
615
|
+
"components with htm (html`<${Comp} prop=${x}>...</${Comp}>`) or "
|
|
616
|
+
"React.createElement, or set the project up with a bundler (Vite).")
|
|
617
|
+
problems.append(note)
|
|
618
|
+
if not problems:
|
|
619
|
+
return ""
|
|
620
|
+
return (f"\n[Forge] {p.name} has JavaScript that does not parse, so it will not run "
|
|
621
|
+
"(a page shows only its placeholder). Fix this before testing or saying it works:\n "
|
|
622
|
+
+ "\n ".join(problems))
|
|
623
|
+
|
|
624
|
+
@staticmethod
|
|
625
|
+
def _parse_patch(patch: str) -> list[
|
|
626
|
+
tuple[str, str, list[tuple[int | None, list[tuple[str, str]], bool]] | str]]:
|
|
627
|
+
lines = patch.splitlines()
|
|
628
|
+
if len(lines) < 3 or lines[0] != "*** Begin Patch" or lines[-1] != "*** End Patch":
|
|
629
|
+
raise ValueError("expected *** Begin Patch and *** End Patch markers")
|
|
630
|
+
operations = []
|
|
631
|
+
index = 1
|
|
632
|
+
while index < len(lines) - 1:
|
|
633
|
+
line = lines[index]
|
|
634
|
+
index += 1
|
|
635
|
+
if line.startswith("*** Update File: "):
|
|
636
|
+
path = line[len("*** Update File: "):].strip()
|
|
637
|
+
hunks = []
|
|
638
|
+
while index < len(lines) - 1 and not lines[index].startswith("*** "):
|
|
639
|
+
header = lines[index]
|
|
640
|
+
if not header.startswith("@@"):
|
|
641
|
+
raise ValueError(f"expected a hunk header, got {header!r}")
|
|
642
|
+
match = re.match(r"^@@ -(\d+)(?:,\d+)?(?: \+\d+(?:,\d+)?)? @@", header)
|
|
643
|
+
start = int(match.group(1)) - 1 if match else None
|
|
644
|
+
index += 1
|
|
645
|
+
body = []
|
|
646
|
+
end_of_file = False
|
|
647
|
+
while index < len(lines) - 1 and not lines[index].startswith("@@"):
|
|
648
|
+
item = lines[index]
|
|
649
|
+
if item == "*** End of File":
|
|
650
|
+
end_of_file = True
|
|
651
|
+
index += 1
|
|
652
|
+
break
|
|
653
|
+
if item.startswith(("*** Update File:", "*** Add File:", "*** Delete File:", "*** End Patch")):
|
|
654
|
+
break
|
|
655
|
+
if not item or item[0] not in " +-":
|
|
656
|
+
raise ValueError(f"invalid patch line {item!r}")
|
|
657
|
+
body.append((item[0], item[1:]))
|
|
658
|
+
index += 1
|
|
659
|
+
hunks.append((start, body, end_of_file))
|
|
660
|
+
if not hunks:
|
|
661
|
+
raise ValueError(f"no hunks supplied for {path}")
|
|
662
|
+
operations.append(("update", path, hunks))
|
|
663
|
+
elif line.startswith("*** Add File: "):
|
|
664
|
+
path = line[len("*** Add File: "):].strip()
|
|
665
|
+
content = []
|
|
666
|
+
while index < len(lines) - 1 and not lines[index].startswith("*** "):
|
|
667
|
+
item = lines[index]
|
|
668
|
+
if not item.startswith("+"):
|
|
669
|
+
raise ValueError(f"added file lines must start with +, got {item!r}")
|
|
670
|
+
content.append(item[1:])
|
|
671
|
+
index += 1
|
|
672
|
+
operations.append(("add", path, "\n".join(content) + ("\n" if content else "")))
|
|
673
|
+
elif line.startswith("*** Delete File: "):
|
|
674
|
+
path = line[len("*** Delete File: "):].strip()
|
|
675
|
+
operations.append(("delete", path, ""))
|
|
676
|
+
else:
|
|
677
|
+
raise ValueError(f"unknown patch directive {line!r}")
|
|
678
|
+
if not path:
|
|
679
|
+
raise ValueError("patch file path is empty")
|
|
680
|
+
return operations
|
|
681
|
+
|
|
682
|
+
def _prepare_patch(self, operations):
|
|
683
|
+
changes = []
|
|
684
|
+
seen = set()
|
|
685
|
+
for kind, raw_path, payload in operations:
|
|
686
|
+
path = self._path(raw_path)
|
|
687
|
+
if path in seen:
|
|
688
|
+
raise ValueError(f"file appears more than once in patch: {path}")
|
|
689
|
+
seen.add(path)
|
|
690
|
+
exists = path.is_file()
|
|
691
|
+
if kind == "add":
|
|
692
|
+
if path.exists():
|
|
693
|
+
raise ValueError(f"cannot add existing file: {path}")
|
|
694
|
+
new = payload
|
|
695
|
+
old = ""
|
|
696
|
+
elif not exists:
|
|
697
|
+
raise ValueError(f"file does not exist: {path}")
|
|
698
|
+
elif kind == "delete":
|
|
699
|
+
old = path.read_bytes().decode("utf-8")
|
|
700
|
+
new = ""
|
|
701
|
+
else:
|
|
702
|
+
old = path.read_bytes().decode("utf-8")
|
|
703
|
+
new = self._apply_hunks(old, payload)
|
|
704
|
+
if new == old or guards.placeholders(new, old):
|
|
705
|
+
raise ValueError(f"patch for {path} changes nothing or adds placeholder text")
|
|
706
|
+
changes.append((path, old, new, kind))
|
|
707
|
+
return changes
|
|
708
|
+
|
|
709
|
+
@staticmethod
|
|
710
|
+
def _apply_hunks(text: str, hunks) -> str:
|
|
711
|
+
lines = text.splitlines()
|
|
712
|
+
final_newline = text.endswith(("\n", "\r"))
|
|
713
|
+
newline = "\r\n" if "\r\n" in text else "\n"
|
|
714
|
+
line_offset = 0
|
|
715
|
+
for start, body, end_of_file in hunks:
|
|
716
|
+
old = [value for prefix, value in body if prefix in " -"]
|
|
717
|
+
new = [value for prefix, value in body if prefix in " +"]
|
|
718
|
+
if not old:
|
|
719
|
+
position = len(lines) if start is None else min(max(0, start + line_offset), len(lines))
|
|
720
|
+
if end_of_file and position != len(lines):
|
|
721
|
+
raise ValueError("end-of-file hunk does not point to the file end")
|
|
722
|
+
lines[position:position] = new
|
|
723
|
+
line_offset += len(new)
|
|
724
|
+
continue
|
|
725
|
+
expected = None if start is None else max(0, start + line_offset)
|
|
726
|
+
match_at = Tools._locate_hunk(lines, old, expected)
|
|
727
|
+
if end_of_file and match_at + len(old) != len(lines):
|
|
728
|
+
raise ValueError("end-of-file hunk does not match the file end")
|
|
729
|
+
replacement = []
|
|
730
|
+
old_index = 0
|
|
731
|
+
for prefix, value in body:
|
|
732
|
+
if prefix == " ":
|
|
733
|
+
replacement.append(lines[match_at + old_index])
|
|
734
|
+
old_index += 1
|
|
735
|
+
elif prefix == "-":
|
|
736
|
+
old_index += 1
|
|
737
|
+
else:
|
|
738
|
+
replacement.append(value)
|
|
739
|
+
lines[match_at:match_at + len(old)] = replacement
|
|
740
|
+
line_offset += len(new) - len(old)
|
|
741
|
+
result = newline.join(lines)
|
|
742
|
+
if final_newline and lines:
|
|
743
|
+
result += newline
|
|
744
|
+
elif not final_newline:
|
|
745
|
+
result = result.rstrip("\r\n")
|
|
746
|
+
return result
|
|
747
|
+
|
|
748
|
+
@staticmethod
|
|
749
|
+
def _locate_hunk(lines: list[str], old: list[str], expected: int | None) -> int:
|
|
750
|
+
limit = len(lines) - len(old)
|
|
751
|
+
exact = [i for i in range(limit + 1) if lines[i:i + len(old)] == old]
|
|
752
|
+
if expected in exact:
|
|
753
|
+
return expected
|
|
754
|
+
if len(exact) == 1:
|
|
755
|
+
return exact[0]
|
|
756
|
+
if len(exact) > 1:
|
|
757
|
+
raise ValueError("hunk context is ambiguous")
|
|
758
|
+
|
|
759
|
+
def normalize(value):
|
|
760
|
+
return re.sub(r"\s+", " ", value).strip()
|
|
761
|
+
|
|
762
|
+
wanted = [normalize(line) for line in old]
|
|
763
|
+
relaxed = [i for i in range(limit + 1)
|
|
764
|
+
if [normalize(line) for line in lines[i:i + len(old)]] == wanted]
|
|
765
|
+
if expected in relaxed:
|
|
766
|
+
return expected
|
|
767
|
+
if len(relaxed) == 1:
|
|
768
|
+
return relaxed[0]
|
|
769
|
+
if len(relaxed) > 1:
|
|
770
|
+
raise ValueError("whitespace-tolerant hunk context is ambiguous")
|
|
771
|
+
|
|
772
|
+
scored = []
|
|
773
|
+
for i in range(limit + 1):
|
|
774
|
+
candidate = [normalize(line) for line in lines[i:i + len(old)]]
|
|
775
|
+
score = difflib.SequenceMatcher(a=wanted, b=candidate, autojunk=False).ratio()
|
|
776
|
+
scored.append((score, i))
|
|
777
|
+
scored.sort(reverse=True)
|
|
778
|
+
if not scored or scored[0][0] < 0.88 or len(scored) > 1 and scored[0][0] - scored[1][0] < 0.08:
|
|
779
|
+
raise ValueError("no matching context for patch hunk")
|
|
780
|
+
return scored[0][1]
|
|
781
|
+
|
|
782
|
+
@staticmethod
|
|
783
|
+
def _atomic_write(path: pathlib.Path, content: bytes) -> None:
|
|
784
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
785
|
+
fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.forge-patch-",
|
|
786
|
+
dir=path.parent)
|
|
787
|
+
tmp = pathlib.Path(tmp_name)
|
|
788
|
+
try:
|
|
789
|
+
with os.fdopen(fd, "wb") as stream:
|
|
790
|
+
stream.write(content)
|
|
791
|
+
if path.exists():
|
|
792
|
+
os.chmod(tmp, stat.S_IMODE(path.stat().st_mode))
|
|
793
|
+
os.replace(tmp, path)
|
|
794
|
+
finally:
|
|
795
|
+
if tmp.exists():
|
|
796
|
+
tmp.unlink()
|
|
797
|
+
|
|
798
|
+
def _lazy(self, p: pathlib.Path, content: str, old: str) -> str | None:
|
|
799
|
+
"""Refuse placeholder content on every attempt; a retry must provide real content."""
|
|
800
|
+
hits = guards.placeholders(content, old)
|
|
801
|
+
if not hits:
|
|
802
|
+
return None
|
|
803
|
+
return ("Error: not written — this looks like placeholder text instead of real content: "
|
|
804
|
+
+ " | ".join(hits[:3]) + ". Write the complete code (the tool replaces text literally, so "
|
|
805
|
+
"a placeholder would delete what it stands for). Replace the placeholder with complete content.")
|
|
806
|
+
|
|
807
|
+
@staticmethod
|
|
808
|
+
def _landed(p: pathlib.Path, expected: str) -> bool:
|
|
809
|
+
try:
|
|
810
|
+
return p.read_text(encoding="utf-8").replace("\r\n", "\n") == expected.replace("\r\n", "\n")
|
|
811
|
+
except OSError:
|
|
812
|
+
return False
|
|
813
|
+
|
|
814
|
+
def _produced(self, p: pathlib.Path) -> None:
|
|
815
|
+
if p.suffix.lower() in (".html", ".htm") and p not in self.pages:
|
|
816
|
+
self.pages.append(p)
|
|
817
|
+
|
|
818
|
+
def new_turn(self) -> None:
|
|
819
|
+
self.pages, self.urls, self.opened_now = [], [], False
|
|
820
|
+
self.todo_items = []
|
|
821
|
+
self.ledger.clear()
|
|
822
|
+
self.openapi.active.clear()
|
|
823
|
+
self.mcp.exposed.clear()
|
|
824
|
+
|
|
825
|
+
def show_result(self) -> str | None:
|
|
826
|
+
"""After a turn: open what was built, if the model didn't already. Returns what was opened."""
|
|
827
|
+
if self.opened_now:
|
|
828
|
+
return None
|
|
829
|
+
target = None
|
|
830
|
+
if self.urls:
|
|
831
|
+
target = self.urls[-1]
|
|
832
|
+
elif self.pages:
|
|
833
|
+
# The page the user lands on: index.html if one was made, else the first page written.
|
|
834
|
+
page = next((p for p in self.pages if p.name.lower() in ("index.html", "index.htm")), self.pages[0])
|
|
835
|
+
target = page.resolve().as_uri()
|
|
836
|
+
if not target:
|
|
837
|
+
return None
|
|
838
|
+
if target in self.opened:
|
|
839
|
+
return "refresh" # already open in a tab; a new tab per edit would pile up
|
|
840
|
+
import webbrowser
|
|
841
|
+
if webbrowser.open(target):
|
|
842
|
+
self.opened.add(target)
|
|
843
|
+
return target
|
|
844
|
+
return None
|
|
845
|
+
|
|
846
|
+
def t_list_dir(self, path="."):
|
|
847
|
+
p = self._path(path)
|
|
848
|
+
self.ui.peek("list", str(p))
|
|
849
|
+
rows = []
|
|
850
|
+
for e in sorted(p.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower()))[:500]:
|
|
851
|
+
rows.append(e.name + "/" if e.is_dir() else f"{e.name} ({e.stat().st_size} B)")
|
|
852
|
+
out = "\n".join(rows) or "(empty directory)"
|
|
853
|
+
skill_docs = [d for d in [p / "SKILL.md", *sorted(p.glob("skills/*/SKILL.md"))[:3]] if d.is_file()]
|
|
854
|
+
if skill_docs:
|
|
855
|
+
out += ("\n\n[Forge: this folder is an agent skill. Its instructions are "
|
|
856
|
+
+ ", ".join(str(d) for d in skill_docs)
|
|
857
|
+
+ ": read that first and follow its steps and checks (run any scripts it says to). "
|
|
858
|
+
"Its examples and demo pages are references, not the thing to copy.]")
|
|
859
|
+
return out
|
|
860
|
+
|
|
861
|
+
def t_glob(self, pattern, path="."):
|
|
862
|
+
base = self._path(path)
|
|
863
|
+
self.ui.peek("search", f"{pattern} in {base}")
|
|
864
|
+
if "**" in pattern and _broad_search_root(base):
|
|
865
|
+
return ("Error: refusing a recursive search from a home or drive root. "
|
|
866
|
+
"Search a specific project or subdirectory instead.")
|
|
867
|
+
hits = []
|
|
868
|
+
for m in base.glob(pattern):
|
|
869
|
+
if SKIP_DIRS.intersection(m.relative_to(base).parts):
|
|
870
|
+
continue
|
|
871
|
+
hits.append(str(m))
|
|
872
|
+
if len(hits) >= 500:
|
|
873
|
+
hits.append("… (stopped at 500 results)")
|
|
874
|
+
break
|
|
875
|
+
return "\n".join(hits) or "No matches."
|
|
876
|
+
|
|
877
|
+
def t_grep(self, pattern, path=".", glob=None, ignore_case=False):
|
|
878
|
+
base = self._path(path)
|
|
879
|
+
self.ui.peek("search", f"/{pattern}/ in {base}")
|
|
880
|
+
if base.is_dir() and _broad_search_root(base):
|
|
881
|
+
return ("Error: refusing a recursive search from a home or drive root. "
|
|
882
|
+
"Search a specific project or subdirectory instead.")
|
|
883
|
+
rx = re.compile(pattern, re.I if ignore_case else 0)
|
|
884
|
+
files = [base] if base.is_file() else []
|
|
885
|
+
if base.is_dir():
|
|
886
|
+
for root, dirs, names in os.walk(base):
|
|
887
|
+
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
|
|
888
|
+
files += [pathlib.Path(root, n) for n in names if not glob or fnmatch.fnmatch(n, glob)]
|
|
889
|
+
out = []
|
|
890
|
+
for f in files:
|
|
891
|
+
try:
|
|
892
|
+
if f.stat().st_size > 2_000_000:
|
|
893
|
+
continue
|
|
894
|
+
data = f.read_bytes()
|
|
895
|
+
if b"\0" in data[:8000]:
|
|
896
|
+
continue
|
|
897
|
+
for i, ln in enumerate(data.decode("utf-8", "replace").splitlines(), 1):
|
|
898
|
+
if rx.search(ln):
|
|
899
|
+
out.append(f"{f}:{i}: {ln.strip()[:300]}")
|
|
900
|
+
if len(out) >= 200:
|
|
901
|
+
return "\n".join(out) + "\n… (stopped at 200 matches)"
|
|
902
|
+
except OSError:
|
|
903
|
+
continue
|
|
904
|
+
return "\n".join(out) or "No matches."
|
|
905
|
+
|
|
906
|
+
def t_repo_map(self, path="."):
|
|
907
|
+
from . import repomap
|
|
908
|
+
|
|
909
|
+
base = self._path(path)
|
|
910
|
+
if _broad_search_root(base):
|
|
911
|
+
return ("Error: refusing to map a home or drive root. "
|
|
912
|
+
"Choose a specific project folder instead.")
|
|
913
|
+
self.ui.peek("search", f"repository map in {base}")
|
|
914
|
+
return repomap.build(base)
|
|
915
|
+
|
|
916
|
+
def t_run_shell(self, command, cwd=None, timeout=120, background=False):
|
|
917
|
+
where = self._path(cwd)
|
|
918
|
+
from . import sandbox
|
|
919
|
+
boxed = sandbox.active()
|
|
920
|
+
if boxed:
|
|
921
|
+
problem = sandbox.available()
|
|
922
|
+
if problem:
|
|
923
|
+
return "Error: FORGE_SANDBOX=docker is on but " + problem
|
|
924
|
+
elif "powershell" in self.shell.lower() or "pwsh" in self.shell.lower():
|
|
925
|
+
command = unixy_to_powershell(command)
|
|
926
|
+
command = local_only_server(command)
|
|
927
|
+
from . import github
|
|
928
|
+
risk = github.git_risk(command)
|
|
929
|
+
if risk == github.DESTRUCTIVE:
|
|
930
|
+
denied = self._confirm_destructive("Destructive GitHub/Git action", "$ " + command)
|
|
931
|
+
else:
|
|
932
|
+
denied = self._approve("run_shell", f"Run in {where}" + (" (docker sandbox)" if boxed else ""),
|
|
933
|
+
"$ " + command, command=command,
|
|
934
|
+
why="GitHub write action" if risk == github.WRITE else "")
|
|
935
|
+
if denied:
|
|
936
|
+
return denied
|
|
937
|
+
self.ui.tool("run_shell", command + (" (background)" if background else ""))
|
|
938
|
+
if not where.is_dir():
|
|
939
|
+
return f"Error: folder not found: {where}"
|
|
940
|
+
try:
|
|
941
|
+
timeout = max(1, min(int(timeout or 120), 600))
|
|
942
|
+
except (TypeError, ValueError):
|
|
943
|
+
timeout = 120
|
|
944
|
+
if _LONG_COMMAND.search(command):
|
|
945
|
+
# Installs, clones and builds routinely pass two minutes on a slow connection; killing
|
|
946
|
+
# them at 120s made the model start over again and again.
|
|
947
|
+
timeout = max(timeout, 600)
|
|
948
|
+
box_argv = None
|
|
949
|
+
if boxed:
|
|
950
|
+
box_argv = sandbox.argv(self.root, where, command)
|
|
951
|
+
if isinstance(box_argv, str):
|
|
952
|
+
return box_argv
|
|
953
|
+
p = procs.start(self.shell, command, str(where), argv=box_argv)
|
|
954
|
+
if background:
|
|
955
|
+
return self._detached(p, wait=6)
|
|
956
|
+
self.ui.start_spinner(f"running {command[:50]}")
|
|
957
|
+
deadline = time.time() + timeout
|
|
958
|
+
try:
|
|
959
|
+
while p.alive():
|
|
960
|
+
time.sleep(0.25)
|
|
961
|
+
ran = time.time() - p.started
|
|
962
|
+
# Servers never exit; once one reports it's up, let it run and hand back control.
|
|
963
|
+
if ran >= 3 and procs.looks_like_server(p):
|
|
964
|
+
self.ui.stop_spinner()
|
|
965
|
+
return self._detached(p, wait=0, auto=True)
|
|
966
|
+
if time.time() > deadline:
|
|
967
|
+
self.ui.stop_spinner()
|
|
968
|
+
if _still_progressing(p):
|
|
969
|
+
# Still printing progress: it's slow, not stuck. Keep it running instead
|
|
970
|
+
# of throwing the work away.
|
|
971
|
+
return ("It is still working (output is still coming), so it was left "
|
|
972
|
+
"running. " + self._detached(p, wait=0) +
|
|
973
|
+
"\nUse process with action=wait to wait for it to finish.")
|
|
974
|
+
procs.kill(p)
|
|
975
|
+
out = p.output().strip()
|
|
976
|
+
procs.forget(p)
|
|
977
|
+
self.ui.tool_result(out or "(no output)")
|
|
978
|
+
return (f"Command was stopped after {timeout}s (still running). If it is meant to keep "
|
|
979
|
+
f"running, use background=true.\nOutput so far:\n{out or '(no output)'}")
|
|
980
|
+
except KeyboardInterrupt:
|
|
981
|
+
self.ui.stop_spinner()
|
|
982
|
+
procs.kill(p)
|
|
983
|
+
procs.forget(p)
|
|
984
|
+
raise
|
|
985
|
+
self.ui.stop_spinner()
|
|
986
|
+
out = p.output(MAX_OUT * 2).strip()
|
|
987
|
+
code = p.popen.returncode
|
|
988
|
+
procs.forget(p)
|
|
989
|
+
self.ui.tool_result(out or "(no output)")
|
|
990
|
+
return f"exit code {code}\n{out or '(no output)'}"
|
|
991
|
+
|
|
992
|
+
def _detached(self, p, wait: float, auto: bool = False) -> str:
|
|
993
|
+
end = time.time() + wait
|
|
994
|
+
while wait and time.time() < end and p.alive() and not p.urls():
|
|
995
|
+
time.sleep(0.25)
|
|
996
|
+
out = p.output(4000).strip()
|
|
997
|
+
if not p.alive():
|
|
998
|
+
code = p.popen.returncode
|
|
999
|
+
procs.forget(p)
|
|
1000
|
+
self.ui.tool_result(out or "(no output)")
|
|
1001
|
+
return f"The command exited right away (exit code {code}).\n{out or '(no output)'}"
|
|
1002
|
+
urls = p.urls()
|
|
1003
|
+
if urls:
|
|
1004
|
+
self.urls.append(urls[0])
|
|
1005
|
+
self.ui.tool_result((f"running in background as process {p.id}" +
|
|
1006
|
+
(f" · {urls[0]}" if urls else "")))
|
|
1007
|
+
head = ("It kept running like a server, so it was moved to the background. " if auto else "") + \
|
|
1008
|
+
f"Running in the background as process {p.id}."
|
|
1009
|
+
if urls:
|
|
1010
|
+
head += f" Local URL: {urls[0]} (open it with open_url)."
|
|
1011
|
+
return (f"{head} Use the process tool to read its output or stop it; it is stopped when Forge "
|
|
1012
|
+
f"exits.\nOutput so far:\n{out or '(none yet)'}")
|
|
1013
|
+
|
|
1014
|
+
def t_process(self, action="list", id=None, timeout=300):
|
|
1015
|
+
if action == "list":
|
|
1016
|
+
items = procs.all_procs()
|
|
1017
|
+
if not items:
|
|
1018
|
+
return "No background processes."
|
|
1019
|
+
return "\n".join(f"{p.id}: {p.state()} · {p.command} · in {p.cwd}" +
|
|
1020
|
+
(f" · {p.urls()[0]}" if p.urls() else "") for p in items)
|
|
1021
|
+
p = procs.get(id)
|
|
1022
|
+
if not p:
|
|
1023
|
+
return f"Error: no background process {id}. Use action=list."
|
|
1024
|
+
if action == "output":
|
|
1025
|
+
self.ui.tool("process", f"output of {p.id}")
|
|
1026
|
+
return f"{p.state()}\n{p.output() or '(no output)'}"
|
|
1027
|
+
if action == "wait":
|
|
1028
|
+
try:
|
|
1029
|
+
timeout = max(1, min(int(timeout or 300), 600))
|
|
1030
|
+
except (TypeError, ValueError):
|
|
1031
|
+
timeout = 300
|
|
1032
|
+
self.ui.tool("process", f"wait for {p.id}: {p.command}")
|
|
1033
|
+
self.ui.start_spinner(f"waiting for {p.command[:50]}")
|
|
1034
|
+
end = time.time() + timeout
|
|
1035
|
+
try:
|
|
1036
|
+
while p.alive() and time.time() < end:
|
|
1037
|
+
time.sleep(0.5)
|
|
1038
|
+
finally:
|
|
1039
|
+
self.ui.stop_spinner()
|
|
1040
|
+
if p.alive():
|
|
1041
|
+
return (f"Still running after waiting {timeout}s more (process {p.id}). Wait again or "
|
|
1042
|
+
f"stop it.\nLatest output:\n{p.output(4000) or '(none yet)'}")
|
|
1043
|
+
code = p.popen.returncode
|
|
1044
|
+
out = p.output(MAX_OUT).strip()
|
|
1045
|
+
procs.forget(p)
|
|
1046
|
+
self.ui.tool_result(out or "(no output)")
|
|
1047
|
+
return f"Finished, exit code {code}\n{out or '(no output)'}"
|
|
1048
|
+
if action == "stop":
|
|
1049
|
+
ports = {urllib.parse.urlparse(u).port for u in p.urls()} - {None}
|
|
1050
|
+
shown = [u for u in self.opened if urllib.parse.urlparse(u).port in ports]
|
|
1051
|
+
if shown and p.id not in self._stop_warned:
|
|
1052
|
+
self._stop_warned.add(p.id)
|
|
1053
|
+
return (f"Not stopped: process {p.id} serves {shown[0]}, which you opened for the user. "
|
|
1054
|
+
"Stopping it leaves them a dead page, so leave it running (Forge stops it when "
|
|
1055
|
+
"it exits). Call stop again only if the user asked or it must restart.")
|
|
1056
|
+
self.ui.tool("process", f"stop {p.id}: {p.command}")
|
|
1057
|
+
procs.kill(p)
|
|
1058
|
+
out = p.output(2000)
|
|
1059
|
+
procs.forget(p)
|
|
1060
|
+
return f"Stopped process {p.id}.\nLast output:\n{out}"
|
|
1061
|
+
return "Error: action must be list, output or stop."
|
|
1062
|
+
|
|
1063
|
+
def t_web_fetch(self, url, max_chars=12000):
|
|
1064
|
+
if not re.match(r"^https?://", url or "", re.I):
|
|
1065
|
+
return "Error: web_fetch needs an http:// or https:// address."
|
|
1066
|
+
self.peek("fetch", url)
|
|
1067
|
+
status, ctype, text = http_get(url)
|
|
1068
|
+
if "html" in ctype or text.lstrip()[:15].lower().startswith(("<!doctype", "<html")):
|
|
1069
|
+
text = html_to_text(text)
|
|
1070
|
+
max_chars = max(500, min(int(max_chars or 12000), MAX_OUT))
|
|
1071
|
+
more = f"\n\n… [{len(text) - max_chars} more characters]" if len(text) > max_chars else ""
|
|
1072
|
+
if "html" in ctype and len(text.strip()) < 200:
|
|
1073
|
+
more += ("\n\n[Forge] Almost no text: this page is built by JavaScript in the browser, so "
|
|
1074
|
+
"web_fetch cannot show whether it renders. HTTP 200 only proves the file is "
|
|
1075
|
+
"served. Check the script and imports it loads, or use browser_check.")
|
|
1076
|
+
return f"HTTP {status} · {ctype or 'unknown type'}\n\n{text[:max_chars]}{more}"
|
|
1077
|
+
|
|
1078
|
+
def t_http_request(self, **args):
|
|
1079
|
+
return self.http.request(**args)
|
|
1080
|
+
|
|
1081
|
+
def t_github_request(self, method, path, query=None, json_body=None, timeout=30):
|
|
1082
|
+
path = str(path or "")
|
|
1083
|
+
if not path.startswith("/") or path.startswith("//") or ".." in path.split("/"):
|
|
1084
|
+
return "Error: GitHub API path must be an absolute API path without parent traversal."
|
|
1085
|
+
return self.http.request(method, self.github_api_base.rstrip("/") + path,
|
|
1086
|
+
query=query, json_body=json_body, timeout=timeout)
|
|
1087
|
+
|
|
1088
|
+
def t_openapi_import(self, source, name=""):
|
|
1089
|
+
started = time.monotonic()
|
|
1090
|
+
result = self.openapi.import_spec(source, name)
|
|
1091
|
+
self.usage.record("openapi", str(name or "imported API"),
|
|
1092
|
+
not result.startswith("Error:"), time.monotonic() - started)
|
|
1093
|
+
return result
|
|
1094
|
+
|
|
1095
|
+
def t_openapi_search(self, query):
|
|
1096
|
+
hits = self.openapi.search(str(query))
|
|
1097
|
+
if not hits:
|
|
1098
|
+
return "No imported OpenAPI operation matches that query."
|
|
1099
|
+
return "Matching operations (their exact schemas are now available):\n" + "\n".join(
|
|
1100
|
+
f"- {o['name']}: {o['method']} {o['path']} — {o['summary']}" for o in hits)
|
|
1101
|
+
|
|
1102
|
+
def t_mcp_search(self, query):
|
|
1103
|
+
return self.mcp.search(str(query))
|
|
1104
|
+
|
|
1105
|
+
def extra_schemas(self, query: str) -> list[dict]:
|
|
1106
|
+
schemas = [OPENAPI_IMPORT_SCHEMA, OPENAPI_SEARCH_SCHEMA, *self.openapi.schemas(query)]
|
|
1107
|
+
if self.mcp.configs:
|
|
1108
|
+
schemas.extend(self.mcp.schemas(query))
|
|
1109
|
+
return schemas
|
|
1110
|
+
|
|
1111
|
+
def doctor_report(self) -> str:
|
|
1112
|
+
reports = [self.mcp.report(), self.openapi.report()]
|
|
1113
|
+
return "\n".join(report for report in reports if report)
|
|
1114
|
+
|
|
1115
|
+
def t_web_search(self, query, count=6):
|
|
1116
|
+
import urllib.parse
|
|
1117
|
+
self.peek("search", f"web: {query}")
|
|
1118
|
+
status, _, page = http_get("https://html.duckduckgo.com/html/?q=" + urllib.parse.quote(query))
|
|
1119
|
+
if status != 200:
|
|
1120
|
+
return f"Error: search failed (HTTP {status}). Try web_fetch on a known site instead."
|
|
1121
|
+
out = []
|
|
1122
|
+
for m in re.finditer(r'(?s)class="result__a"[^>]*href="([^"]+)"[^>]*>(.*?)</a>.*?'
|
|
1123
|
+
r'class="result__snippet"[^>]*>(.*?)</a>', page):
|
|
1124
|
+
link = urllib.parse.unquote(re.sub(r"^.*?uddg=([^&]+).*$", r"\1", m.group(1)))
|
|
1125
|
+
if "duckduckgo.com/y.js" in link:
|
|
1126
|
+
continue # ad
|
|
1127
|
+
title, snip = (html_to_text(m.group(i)) for i in (2, 3))
|
|
1128
|
+
out.append(f"{len(out) + 1}. {title}\n {link}\n {snip}")
|
|
1129
|
+
if len(out) >= max(1, min(int(count or 6), 10)):
|
|
1130
|
+
break
|
|
1131
|
+
return "\n".join(out) if out else "No results."
|
|
1132
|
+
|
|
1133
|
+
def t_todo(self, items):
|
|
1134
|
+
items = [i for i in (items or []) if isinstance(i, dict)]
|
|
1135
|
+
self.ui.todos(items)
|
|
1136
|
+
self.todo_items = items
|
|
1137
|
+
left = sum(i.get("status") != "done" for i in items)
|
|
1138
|
+
return f"Checklist updated ({len(items) - left}/{len(items)} done)."
|
|
1139
|
+
|
|
1140
|
+
def t_memory(self, action, scope, note=""):
|
|
1141
|
+
if action == "read":
|
|
1142
|
+
content = memorymod.read(scope, str(self.root))
|
|
1143
|
+
return content or f"No {scope} memory saved yet."
|
|
1144
|
+
if action == "remember":
|
|
1145
|
+
path = memorymod.remember(scope, str(self.root), note)
|
|
1146
|
+
return f"Saved {scope} memory to {path}."
|
|
1147
|
+
return "Error: action must be 'read' or 'remember'."
|
|
1148
|
+
|
|
1149
|
+
def t_ask_user(self, question, options=None):
|
|
1150
|
+
ans = self.ui.ask(str(question), [str(o) for o in (options or [])][:6])
|
|
1151
|
+
if not ans:
|
|
1152
|
+
return "No answer (non-interactive). Make the most sensible choice and say which you picked."
|
|
1153
|
+
# People answer questions with files too ("use C:\pics\me.jpg"): treat those exactly like
|
|
1154
|
+
# attachments to a message, extracted text and all.
|
|
1155
|
+
from . import attach
|
|
1156
|
+
paths = attach.find(ans, self.root)
|
|
1157
|
+
return f"The user answered: {ans}" + (attach.note(paths, self.root) if paths else "")
|
|
1158
|
+
|
|
1159
|
+
def peek(self, kind, target):
|
|
1160
|
+
self.ui.peek(kind, target)
|
|
1161
|
+
|
|
1162
|
+
def t_open_url(self, url):
|
|
1163
|
+
if not re.match(r"^(https?://|file:///)", url or "", re.I):
|
|
1164
|
+
return "Error: only http(s):// or file:/// addresses can be opened."
|
|
1165
|
+
self.ui.tool("open_url", url)
|
|
1166
|
+
self.opened.add(url)
|
|
1167
|
+
self.opened_now = True
|
|
1168
|
+
import webbrowser
|
|
1169
|
+
return "Opened in the browser." if webbrowser.open(url) else "Error: could not open a browser."
|
|
1170
|
+
|
|
1171
|
+
def t_browser_check(self, url):
|
|
1172
|
+
parsed = urllib.parse.urlparse(url or "")
|
|
1173
|
+
host = (parsed.hostname or "").lower()
|
|
1174
|
+
if (parsed.scheme not in ("http", "https") or host not in ("localhost", "127.0.0.1", "::1") or
|
|
1175
|
+
parsed.username is not None or parsed.password is not None):
|
|
1176
|
+
return "Error: browser_check only accepts localhost URLs."
|
|
1177
|
+
shot = browsercheck.shot_path(self.root)
|
|
1178
|
+
info = browsercheck.playwright_check(url, shot)
|
|
1179
|
+
if info is None: # no Playwright: Edge/Chrome can still take the picture
|
|
1180
|
+
info = browsercheck.cli_screenshot(url, shot)
|
|
1181
|
+
if shot.exists() and shot.stat().st_size > 0:
|
|
1182
|
+
self.pending_images.append(str(shot))
|
|
1183
|
+
info += f"\nscreenshot: {shot} (shown to you next if your model can see images)"
|
|
1184
|
+
return info
|