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/memory.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Small, explicit, project-scoped persistent notes."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import os
|
|
6
|
+
import pathlib
|
|
7
|
+
|
|
8
|
+
from .config import HOME_DIR
|
|
9
|
+
|
|
10
|
+
MAX_CHARS = 50_000
|
|
11
|
+
MAX_NOTE_CHARS = 4_000
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _path(scope: str, cwd: str) -> pathlib.Path:
|
|
15
|
+
if scope == "user":
|
|
16
|
+
return HOME_DIR / "memory" / "user.md"
|
|
17
|
+
if scope != "project":
|
|
18
|
+
raise ValueError("scope must be 'user' or 'project'")
|
|
19
|
+
root = os.path.normcase(str(pathlib.Path(cwd).resolve()))
|
|
20
|
+
key = hashlib.sha256(root.encode("utf-8")).hexdigest()[:20]
|
|
21
|
+
return HOME_DIR / "memory" / "projects" / key / "memory.md"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def read(scope: str, cwd: str) -> str:
|
|
25
|
+
path = _path(scope, cwd)
|
|
26
|
+
if not path.is_file():
|
|
27
|
+
return ""
|
|
28
|
+
return path.read_text(encoding="utf-8")[-MAX_CHARS:]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def remember(scope: str, cwd: str, note: str) -> str:
|
|
32
|
+
note = note.strip()
|
|
33
|
+
if not note:
|
|
34
|
+
raise ValueError("note must not be empty")
|
|
35
|
+
if len(note) > MAX_NOTE_CHARS:
|
|
36
|
+
raise ValueError(f"note must be at most {MAX_NOTE_CHARS} characters")
|
|
37
|
+
path = _path(scope, cwd)
|
|
38
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
existing = read(scope, cwd)
|
|
40
|
+
combined = (existing.rstrip() + "\n- " + note + "\n") if existing else ("- " + note + "\n")
|
|
41
|
+
path.write_text(combined[-MAX_CHARS:], encoding="utf-8")
|
|
42
|
+
return str(path)
|
forgecli/openapi.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Import OpenAPI descriptions and expose their declared operations as tools."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import pathlib
|
|
7
|
+
import re
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
|
|
12
|
+
from . import skills
|
|
13
|
+
from .httpapi import validate_arguments
|
|
14
|
+
|
|
15
|
+
MAX_SPEC = 5_000_000
|
|
16
|
+
METHODS = {"get", "post", "put", "patch", "delete", "head", "options"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class OpenAPIs:
|
|
20
|
+
def __init__(self, http, root: pathlib.Path):
|
|
21
|
+
self.http = http
|
|
22
|
+
self.root = root
|
|
23
|
+
self.operations: dict[str, dict] = {}
|
|
24
|
+
self.active: set[str] = set()
|
|
25
|
+
self.api_names: set[str] = set()
|
|
26
|
+
|
|
27
|
+
def import_spec(self, source: str, name: str = "") -> str:
|
|
28
|
+
source = str(source or "").strip()
|
|
29
|
+
if not source:
|
|
30
|
+
return "Error: provide an OpenAPI spec URL or file path."
|
|
31
|
+
try:
|
|
32
|
+
base_url = ""
|
|
33
|
+
if urllib.parse.urlsplit(source).scheme in ("http", "https"):
|
|
34
|
+
req = urllib.request.Request(source, headers={"User-Agent": "Forge/0.1"})
|
|
35
|
+
with urllib.request.urlopen(req, timeout=30) as response:
|
|
36
|
+
raw = response.read(MAX_SPEC + 1)
|
|
37
|
+
if len(raw) > MAX_SPEC:
|
|
38
|
+
return "Error: OpenAPI spec exceeds the 5 MB limit."
|
|
39
|
+
base_url = response.geturl()
|
|
40
|
+
else:
|
|
41
|
+
raw = pathlib.Path(source).expanduser().resolve().read_bytes()
|
|
42
|
+
if len(raw) > MAX_SPEC:
|
|
43
|
+
return "Error: OpenAPI spec exceeds the 5 MB limit."
|
|
44
|
+
text = raw.decode("utf-8-sig")
|
|
45
|
+
try:
|
|
46
|
+
spec = json.loads(text)
|
|
47
|
+
except json.JSONDecodeError:
|
|
48
|
+
import yaml
|
|
49
|
+
spec = yaml.safe_load(text)
|
|
50
|
+
if not isinstance(spec, dict) or not str(spec.get("openapi", "")).startswith("3."):
|
|
51
|
+
return "Error: this is not a supported OpenAPI 3.x document."
|
|
52
|
+
except (OSError, UnicodeError, urllib.error.URLError, ValueError, ImportError) as e:
|
|
53
|
+
return f"Error: could not read OpenAPI spec: {type(e).__name__}: {e}"
|
|
54
|
+
|
|
55
|
+
api_name = self._slug(name or self._default_name(source))
|
|
56
|
+
if not api_name:
|
|
57
|
+
api_name = "api"
|
|
58
|
+
digest = hashlib.sha1(source.encode("utf-8")).hexdigest()[:6]
|
|
59
|
+
prefix = f"{api_name}_{digest}"
|
|
60
|
+
paths = spec.get("paths") or {}
|
|
61
|
+
if not isinstance(paths, dict):
|
|
62
|
+
return "Error: OpenAPI paths must be an object."
|
|
63
|
+
components = spec.get("components") or {}
|
|
64
|
+
if not isinstance(components, dict):
|
|
65
|
+
return "Error: OpenAPI components must be an object."
|
|
66
|
+
refs = components.get("schemas") or {}
|
|
67
|
+
if not isinstance(refs, dict):
|
|
68
|
+
return "Error: OpenAPI component schemas must be an object."
|
|
69
|
+
servers = spec.get("servers") or [{"url": "/"}]
|
|
70
|
+
operations = {}
|
|
71
|
+
for path, path_item in paths.items():
|
|
72
|
+
if not isinstance(path_item, dict):
|
|
73
|
+
return f"Error: OpenAPI path {path} must be an object."
|
|
74
|
+
if "$ref" in path_item:
|
|
75
|
+
return f"Error: path-item references are not supported for {path}."
|
|
76
|
+
for method, operation in path_item.items():
|
|
77
|
+
if method.lower() not in METHODS:
|
|
78
|
+
continue
|
|
79
|
+
if not isinstance(operation, dict):
|
|
80
|
+
return f"Error: OpenAPI operation {method.upper()} {path} must be an object."
|
|
81
|
+
try:
|
|
82
|
+
item = self._operation(spec, components, base_url, prefix, str(path),
|
|
83
|
+
method.lower(), path_item, operation, servers)
|
|
84
|
+
except (KeyError, IndexError, TypeError, ValueError) as e:
|
|
85
|
+
return f"Error: invalid OpenAPI operation {method.upper()} {path}: {e}"
|
|
86
|
+
if item["name"] in operations:
|
|
87
|
+
return f"Error: duplicate OpenAPI operation ID {item['name']}."
|
|
88
|
+
operations[item["name"]] = item
|
|
89
|
+
if not operations:
|
|
90
|
+
return "Error: the OpenAPI spec contains no supported operations."
|
|
91
|
+
self.operations.update(operations)
|
|
92
|
+
self.api_names.add(api_name)
|
|
93
|
+
return (f"Imported OpenAPI API '{api_name}' with {len(operations)} operations. "
|
|
94
|
+
"Use openapi_search with a task or operation keyword to load matching tool schemas.")
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def _default_name(source: str) -> str:
|
|
98
|
+
path = urllib.parse.urlsplit(source).path
|
|
99
|
+
return pathlib.Path(path or source).stem or "api"
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def _slug(value: str) -> str:
|
|
103
|
+
return re.sub(r"_+", "_", re.sub(r"[^a-zA-Z0-9_]+", "_", value)).strip("_").lower()[:32]
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def _schema(schema, refs, depth=0):
|
|
107
|
+
if depth > 12:
|
|
108
|
+
raise ValueError("schema reference nesting exceeds 12 levels")
|
|
109
|
+
if not isinstance(schema, dict):
|
|
110
|
+
return {}
|
|
111
|
+
ref = schema.get("$ref")
|
|
112
|
+
if ref:
|
|
113
|
+
if not ref.startswith("#/components/schemas/"):
|
|
114
|
+
raise ValueError(f"unsupported schema reference {ref}")
|
|
115
|
+
component_name = ref.split("/", 3)[-1].replace("~1", "/").replace("~0", "~")
|
|
116
|
+
target = refs.get(component_name)
|
|
117
|
+
if target is None:
|
|
118
|
+
raise ValueError(f"unresolved schema reference {ref}")
|
|
119
|
+
resolved = OpenAPIs._schema(target, refs, depth + 1)
|
|
120
|
+
if schema.get("description"):
|
|
121
|
+
resolved["description"] = schema["description"]
|
|
122
|
+
return resolved
|
|
123
|
+
out = {k: schema[k] for k in ("type", "description", "format", "enum", "default",
|
|
124
|
+
"minimum", "maximum", "minLength", "maxLength")
|
|
125
|
+
if k in schema}
|
|
126
|
+
for key in ("allOf", "anyOf", "oneOf"):
|
|
127
|
+
if key in schema:
|
|
128
|
+
out[key] = [OpenAPIs._schema(item, refs, depth + 1) for item in schema[key]]
|
|
129
|
+
if "properties" in schema:
|
|
130
|
+
out["properties"] = {k: OpenAPIs._schema(v, refs, depth + 1)
|
|
131
|
+
for k, v in schema["properties"].items()}
|
|
132
|
+
if "items" in schema:
|
|
133
|
+
out["items"] = OpenAPIs._schema(schema["items"], refs, depth + 1)
|
|
134
|
+
if schema.get("required"):
|
|
135
|
+
out["required"] = list(schema["required"])
|
|
136
|
+
if "additionalProperties" in schema:
|
|
137
|
+
additional = schema["additionalProperties"]
|
|
138
|
+
out["additionalProperties"] = OpenAPIs._schema(additional, refs, depth + 1) \
|
|
139
|
+
if isinstance(additional, dict) else additional
|
|
140
|
+
return out
|
|
141
|
+
|
|
142
|
+
@classmethod
|
|
143
|
+
def _component_ref(cls, components, section, item):
|
|
144
|
+
ref = item.get("$ref")
|
|
145
|
+
if not ref:
|
|
146
|
+
return item
|
|
147
|
+
prefix = f"#/components/{section}/"
|
|
148
|
+
if not ref.startswith(prefix):
|
|
149
|
+
raise ValueError(f"unsupported {section} reference {ref}")
|
|
150
|
+
name = ref[len(prefix):].replace("~1", "/").replace("~0", "~")
|
|
151
|
+
target = (components.get(section) or {}).get(name)
|
|
152
|
+
if not isinstance(target, dict):
|
|
153
|
+
raise ValueError(f"unresolved {section} reference {ref}")
|
|
154
|
+
return target
|
|
155
|
+
|
|
156
|
+
@classmethod
|
|
157
|
+
def _operation(cls, spec, components, base_url, prefix, path, method, path_item, operation, servers):
|
|
158
|
+
refs = components.get("schemas") or {}
|
|
159
|
+
operation_id = str(operation.get("operationId") or f"{method}_{path}")
|
|
160
|
+
slug = cls._slug(operation_id)
|
|
161
|
+
head = f"openapi__{prefix}__"
|
|
162
|
+
max_slug_len = 64 - len(head)
|
|
163
|
+
if len(slug) > max_slug_len:
|
|
164
|
+
suffix = "_" + hashlib.sha1(operation_id.encode()).hexdigest()[:6]
|
|
165
|
+
slug = slug[:max_slug_len - len(suffix)] + suffix
|
|
166
|
+
name = head + slug
|
|
167
|
+
properties, required = {"path_params": {"type": "object", "properties": {}, "required": []},
|
|
168
|
+
"query": {"type": "object", "properties": {}},
|
|
169
|
+
"headers": {"type": "object", "properties": {}}}, []
|
|
170
|
+
params = {}
|
|
171
|
+
for p in (path_item.get("parameters") or []) + (operation.get("parameters") or []):
|
|
172
|
+
if not isinstance(p, dict):
|
|
173
|
+
continue
|
|
174
|
+
p = cls._component_ref(components, "parameters", p)
|
|
175
|
+
key = (p.get("name"), p.get("in"))
|
|
176
|
+
params[key] = p
|
|
177
|
+
for (param_name, location), param in params.items():
|
|
178
|
+
if not param_name or location not in ("path", "query", "header"):
|
|
179
|
+
continue
|
|
180
|
+
target = {"path": "path_params", "query": "query", "header": "headers"}[location]
|
|
181
|
+
schema = cls._schema(param.get("schema") or {}, refs)
|
|
182
|
+
if param.get("description"):
|
|
183
|
+
schema["description"] = param["description"]
|
|
184
|
+
properties[target]["properties"][param_name] = schema
|
|
185
|
+
if param.get("required"):
|
|
186
|
+
properties[target].setdefault("required", []).append(param_name)
|
|
187
|
+
for key in ("path_params", "query", "headers"):
|
|
188
|
+
if properties[key]["properties"]:
|
|
189
|
+
properties[key]["additionalProperties"] = False
|
|
190
|
+
required.append(key) if key == "path_params" and properties[key].get("required") else None
|
|
191
|
+
elif key != "path_params":
|
|
192
|
+
del properties[key]
|
|
193
|
+
|
|
194
|
+
request_body = operation.get("requestBody") or {}
|
|
195
|
+
if isinstance(request_body, dict):
|
|
196
|
+
request_body = cls._component_ref(components, "requestBodies", request_body)
|
|
197
|
+
content = request_body.get("content") or {}
|
|
198
|
+
media_type = next((key for key in content if key == "application/json" or key.endswith("+json")),
|
|
199
|
+
next((key for key in ("application/x-www-form-urlencoded", "multipart/form-data")
|
|
200
|
+
if key in content), ""))
|
|
201
|
+
media = content.get(media_type)
|
|
202
|
+
if media:
|
|
203
|
+
body_schema = cls._schema(media.get("schema") or {}, refs)
|
|
204
|
+
if media_type == "multipart/form-data":
|
|
205
|
+
body_properties = dict(body_schema.get("properties") or {})
|
|
206
|
+
body_required = list(body_schema.get("required") or [])
|
|
207
|
+
binary = [key for key, value in body_properties.items()
|
|
208
|
+
if value.get("format") in ("binary", "base64")]
|
|
209
|
+
for key in binary:
|
|
210
|
+
body_properties.pop(key, None)
|
|
211
|
+
body_required = [key for key in body_required if key not in binary]
|
|
212
|
+
if body_properties:
|
|
213
|
+
properties["form_body"] = {**body_schema, "properties": body_properties,
|
|
214
|
+
"required": body_required}
|
|
215
|
+
if request_body.get("required"):
|
|
216
|
+
required.append("form_body")
|
|
217
|
+
if binary:
|
|
218
|
+
properties["files"] = {"type": "array", "items": {"type": "object",
|
|
219
|
+
"properties": {"field": {"type": "string", "enum": binary},
|
|
220
|
+
"path": {"type": "string"}, "filename": {"type": "string"}},
|
|
221
|
+
"required": ["field", "path"], "additionalProperties": False}}
|
|
222
|
+
if request_body.get("required") and (
|
|
223
|
+
any(k in body_schema.get("required", []) for k in binary) or not body_properties):
|
|
224
|
+
required.append("files")
|
|
225
|
+
if request_body.get("required") and not body_properties and not binary:
|
|
226
|
+
raise ValueError("required multipart body has no supported fields")
|
|
227
|
+
elif media_type == "application/x-www-form-urlencoded":
|
|
228
|
+
properties["form_body"] = body_schema or {"type": "object"}
|
|
229
|
+
if request_body.get("required"):
|
|
230
|
+
required.append("form_body")
|
|
231
|
+
elif media_type == "application/json" or media_type.endswith("+json"):
|
|
232
|
+
properties["json_body"] = body_schema or {"type": "object"}
|
|
233
|
+
if request_body.get("required"):
|
|
234
|
+
required.append("json_body")
|
|
235
|
+
else:
|
|
236
|
+
raise ValueError(f"unsupported request body media type {media_type}")
|
|
237
|
+
elif request_body:
|
|
238
|
+
raise ValueError("request body has no supported content")
|
|
239
|
+
|
|
240
|
+
server_list = operation.get("servers") or path_item.get("servers") or servers or [{"url": "/"}]
|
|
241
|
+
if not isinstance(server_list, list) or not server_list:
|
|
242
|
+
raise ValueError("server list is empty or invalid")
|
|
243
|
+
server_config = server_list[0]
|
|
244
|
+
if not isinstance(server_config, dict):
|
|
245
|
+
raise ValueError("server entry must be an object")
|
|
246
|
+
server = server_config.get("url", "/")
|
|
247
|
+
if not isinstance(server, str):
|
|
248
|
+
raise ValueError("server URL must be a string")
|
|
249
|
+
server_vars = server_config.get("variables") or {}
|
|
250
|
+
for var, definition in server_vars.items():
|
|
251
|
+
server = server.replace("{" + var + "}", str(definition.get("default", "")))
|
|
252
|
+
if re.search(r"\{[^{}]+\}", server):
|
|
253
|
+
raise ValueError("server URL has a variable without a default")
|
|
254
|
+
if urllib.parse.urlsplit(server).scheme not in ("http", "https"):
|
|
255
|
+
if base_url:
|
|
256
|
+
origin = urllib.parse.urlsplit(base_url)
|
|
257
|
+
server = urllib.parse.urljoin(f"{origin.scheme}://{origin.netloc}/", server)
|
|
258
|
+
else:
|
|
259
|
+
server = urllib.parse.urljoin("http://localhost/", server)
|
|
260
|
+
if urllib.parse.urlsplit(server).scheme not in ("http", "https"):
|
|
261
|
+
raise ValueError("unsupported server URL")
|
|
262
|
+
|
|
263
|
+
summary = operation.get("summary") or operation.get("description") or operation_id
|
|
264
|
+
description = str(operation.get("description") or summary)[:800]
|
|
265
|
+
schema = {"type": "function", "function": {
|
|
266
|
+
"name": name,
|
|
267
|
+
"description": f"OpenAPI {method.upper()} {path}: {summary}. Call only with parameters from this schema.",
|
|
268
|
+
"parameters": {"type": "object", "properties": properties, "required": required,
|
|
269
|
+
"additionalProperties": False},
|
|
270
|
+
}}
|
|
271
|
+
return {"name": name, "api": prefix, "method": method.upper(), "path": path,
|
|
272
|
+
"server": server.rstrip("/"), "summary": str(summary), "description": description,
|
|
273
|
+
"schema": schema}
|
|
274
|
+
|
|
275
|
+
def search(self, query: str, limit: int = 8) -> list[dict]:
|
|
276
|
+
if not self.operations:
|
|
277
|
+
return []
|
|
278
|
+
skills_index = {}
|
|
279
|
+
for name, operation in self.operations.items():
|
|
280
|
+
path = pathlib.Path(name.replace(":", "_"))
|
|
281
|
+
skills_index[name] = skills.Skill(name, f"{operation['summary']} {operation['description']} "
|
|
282
|
+
f"{operation['method']} {operation['path']}",
|
|
283
|
+
path, "openapi")
|
|
284
|
+
matcher = skills.Matcher(skills_index)
|
|
285
|
+
ranked = matcher.scores(query)
|
|
286
|
+
results = []
|
|
287
|
+
for score, skill in ranked[:limit]:
|
|
288
|
+
if score < matcher.min_score * 0.45:
|
|
289
|
+
continue
|
|
290
|
+
operation = self.operations[skill.name]
|
|
291
|
+
self.active.add(skill.name)
|
|
292
|
+
results.append(operation)
|
|
293
|
+
return results
|
|
294
|
+
|
|
295
|
+
def schemas(self, query: str) -> list[dict]:
|
|
296
|
+
matches = self.search(query)
|
|
297
|
+
names = {item["name"] for item in matches} | self.active
|
|
298
|
+
return [self.operations[name]["schema"] for name in sorted(names) if name in self.operations]
|
|
299
|
+
|
|
300
|
+
def report(self) -> str:
|
|
301
|
+
if not self.operations:
|
|
302
|
+
return ""
|
|
303
|
+
total = sum(len(json.dumps(item["schema"], ensure_ascii=False))
|
|
304
|
+
for item in self.operations.values())
|
|
305
|
+
active = sum(len(json.dumps(self.operations[name]["schema"], ensure_ascii=False))
|
|
306
|
+
for name in self.active if name in self.operations)
|
|
307
|
+
deferred = max(0, total - active) // 4
|
|
308
|
+
return (f"OpenAPI operations registered: {len(self.operations)}; "
|
|
309
|
+
f"deferred schemas save about {deferred:,} tokens per unmatched request")
|
|
310
|
+
|
|
311
|
+
def execute(self, name: str, args: dict) -> str:
|
|
312
|
+
operation = self.operations.get(name)
|
|
313
|
+
if not operation:
|
|
314
|
+
return f"Error: OpenAPI operation {name!r} is not registered."
|
|
315
|
+
if name not in self.active:
|
|
316
|
+
return "Error: this OpenAPI operation was not loaded from a matching spec search."
|
|
317
|
+
invalid = validate_arguments(operation["schema"]["function"]["parameters"], args)
|
|
318
|
+
if invalid:
|
|
319
|
+
return f"Error: {invalid}"
|
|
320
|
+
path = operation["path"]
|
|
321
|
+
path_params = args.get("path_params") or {}
|
|
322
|
+
for match in re.findall(r"\{([^{}]+)\}", path):
|
|
323
|
+
if match not in path_params:
|
|
324
|
+
return f"Error: required path parameter {match!r} is missing."
|
|
325
|
+
path = path.replace("{" + match + "}", urllib.parse.quote(str(path_params[match]), safe=""))
|
|
326
|
+
url = operation["server"] + "/" + path.lstrip("/")
|
|
327
|
+
request_args = {"method": operation["method"], "url": url,
|
|
328
|
+
"query": args.get("query"), "headers": args.get("headers"),
|
|
329
|
+
"json_body": args.get("json_body"), "form_body": args.get("form_body"),
|
|
330
|
+
"files": args.get("files")}
|
|
331
|
+
return self.http.request(**request_args)
|