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/httpapi.py
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
"""First-class HTTP API calls and OpenAPI operation tools."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import ipaddress
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import pathlib
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.parse
|
|
12
|
+
import urllib.request
|
|
13
|
+
|
|
14
|
+
SECRET = re.compile(r"\{\{env:([A-Za-z_][A-Za-z0-9_]*)\}\}")
|
|
15
|
+
MAX_DOWNLOAD = 50_000_000
|
|
16
|
+
MAX_RESPONSE = 16_000
|
|
17
|
+
|
|
18
|
+
HTTP_SCHEMA = {
|
|
19
|
+
"type": "function",
|
|
20
|
+
"function": {
|
|
21
|
+
"name": "http_request",
|
|
22
|
+
"description": (
|
|
23
|
+
"Call an HTTP API. Use only endpoints and parameters you have verified from the user's "
|
|
24
|
+
"request, a tool result, or an imported OpenAPI schema. Supports JSON/form bodies, file "
|
|
25
|
+
"uploads/downloads, pagination hints, and {{env:NAME}} secrets."
|
|
26
|
+
),
|
|
27
|
+
"parameters": {
|
|
28
|
+
"type": "object",
|
|
29
|
+
"properties": {
|
|
30
|
+
"method": {"type": "string", "description": "HTTP method, e.g. GET, POST, PATCH, DELETE"},
|
|
31
|
+
"url": {"type": "string"},
|
|
32
|
+
"headers": {"type": "object", "additionalProperties": {"type": "string"}},
|
|
33
|
+
"query": {"type": "object", "description": "Query parameters"},
|
|
34
|
+
"json_body": {"description": "JSON request body"},
|
|
35
|
+
"form_body": {"type": "object", "description": "URL-encoded form fields"},
|
|
36
|
+
"files": {"type": "array", "items": {"type": "object", "properties": {
|
|
37
|
+
"field": {"type": "string"}, "path": {"type": "string"},
|
|
38
|
+
"filename": {"type": "string"},
|
|
39
|
+
}, "required": ["field", "path"]}},
|
|
40
|
+
"download_path": {"type": "string", "description": "Save the response body to this file"},
|
|
41
|
+
"timeout": {"type": "integer", "description": "Seconds, default 30, maximum 600"},
|
|
42
|
+
"max_chars": {"type": "integer", "description": "Maximum response text to return, default 12000"},
|
|
43
|
+
},
|
|
44
|
+
"required": ["method", "url"],
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
OPENAPI_IMPORT_SCHEMA = {
|
|
50
|
+
"type": "function",
|
|
51
|
+
"function": {
|
|
52
|
+
"name": "openapi_import",
|
|
53
|
+
"description": "Import an OpenAPI JSON/YAML spec from an HTTP(S) URL or local file. "
|
|
54
|
+
"Operations become callable tools based on their verified schemas.",
|
|
55
|
+
"parameters": {"type": "object", "properties": {
|
|
56
|
+
"source": {"type": "string", "description": "HTTP(S) spec URL or local spec file"},
|
|
57
|
+
"name": {"type": "string", "description": "Optional short name for this API"},
|
|
58
|
+
}, "required": ["source"]},
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
OPENAPI_SEARCH_SCHEMA = {
|
|
63
|
+
"type": "function",
|
|
64
|
+
"function": {
|
|
65
|
+
"name": "openapi_search",
|
|
66
|
+
"description": "Find callable operations in OpenAPI specs already imported into Forge.",
|
|
67
|
+
"parameters": {"type": "object", "properties": {
|
|
68
|
+
"query": {"type": "string"},
|
|
69
|
+
}, "required": ["query"]},
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _resolve_secrets(value, used: set[str], secret_env: dict[str, str] | None = None):
|
|
75
|
+
if isinstance(value, str):
|
|
76
|
+
def substitute(match):
|
|
77
|
+
name = match.group(1)
|
|
78
|
+
secret = (secret_env or {}).get(name, os.environ.get(name))
|
|
79
|
+
if secret is None:
|
|
80
|
+
raise ValueError(f"environment variable {name} is not set")
|
|
81
|
+
if secret:
|
|
82
|
+
used.add(secret)
|
|
83
|
+
return secret
|
|
84
|
+
return SECRET.sub(substitute, value)
|
|
85
|
+
if isinstance(value, list):
|
|
86
|
+
return [_resolve_secrets(item, used, secret_env) for item in value]
|
|
87
|
+
if isinstance(value, dict):
|
|
88
|
+
return {key: _resolve_secrets(item, used, secret_env) for key, item in value.items()}
|
|
89
|
+
return value
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _redact(text: str, secrets: set[str]) -> str:
|
|
93
|
+
for secret in secrets:
|
|
94
|
+
if secret:
|
|
95
|
+
text = text.replace(secret, "[secret redacted]")
|
|
96
|
+
return text
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _is_local(host: str) -> bool:
|
|
100
|
+
host = (host or "").strip("[]").lower()
|
|
101
|
+
if host == "localhost" or host.endswith(".localhost"):
|
|
102
|
+
return True
|
|
103
|
+
try:
|
|
104
|
+
return ipaddress.ip_address(host).is_loopback
|
|
105
|
+
except ValueError:
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def validate_arguments(schema, value, path="arguments"):
|
|
110
|
+
"""Validate declared JSON Schema fields before dispatching imported tools."""
|
|
111
|
+
if not isinstance(schema, dict):
|
|
112
|
+
return "has an invalid input schema"
|
|
113
|
+
expected = schema.get("type")
|
|
114
|
+
types = {
|
|
115
|
+
"object": lambda v: isinstance(v, dict),
|
|
116
|
+
"array": lambda v: isinstance(v, list),
|
|
117
|
+
"string": lambda v: isinstance(v, str),
|
|
118
|
+
"integer": lambda v: isinstance(v, int) and not isinstance(v, bool),
|
|
119
|
+
"number": lambda v: isinstance(v, (int, float)) and not isinstance(v, bool),
|
|
120
|
+
"boolean": lambda v: isinstance(v, bool),
|
|
121
|
+
"null": lambda v: v is None,
|
|
122
|
+
}
|
|
123
|
+
if expected in types and not types[expected](value):
|
|
124
|
+
return f"{path} must be {expected}"
|
|
125
|
+
if "enum" in schema and value not in schema["enum"]:
|
|
126
|
+
return f"{path} must be one of {schema['enum']}"
|
|
127
|
+
if "allOf" in schema:
|
|
128
|
+
for choice in schema["allOf"]:
|
|
129
|
+
error = validate_arguments(choice, value, path)
|
|
130
|
+
if error:
|
|
131
|
+
return error
|
|
132
|
+
for key in ("anyOf", "oneOf"):
|
|
133
|
+
if key in schema:
|
|
134
|
+
matches = sum(not validate_arguments(choice, value, path) for choice in schema[key])
|
|
135
|
+
if matches == 0 or (key == "oneOf" and matches != 1):
|
|
136
|
+
return f"{path} does not match the imported {key} schema"
|
|
137
|
+
if isinstance(value, dict):
|
|
138
|
+
props = schema.get("properties") or {}
|
|
139
|
+
for key in schema.get("required") or []:
|
|
140
|
+
if key not in value:
|
|
141
|
+
return f"{path}.{key} is required by the imported schema"
|
|
142
|
+
for key, item in value.items():
|
|
143
|
+
sub = props.get(key)
|
|
144
|
+
if sub is None:
|
|
145
|
+
extra = schema.get("additionalProperties", True)
|
|
146
|
+
if extra is False:
|
|
147
|
+
return f"{path}.{key} is not declared in the imported schema"
|
|
148
|
+
if isinstance(extra, dict):
|
|
149
|
+
sub = extra
|
|
150
|
+
if sub is not None:
|
|
151
|
+
error = validate_arguments(sub, item, f"{path}.{key}")
|
|
152
|
+
if error:
|
|
153
|
+
return error
|
|
154
|
+
elif isinstance(value, list) and isinstance(schema.get("items"), dict):
|
|
155
|
+
for i, item in enumerate(value):
|
|
156
|
+
error = validate_arguments(schema["items"], item, f"{path}[{i}]")
|
|
157
|
+
if error:
|
|
158
|
+
return error
|
|
159
|
+
return ""
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class _SameOriginRedirect(urllib.request.HTTPRedirectHandler):
|
|
163
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
164
|
+
old = urllib.parse.urlsplit(req.full_url)
|
|
165
|
+
new = urllib.parse.urlsplit(newurl)
|
|
166
|
+
if (old.scheme, old.hostname, old.port) != (new.scheme, new.hostname, new.port):
|
|
167
|
+
return None
|
|
168
|
+
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class HttpAPI:
|
|
172
|
+
def __init__(self, tools):
|
|
173
|
+
self.tools = tools
|
|
174
|
+
|
|
175
|
+
def request(self, method, url, headers=None, query=None, json_body=None, form_body=None,
|
|
176
|
+
files=None, download_path=None, timeout=30, max_chars=12000):
|
|
177
|
+
started = time.monotonic()
|
|
178
|
+
headers = dict(headers or {})
|
|
179
|
+
secret_env = {}
|
|
180
|
+
api_base = getattr(self.tools, "github_api_base", "https://api.github.com")
|
|
181
|
+
try:
|
|
182
|
+
is_github = urllib.parse.urlsplit(str(url)).hostname == urllib.parse.urlsplit(api_base).hostname
|
|
183
|
+
except ValueError:
|
|
184
|
+
is_github = False
|
|
185
|
+
if is_github and not any(k.lower() == "authorization" for k in headers):
|
|
186
|
+
token = self.tools.github_token()
|
|
187
|
+
if not token:
|
|
188
|
+
return ("Error: GitHub API requires GITHUB_TOKEN, a key saved with /keys, "
|
|
189
|
+
"or an authenticated `gh` CLI account.")
|
|
190
|
+
secret_env["GITHUB_TOKEN"] = token
|
|
191
|
+
if self.tools.github_account is None:
|
|
192
|
+
self.tools.github_account = self._github_login(token)
|
|
193
|
+
headers["Authorization"] = "Bearer {{env:GITHUB_TOKEN}}"
|
|
194
|
+
result = self._request(method, url, headers, query, json_body, form_body, files,
|
|
195
|
+
download_path, timeout, max_chars, secret_env)
|
|
196
|
+
try:
|
|
197
|
+
host = urllib.parse.urlsplit(str(url)).hostname or "invalid"
|
|
198
|
+
except ValueError:
|
|
199
|
+
host = "invalid"
|
|
200
|
+
self.tools.usage.record("api", f"{str(method).upper()} {host}",
|
|
201
|
+
not result.startswith("Error:"), time.monotonic() - started)
|
|
202
|
+
return result
|
|
203
|
+
|
|
204
|
+
def _github_login(self, token: str) -> str:
|
|
205
|
+
"""Resolve the identity attached to this exact token for action labels."""
|
|
206
|
+
api_base = getattr(self.tools, "github_api_base", "https://api.github.com").rstrip("/")
|
|
207
|
+
request = urllib.request.Request(
|
|
208
|
+
api_base + "/user",
|
|
209
|
+
headers={"Authorization": "Bearer " + token, "Accept": "application/vnd.github+json",
|
|
210
|
+
"User-Agent": "Forge"},
|
|
211
|
+
method="GET")
|
|
212
|
+
try:
|
|
213
|
+
opener = urllib.request.build_opener(_SameOriginRedirect())
|
|
214
|
+
with opener.open(request, timeout=5) as response:
|
|
215
|
+
body = json.loads(response.read(64_000).decode("utf-8", "replace"))
|
|
216
|
+
login = body.get("login", "") if isinstance(body, dict) else ""
|
|
217
|
+
if isinstance(login, str) and re.fullmatch(r"[A-Za-z0-9-]{1,39}", login):
|
|
218
|
+
return login
|
|
219
|
+
except (OSError, urllib.error.URLError, ValueError, TypeError):
|
|
220
|
+
pass
|
|
221
|
+
return "unknown account"
|
|
222
|
+
|
|
223
|
+
def _request(self, method, url, headers=None, query=None, json_body=None, form_body=None,
|
|
224
|
+
files=None, download_path=None, timeout=30, max_chars=12000, secret_env=None):
|
|
225
|
+
method = str(method or "").strip().upper()
|
|
226
|
+
if not re.fullmatch(r"[A-Z][A-Z0-9!#$%&'*+.^_`|~-]{0,31}", method):
|
|
227
|
+
return "Error: invalid HTTP method."
|
|
228
|
+
used: set[str] = set()
|
|
229
|
+
if (secret_env or {}).get("GITHUB_TOKEN"):
|
|
230
|
+
headers = dict(headers or {})
|
|
231
|
+
headers["Authorization"] = "Bearer {{env:GITHUB_TOKEN}}"
|
|
232
|
+
try:
|
|
233
|
+
url, headers, query = (_resolve_secrets(url, used, secret_env),
|
|
234
|
+
_resolve_secrets(headers or {}, used, secret_env),
|
|
235
|
+
_resolve_secrets(query or {}, used, secret_env))
|
|
236
|
+
json_body = _resolve_secrets(json_body, used, secret_env)
|
|
237
|
+
form_body = _resolve_secrets(form_body, used, secret_env)
|
|
238
|
+
except ValueError as e:
|
|
239
|
+
return f"Error: {e}"
|
|
240
|
+
if not isinstance(headers, dict) or not isinstance(query, dict):
|
|
241
|
+
return "Error: headers and query must be objects."
|
|
242
|
+
parsed = urllib.parse.urlsplit(str(url))
|
|
243
|
+
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
|
244
|
+
return "Error: http_request needs an http:// or https:// URL."
|
|
245
|
+
if json_body is not None and form_body is not None:
|
|
246
|
+
return "Error: provide either json_body or form_body, not both."
|
|
247
|
+
if files and json_body is not None:
|
|
248
|
+
return "Error: file uploads cannot be combined with json_body."
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
timeout = max(1, min(int(timeout or 30), 600))
|
|
252
|
+
max_chars = max(500, min(int(max_chars or 12000), MAX_RESPONSE))
|
|
253
|
+
except (TypeError, ValueError):
|
|
254
|
+
return "Error: timeout and max_chars must be integers."
|
|
255
|
+
|
|
256
|
+
host = parsed.hostname
|
|
257
|
+
api_base = getattr(self.tools, "github_api_base", "https://api.github.com")
|
|
258
|
+
try:
|
|
259
|
+
is_github = host == urllib.parse.urlsplit(api_base).hostname
|
|
260
|
+
except ValueError:
|
|
261
|
+
is_github = False
|
|
262
|
+
github_risk = ""
|
|
263
|
+
github_target = ""
|
|
264
|
+
if is_github:
|
|
265
|
+
from . import github
|
|
266
|
+
github_risk = github.classify(method, parsed.path)
|
|
267
|
+
match = re.match(r"^/repos/([^/]+/[^/]+)", parsed.path)
|
|
268
|
+
github_target = match.group(1) if match else parsed.path.strip("/")
|
|
269
|
+
if method in {"POST", "PUT", "PATCH", "DELETE"}:
|
|
270
|
+
missing = self._github_missing_target(parsed, method, request_headers=headers)
|
|
271
|
+
if missing:
|
|
272
|
+
return f"Error: GitHub target {missing} does not exist; no change was made."
|
|
273
|
+
external_change = method in {"POST", "PUT", "PATCH", "DELETE"} and not _is_local(host)
|
|
274
|
+
if is_github and github_risk == "destructive":
|
|
275
|
+
denied = self.tools._confirm_destructive(
|
|
276
|
+
f"GitHub {method} · {github_target}",
|
|
277
|
+
f"Target: {github_target}\n{method} {parsed.path}")
|
|
278
|
+
if denied:
|
|
279
|
+
return denied
|
|
280
|
+
elif external_change and (not is_github or github_risk == "write"):
|
|
281
|
+
why = f"{method} request to {host}"
|
|
282
|
+
if is_github:
|
|
283
|
+
why = f"GitHub write to {github_target}"
|
|
284
|
+
denied = self.tools._approve(
|
|
285
|
+
"http_request", f"Send {method} request to {host}",
|
|
286
|
+
f"{method} {parsed.path or '/'} (query, headers, and body are not shown)",
|
|
287
|
+
why=why)
|
|
288
|
+
if denied:
|
|
289
|
+
return denied
|
|
290
|
+
if files and not _is_local(host):
|
|
291
|
+
names = ", ".join(pathlib.Path(str(item.get("path", ""))).name
|
|
292
|
+
for item in files if isinstance(item, dict))
|
|
293
|
+
denied = self.tools._approve(
|
|
294
|
+
"http_request", f"Upload file(s) to {host}",
|
|
295
|
+
f"Files selected: {names or '(none)'}",
|
|
296
|
+
why="uploads a local file to a remote service")
|
|
297
|
+
if denied:
|
|
298
|
+
return denied
|
|
299
|
+
|
|
300
|
+
params = urllib.parse.urlencode(query, doseq=True)
|
|
301
|
+
target = str(url)
|
|
302
|
+
if params:
|
|
303
|
+
target += ("&" if urllib.parse.urlsplit(target).query else "?") + params
|
|
304
|
+
|
|
305
|
+
body = None
|
|
306
|
+
request_headers = {str(k): str(v) for k, v in headers.items()}
|
|
307
|
+
try:
|
|
308
|
+
if files:
|
|
309
|
+
body, boundary = self._multipart(form_body or {}, files, used)
|
|
310
|
+
request_headers.setdefault("Content-Type", f"multipart/form-data; boundary={boundary}")
|
|
311
|
+
elif form_body is not None:
|
|
312
|
+
body = urllib.parse.urlencode(form_body, doseq=True).encode()
|
|
313
|
+
request_headers.setdefault("Content-Type", "application/x-www-form-urlencoded")
|
|
314
|
+
elif json_body is not None:
|
|
315
|
+
body = json.dumps(json_body, ensure_ascii=False).encode("utf-8")
|
|
316
|
+
request_headers.setdefault("Content-Type", "application/json")
|
|
317
|
+
request_headers.setdefault("Accept", "application/json, text/plain, */*")
|
|
318
|
+
request_headers.setdefault("User-Agent", "Forge/0.1")
|
|
319
|
+
req = urllib.request.Request(target, data=body, headers=request_headers, method=method)
|
|
320
|
+
opener = urllib.request.build_opener(_SameOriginRedirect())
|
|
321
|
+
target_label = (f"{method} {github_target} as {self.tools.github_account or 'unknown account'}"
|
|
322
|
+
if is_github else f"{method} {host}")
|
|
323
|
+
self.tools.ui.action("GitHub" if is_github else "HTTP", target_label)
|
|
324
|
+
try:
|
|
325
|
+
response = opener.open(req, timeout=timeout)
|
|
326
|
+
except urllib.error.HTTPError as e:
|
|
327
|
+
response = e
|
|
328
|
+
with response:
|
|
329
|
+
raw = response.read(MAX_DOWNLOAD + 1)
|
|
330
|
+
status = response.status
|
|
331
|
+
response_headers = response.headers
|
|
332
|
+
if len(raw) > MAX_DOWNLOAD:
|
|
333
|
+
return "Error: response exceeds the 50 MB limit."
|
|
334
|
+
if download_path:
|
|
335
|
+
path = self.tools._path(download_path)
|
|
336
|
+
denied = self.tools._approve("write_file", f"Save HTTP response to {path}",
|
|
337
|
+
f"Write {len(raw)} response bytes to disk.", path=path)
|
|
338
|
+
if denied:
|
|
339
|
+
return denied
|
|
340
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
341
|
+
path.write_bytes(raw)
|
|
342
|
+
return f"HTTP {status} · saved {len(raw)} bytes to {path}"
|
|
343
|
+
charset = re.search(r"charset=([\w-]+)", response_headers.get("Content-Type", ""))
|
|
344
|
+
text = raw.decode(charset.group(1) if charset else "utf-8", "replace")
|
|
345
|
+
text = _redact(text, used)
|
|
346
|
+
pagination = self._pagination(response_headers, text)
|
|
347
|
+
more = f"\n\n… [{len(text) - max_chars} more characters]" if len(text) > max_chars else ""
|
|
348
|
+
content_type = _redact(response_headers.get("Content-Type", "unknown type"), used)
|
|
349
|
+
result = f"HTTP {status} · {content_type}\n\n{text[:max_chars]}{more}"
|
|
350
|
+
if pagination:
|
|
351
|
+
result += "\n\nPagination hint: " + _redact(pagination, used)
|
|
352
|
+
return result
|
|
353
|
+
except (OSError, urllib.error.URLError, ValueError, TypeError) as e:
|
|
354
|
+
return f"Error: HTTP request failed: {_redact(str(e), used)[:500]}"
|
|
355
|
+
|
|
356
|
+
@staticmethod
|
|
357
|
+
def _github_missing_target(parsed, method, request_headers):
|
|
358
|
+
match = re.match(r"^/repos/([^/]+)/([^/]+)(/.*)?$", parsed.path)
|
|
359
|
+
if not match:
|
|
360
|
+
return ""
|
|
361
|
+
owner, repo, suffix = match.group(1), match.group(2), match.group(3) or ""
|
|
362
|
+
targets = [f"/repos/{owner}/{repo}"]
|
|
363
|
+
if method in {"PATCH", "DELETE", "PUT"} and suffix:
|
|
364
|
+
resource = suffix
|
|
365
|
+
merge = re.match(r"^/pulls/(\d+)/merge$", suffix)
|
|
366
|
+
if merge:
|
|
367
|
+
resource = f"/pulls/{merge.group(1)}"
|
|
368
|
+
if re.match(r"^/(?:issues|pulls|branches|git/refs)/", resource):
|
|
369
|
+
targets.append(f"/repos/{owner}/{repo}{resource}")
|
|
370
|
+
for path in targets:
|
|
371
|
+
request = urllib.request.Request(
|
|
372
|
+
urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, path, "", "")),
|
|
373
|
+
headers=request_headers, method="GET")
|
|
374
|
+
try:
|
|
375
|
+
with urllib.request.urlopen(request, timeout=10) as response:
|
|
376
|
+
if not 200 <= response.status < 300:
|
|
377
|
+
return path
|
|
378
|
+
except urllib.error.HTTPError as error:
|
|
379
|
+
if error.code == 404:
|
|
380
|
+
return path
|
|
381
|
+
if error.code in (401, 403):
|
|
382
|
+
return f"{path} (authorization failed: HTTP {error.code})"
|
|
383
|
+
return f"{path} (preflight failed: HTTP {error.code})"
|
|
384
|
+
except (OSError, urllib.error.URLError, ValueError) as error:
|
|
385
|
+
return f"{path} (preflight failed: {type(error).__name__})"
|
|
386
|
+
return ""
|
|
387
|
+
|
|
388
|
+
@staticmethod
|
|
389
|
+
def _multipart(fields, files, used):
|
|
390
|
+
boundary = "forge-" + os.urandom(18).hex()
|
|
391
|
+
parts = []
|
|
392
|
+
for key, value in fields.items():
|
|
393
|
+
resolved = _resolve_secrets(value, used)
|
|
394
|
+
field = re.sub(r"[^A-Za-z0-9_.-]", "_", str(key))
|
|
395
|
+
for item in (resolved if isinstance(resolved, list) else [resolved]):
|
|
396
|
+
parts.extend((f"--{boundary}\r\n".encode(),
|
|
397
|
+
f'Content-Disposition: form-data; name="{field}"\r\n\r\n'.encode(),
|
|
398
|
+
str(item).encode(), b"\r\n"))
|
|
399
|
+
for item in files:
|
|
400
|
+
if not isinstance(item, dict) or not item.get("field") or not item.get("path"):
|
|
401
|
+
raise ValueError("each uploaded file needs field and path")
|
|
402
|
+
path = pathlib.Path(os.path.expandvars(os.path.expanduser(str(item["path"])))).resolve()
|
|
403
|
+
if not path.is_file():
|
|
404
|
+
raise ValueError(f"upload file not found: {path}")
|
|
405
|
+
if path.stat().st_size > MAX_DOWNLOAD:
|
|
406
|
+
raise ValueError(f"upload file exceeds the 50 MB limit: {path.name}")
|
|
407
|
+
data = path.read_bytes()
|
|
408
|
+
field = re.sub(r"[^A-Za-z0-9_.-]", "_", str(item["field"]))
|
|
409
|
+
filename = re.sub(r"[\r\n\"]", "_", str(item.get("filename") or path.name))
|
|
410
|
+
parts.extend((f"--{boundary}\r\n".encode(),
|
|
411
|
+
(f'Content-Disposition: form-data; name="{field}"; '
|
|
412
|
+
f'filename="{filename}"\r\n').encode(),
|
|
413
|
+
b"Content-Type: application/octet-stream\r\n\r\n",
|
|
414
|
+
data, b"\r\n"))
|
|
415
|
+
parts.append(f"--{boundary}--\r\n".encode())
|
|
416
|
+
return b"".join(parts), boundary
|
|
417
|
+
|
|
418
|
+
@staticmethod
|
|
419
|
+
def _pagination(headers, text):
|
|
420
|
+
links = headers.get("Link", "")
|
|
421
|
+
for item in links.split(","):
|
|
422
|
+
if 'rel="next"' in item:
|
|
423
|
+
match = re.search(r"<([^>]+)>", item)
|
|
424
|
+
if match:
|
|
425
|
+
return match.group(1)
|
|
426
|
+
try:
|
|
427
|
+
data = json.loads(text)
|
|
428
|
+
except ValueError:
|
|
429
|
+
return ""
|
|
430
|
+
if isinstance(data, dict):
|
|
431
|
+
for key in ("next", "next_url", "nextPage", "next_page", "next_cursor", "nextCursor"):
|
|
432
|
+
if data.get(key):
|
|
433
|
+
return f"{key}={data[key]}"
|
|
434
|
+
return ""
|
forgecli/images.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Images the user gives Forge: a pasted screenshot (alt+v saves it and types its path) or the path
|
|
2
|
+
of an image file in a message.
|
|
3
|
+
|
|
4
|
+
A user message keeps its text as a plain string, so everything that trims or summarises history keeps
|
|
5
|
+
working, and lists the images under "images". Only when a request is sent does `parts()` turn the
|
|
6
|
+
newest message's images into content parts, and only for a model that can see them; any other model
|
|
7
|
+
is told plainly that an image was attached that it can't see, so it asks instead of guessing."""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import pathlib
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
EXTS = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif",
|
|
15
|
+
".webp": "image/webp", ".bmp": "image/bmp"}
|
|
16
|
+
MAX_BYTES = 8 * 1024 * 1024
|
|
17
|
+
|
|
18
|
+
# A quoted path, or an unquoted one without spaces, ending in an image extension.
|
|
19
|
+
_PATH = re.compile(r"""(?:"([^"\n]+?\.(?:png|jpe?g|gif|webp|bmp))"|'([^'\n]+?\.(?:png|jpe?g|gif|webp|bmp))'"""
|
|
20
|
+
r"""|((?:[A-Za-z]:[\\/]|[\\/]|~[\\/]|\.{1,2}[\\/])?[^\s"'<>|]*?\.(?:png|jpe?g|gif|webp|bmp)))"""
|
|
21
|
+
r"""(?=$|[\s"',;:)\]])""", re.I)
|
|
22
|
+
# Windows paths with spaces, typed or pasted without quotes ("C:\...\Screenshot (2349).png").
|
|
23
|
+
_SPACED = re.compile(r"[A-Za-z]:[\\/][^\n\"'<>|*?]*?\.(?:png|jpe?g|gif|webp|bmp)(?=$|[\s\"',;)\]])", re.I)
|
|
24
|
+
|
|
25
|
+
# Model names that take images (OpenAI-style image_url parts). Anything else gets a text note.
|
|
26
|
+
_SEES = re.compile(r"(^|[-/_.:])(vl|vision|omni)([-/_.:]|$)|qwen3\.5|qwen2\.5-vl|gemini|gemma-3|"
|
|
27
|
+
r"gpt-4o|gpt-4\.1|gpt-5|claude|llama-4|pixtral|mistral-(small|medium)-3|kimi-k2\.5|"
|
|
28
|
+
r"glm-4\.\dv|grok-4", re.I)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def sees_images(model: str) -> bool:
|
|
32
|
+
return bool(_SEES.search(model or ""))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def find(text: str, root: pathlib.Path | None = None) -> list[str]:
|
|
36
|
+
"""Image files named in the text that exist, as absolute paths, in order, without repeats."""
|
|
37
|
+
root = pathlib.Path(root or pathlib.Path.cwd())
|
|
38
|
+
found: list[str] = []
|
|
39
|
+
cands = [m.group(1) or m.group(2) or m.group(3) for m in _PATH.finditer(text or "")]
|
|
40
|
+
cands += [m.group(0) for m in _SPACED.finditer(text or "")]
|
|
41
|
+
for c in cands:
|
|
42
|
+
if not c:
|
|
43
|
+
continue
|
|
44
|
+
p = pathlib.Path(c.strip()).expanduser()
|
|
45
|
+
if not p.is_absolute():
|
|
46
|
+
p = root / p
|
|
47
|
+
try:
|
|
48
|
+
if p.suffix.lower() in EXTS and p.is_file() and 0 < p.stat().st_size <= MAX_BYTES:
|
|
49
|
+
s = str(p.resolve())
|
|
50
|
+
if s not in found:
|
|
51
|
+
found.append(s)
|
|
52
|
+
except OSError:
|
|
53
|
+
continue
|
|
54
|
+
return found[:4]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def user_message(text: str, root: pathlib.Path | None = None) -> dict:
|
|
58
|
+
msg = {"role": "user", "content": text}
|
|
59
|
+
found = find(text, root)
|
|
60
|
+
if found:
|
|
61
|
+
msg["images"] = found
|
|
62
|
+
return msg
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def has_images(messages) -> bool:
|
|
66
|
+
return any(m.get("images") for m in messages if m.get("role") == "user")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _data_url(path: str) -> str | None:
|
|
70
|
+
p = pathlib.Path(path)
|
|
71
|
+
try:
|
|
72
|
+
raw = p.read_bytes()
|
|
73
|
+
except OSError:
|
|
74
|
+
return None
|
|
75
|
+
if not raw or len(raw) > MAX_BYTES:
|
|
76
|
+
return None
|
|
77
|
+
return f"data:{EXTS.get(p.suffix.lower(), 'image/png')};base64,{base64.b64encode(raw).decode()}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def parts(messages: list[dict], model: str) -> list[dict]:
|
|
81
|
+
"""The messages as sent to `model`: the newest user message with images gets them as image
|
|
82
|
+
parts when the model can see them; every other image becomes a one-line note."""
|
|
83
|
+
last = max((i for i, m in enumerate(messages) if m.get("role") == "user" and m.get("images")),
|
|
84
|
+
default=-1)
|
|
85
|
+
if last < 0:
|
|
86
|
+
return messages
|
|
87
|
+
vision = sees_images(model)
|
|
88
|
+
out = []
|
|
89
|
+
for i, m in enumerate(messages):
|
|
90
|
+
imgs = m.get("images") if m.get("role") == "user" else None
|
|
91
|
+
if not imgs:
|
|
92
|
+
out.append(m)
|
|
93
|
+
continue
|
|
94
|
+
text = m.get("content") or ""
|
|
95
|
+
names = ", ".join(pathlib.Path(p).name for p in imgs)
|
|
96
|
+
if i == last and vision:
|
|
97
|
+
content = [{"type": "text", "text": text + f"\n\n[Forge: the image(s) {names} are attached below.]"}]
|
|
98
|
+
for p in imgs:
|
|
99
|
+
url = _data_url(p)
|
|
100
|
+
if url:
|
|
101
|
+
content.append({"type": "image_url", "image_url": {"url": url}})
|
|
102
|
+
out.append(dict(m, content=content if len(content) > 1 else text))
|
|
103
|
+
elif i == last:
|
|
104
|
+
out.append(dict(m, content=text + (
|
|
105
|
+
f"\n\n[Forge: the user attached the image(s) {names}, but this model can't see images. "
|
|
106
|
+
"Don't open or guess at the file: say you can't see it and ask what it shows (or work "
|
|
107
|
+
"from what they wrote).]")))
|
|
108
|
+
else:
|
|
109
|
+
out.append(dict(m, content=text + f"\n[earlier image(s): {names}]"))
|
|
110
|
+
return out
|