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/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