forgecommandline 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. forgecli/__init__.py +2 -0
  2. forgecli/ads.py +322 -0
  3. forgecli/agent.py +1093 -0
  4. forgecli/art.py +574 -0
  5. forgecli/attach.py +246 -0
  6. forgecli/brief.py +215 -0
  7. forgecli/browsercheck.py +99 -0
  8. forgecli/checkpoints.py +81 -0
  9. forgecli/city.py +833 -0
  10. forgecli/cli.py +1668 -0
  11. forgecli/config.py +194 -0
  12. forgecli/courier.py +177 -0
  13. forgecli/courier_data.py +164 -0
  14. forgecli/courier_hd.py +431 -0
  15. forgecli/courier_hd_data.bin +0 -0
  16. forgecli/courier_hd_data.py +19 -0
  17. forgecli/ghlogin.py +76 -0
  18. forgecli/github.py +54 -0
  19. forgecli/guards.py +403 -0
  20. forgecli/hooks.py +50 -0
  21. forgecli/httpapi.py +434 -0
  22. forgecli/images.py +110 -0
  23. forgecli/keys.py +152 -0
  24. forgecli/keyvault.py +320 -0
  25. forgecli/mcp.py +674 -0
  26. forgecli/memory.py +42 -0
  27. forgecli/openapi.py +331 -0
  28. forgecli/parallel.py +308 -0
  29. forgecli/prices.py +72 -0
  30. forgecli/procs.py +144 -0
  31. forgecli/providers.py +758 -0
  32. forgecli/quality.py +192 -0
  33. forgecli/repomap.py +78 -0
  34. forgecli/roster.py +227 -0
  35. forgecli/safety.py +93 -0
  36. forgecli/sandbox.py +83 -0
  37. forgecli/sessions.py +97 -0
  38. forgecli/skills.py +446 -0
  39. forgecli/tools.py +1184 -0
  40. forgecli/ui.py +860 -0
  41. forgecli/worktrees.py +79 -0
  42. forgecommandline-0.8.0.data/data/skills/commit/SKILL.md +14 -0
  43. forgecommandline-0.8.0.data/data/skills/debug/SKILL.md +17 -0
  44. forgecommandline-0.8.0.data/data/skills/explain/SKILL.md +14 -0
  45. forgecommandline-0.8.0.data/data/skills/init/SKILL.md +19 -0
  46. forgecommandline-0.8.0.data/data/skills/library/LICENSES/agent-skills-MIT.txt +21 -0
  47. forgecommandline-0.8.0.data/data/skills/library/LICENSES/wondelai-MIT.txt +23 -0
  48. forgecommandline-0.8.0.data/data/skills/library/README.md +9 -0
  49. forgecommandline-0.8.0.data/data/skills/library/_references/accessibility-checklist.md +160 -0
  50. forgecommandline-0.8.0.data/data/skills/library/_references/definition-of-done.md +67 -0
  51. forgecommandline-0.8.0.data/data/skills/library/_references/observability-checklist.md +92 -0
  52. forgecommandline-0.8.0.data/data/skills/library/_references/orchestration-patterns.md +370 -0
  53. forgecommandline-0.8.0.data/data/skills/library/_references/performance-checklist.md +238 -0
  54. forgecommandline-0.8.0.data/data/skills/library/_references/security-checklist.md +248 -0
  55. forgecommandline-0.8.0.data/data/skills/library/_references/testing-patterns.md +235 -0
  56. forgecommandline-0.8.0.data/data/skills/library/api-and-interface-design/SKILL.md +367 -0
  57. forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/SKILL.md +279 -0
  58. forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/references/artifact-templates.md +157 -0
  59. forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/references/methods.md +87 -0
  60. forgecommandline-0.8.0.data/data/skills/library/awwwards-tier-web-application/SKILL.md +877 -0
  61. forgecommandline-0.8.0.data/data/skills/library/browser-testing-with-devtools/SKILL.md +317 -0
  62. forgecommandline-0.8.0.data/data/skills/library/ci-cd-and-automation/SKILL.md +390 -0
  63. forgecommandline-0.8.0.data/data/skills/library/code-review-and-quality/SKILL.md +398 -0
  64. forgecommandline-0.8.0.data/data/skills/library/code-simplification/SKILL.md +331 -0
  65. forgecommandline-0.8.0.data/data/skills/library/constraint-driven-development/SKILL.md +311 -0
  66. forgecommandline-0.8.0.data/data/skills/library/constraint-driven-development/references/floor-guard.md +182 -0
  67. forgecommandline-0.8.0.data/data/skills/library/context-engineering/SKILL.md +353 -0
  68. forgecommandline-0.8.0.data/data/skills/library/debugging-and-error-recovery/SKILL.md +300 -0
  69. forgecommandline-0.8.0.data/data/skills/library/deprecation-and-migration/SKILL.md +247 -0
  70. forgecommandline-0.8.0.data/data/skills/library/documentation-and-adrs/SKILL.md +297 -0
  71. forgecommandline-0.8.0.data/data/skills/library/doubt-driven-development/SKILL.md +243 -0
  72. forgecommandline-0.8.0.data/data/skills/library/frontend-ui-engineering/SKILL.md +340 -0
  73. forgecommandline-0.8.0.data/data/skills/library/git-workflow-and-versioning/SKILL.md +355 -0
  74. forgecommandline-0.8.0.data/data/skills/library/idea-refine/SKILL.md +193 -0
  75. forgecommandline-0.8.0.data/data/skills/library/idea-refine/examples.md +238 -0
  76. forgecommandline-0.8.0.data/data/skills/library/idea-refine/frameworks.md +99 -0
  77. forgecommandline-0.8.0.data/data/skills/library/idea-refine/refinement-criteria.md +113 -0
  78. forgecommandline-0.8.0.data/data/skills/library/idea-refine/scripts/idea-refine.sh +15 -0
  79. forgecommandline-0.8.0.data/data/skills/library/improve-code-quality/SKILL.md +275 -0
  80. forgecommandline-0.8.0.data/data/skills/library/improve-code-quality/references/artifact-templates.md +101 -0
  81. forgecommandline-0.8.0.data/data/skills/library/incremental-implementation/SKILL.md +249 -0
  82. forgecommandline-0.8.0.data/data/skills/library/interview-me/SKILL.md +235 -0
  83. forgecommandline-0.8.0.data/data/skills/library/observability-and-instrumentation/SKILL.md +238 -0
  84. forgecommandline-0.8.0.data/data/skills/library/performance-optimization/SKILL.md +267 -0
  85. forgecommandline-0.8.0.data/data/skills/library/performance-optimization/references/optimization-patterns.md +250 -0
  86. forgecommandline-0.8.0.data/data/skills/library/planning-and-task-breakdown/SKILL.md +257 -0
  87. forgecommandline-0.8.0.data/data/skills/library/remove-technical-debt/SKILL.md +203 -0
  88. forgecommandline-0.8.0.data/data/skills/library/remove-technical-debt/references/artifact-templates.md +79 -0
  89. forgecommandline-0.8.0.data/data/skills/library/security-and-hardening/SKILL.md +216 -0
  90. forgecommandline-0.8.0.data/data/skills/library/security-and-hardening/references/hardening-patterns.md +324 -0
  91. forgecommandline-0.8.0.data/data/skills/library/shipping-and-launch/SKILL.md +336 -0
  92. forgecommandline-0.8.0.data/data/skills/library/source-driven-development/SKILL.md +216 -0
  93. forgecommandline-0.8.0.data/data/skills/library/spec-driven-development/SKILL.md +259 -0
  94. forgecommandline-0.8.0.data/data/skills/library/system-design/SKILL.md +207 -0
  95. forgecommandline-0.8.0.data/data/skills/library/system-design/references/building-blocks.md +272 -0
  96. forgecommandline-0.8.0.data/data/skills/library/system-design/references/common-designs.md +257 -0
  97. forgecommandline-0.8.0.data/data/skills/library/system-design/references/database-scaling.md +295 -0
  98. forgecommandline-0.8.0.data/data/skills/library/system-design/references/estimation-numbers.md +250 -0
  99. forgecommandline-0.8.0.data/data/skills/library/system-design/references/four-step-process.md +286 -0
  100. forgecommandline-0.8.0.data/data/skills/library/system-design/references/reliability-operations.md +373 -0
  101. forgecommandline-0.8.0.data/data/skills/library/test-driven-development/SKILL.md +398 -0
  102. forgecommandline-0.8.0.data/data/skills/library/using-agent-skills/SKILL.md +192 -0
  103. forgecommandline-0.8.0.data/data/skills/review/SKILL.md +16 -0
  104. forgecommandline-0.8.0.data/data/skills/run/SKILL.md +17 -0
  105. forgecommandline-0.8.0.data/data/skills/security-review/SKILL.md +20 -0
  106. forgecommandline-0.8.0.data/data/skills/simplify/SKILL.md +18 -0
  107. forgecommandline-0.8.0.data/data/skills/test/SKILL.md +16 -0
  108. forgecommandline-0.8.0.data/data/skills/web-build/SKILL.md +25 -0
  109. forgecommandline-0.8.0.dist-info/METADATA +357 -0
  110. forgecommandline-0.8.0.dist-info/RECORD +113 -0
  111. forgecommandline-0.8.0.dist-info/WHEEL +5 -0
  112. forgecommandline-0.8.0.dist-info/entry_points.txt +2 -0
  113. forgecommandline-0.8.0.dist-info/top_level.txt +1 -0
forgecli/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