agentpatch 0.1.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.
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentpatch
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK and CLI for the AgentPatch tool marketplace
|
|
5
|
+
Project-URL: Homepage, https://agentpatch.ai
|
|
6
|
+
Project-URL: Documentation, https://agentpatch.ai/docs/consumer
|
|
7
|
+
Project-URL: Repository, https://github.com/fullthom/agentpatch-python
|
|
8
|
+
Author-email: AgentPatch <hello@agentpatch.ai>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agent,agentpatch,ai,marketplace,tools
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
23
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# AgentPatch
|
|
27
|
+
|
|
28
|
+
Zero-dependency Python SDK and CLI for the [AgentPatch](https://agentpatch.ai) tool marketplace. Single file, stdlib only.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install agentpatch
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Or with [pipx](https://pipx.pypa.io/) for CLI-only usage:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pipx install agentpatch
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Or just copy `src/agentpatch.py` into your project — it has no dependencies beyond Python 3.10+.
|
|
43
|
+
|
|
44
|
+
## Authentication
|
|
45
|
+
|
|
46
|
+
Get your API key from [agentpatch.ai/dashboard](https://agentpatch.ai/dashboard), then either:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# Option 1: Save to config file
|
|
50
|
+
ap config set-key
|
|
51
|
+
|
|
52
|
+
# Option 2: Environment variable
|
|
53
|
+
export AGENTPATCH_API_KEY=ap_your_key_here
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## CLI Usage
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
# Search for tools
|
|
60
|
+
ap search "image generation"
|
|
61
|
+
ap search --max-price 100 --json
|
|
62
|
+
|
|
63
|
+
# Get tool details
|
|
64
|
+
ap info agentpatch google-search
|
|
65
|
+
|
|
66
|
+
# Invoke a tool (waits for result by default)
|
|
67
|
+
ap run agentpatch google-search --input '{"query": "best pizza NYC"}'
|
|
68
|
+
|
|
69
|
+
# Invoke without waiting (for async tools)
|
|
70
|
+
ap run agentpatch generate-image-recraft --input '{"prompt": "a cat"}' --no-poll
|
|
71
|
+
|
|
72
|
+
# Check async job status
|
|
73
|
+
ap job job_abc123
|
|
74
|
+
ap job job_abc123 --poll # wait for completion
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Every command supports `--json` for scripting:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
ap search "email" --json | jq '.[0].slug'
|
|
81
|
+
ap run agentpatch google-search --input '{"query": "test"}' --json | jq '.output'
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## SDK Usage
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from agentpatch import AgentPatch
|
|
88
|
+
|
|
89
|
+
ap = AgentPatch() # uses AGENTPATCH_API_KEY env var or ~/.agentpatch/config.toml
|
|
90
|
+
|
|
91
|
+
# Search for tools
|
|
92
|
+
tools = ap.search("image generation")
|
|
93
|
+
for t in tools["tools"]:
|
|
94
|
+
print(f"{t['owner_username']}/{t['slug']} — {t['price_credits_per_call']} credits")
|
|
95
|
+
|
|
96
|
+
# Get tool details
|
|
97
|
+
tool = ap.get_tool("agentpatch", "google-search")
|
|
98
|
+
print(tool["input_schema"])
|
|
99
|
+
|
|
100
|
+
# Invoke a tool (auto-polls async jobs)
|
|
101
|
+
result = ap.invoke("agentpatch", "google-search", {"query": "best pizza NYC"})
|
|
102
|
+
print(result["output"])
|
|
103
|
+
|
|
104
|
+
# Manual async control
|
|
105
|
+
result = ap.invoke("agentpatch", "generate-image-recraft", {"prompt": "a cat"}, poll=False)
|
|
106
|
+
job = ap.get_job(result["job_id"])
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Configuration
|
|
110
|
+
|
|
111
|
+
API key resolution order:
|
|
112
|
+
1. `api_key=` parameter (SDK) or `--api-key` flag (CLI)
|
|
113
|
+
2. `AGENTPATCH_API_KEY` environment variable
|
|
114
|
+
3. `~/.agentpatch/config.toml` file
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
ap config set-key # save API key
|
|
118
|
+
ap config show # show current config
|
|
119
|
+
ap config clear # delete config file
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
MIT
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
agentpatch.py,sha256=Xw0tWJBniptyeEwNxhaH-eCb6XdFlUDOUr7jNwRFUQA,18641
|
|
2
|
+
agentpatch-0.1.0.dist-info/METADATA,sha256=dbQIasSZH5j_6Tq8Df_8RJS1-9vt05dcXmls5e1ugUc,3361
|
|
3
|
+
agentpatch-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
4
|
+
agentpatch-0.1.0.dist-info/entry_points.txt,sha256=6ATuSyFuFCyqTBVSgskpUhbOteowC_MEJ1OSTpnS8Y8,39
|
|
5
|
+
agentpatch-0.1.0.dist-info/licenses/LICENSE,sha256=A7--0PwuJ-wShhs_01ROiV4ykuQSWfi04_Jyxt7sWUQ,1067
|
|
6
|
+
agentpatch-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AgentPatch
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
agentpatch.py
ADDED
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
"""AgentPatch — Python SDK and CLI for the AgentPatch tool marketplace.
|
|
2
|
+
|
|
3
|
+
Zero-dependency, single-file package. Uses only the Python standard library.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.parse
|
|
15
|
+
import urllib.request
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
__all__ = ["AgentPatch", "AgentPatchError"]
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Config
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
CONFIG_DIR = Path.home() / ".agentpatch"
|
|
27
|
+
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def resolve_api_key(explicit: str | None = None) -> str | None:
|
|
31
|
+
"""Resolve API key from: explicit param > env var > config file."""
|
|
32
|
+
if explicit:
|
|
33
|
+
return explicit
|
|
34
|
+
from_env = os.environ.get("AGENTPATCH_API_KEY")
|
|
35
|
+
if from_env:
|
|
36
|
+
return from_env
|
|
37
|
+
return _load_from_config()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _load_from_config() -> str | None:
|
|
41
|
+
"""Read API key from ~/.agentpatch/config.toml."""
|
|
42
|
+
if not CONFIG_FILE.exists():
|
|
43
|
+
return None
|
|
44
|
+
try:
|
|
45
|
+
if sys.version_info >= (3, 11):
|
|
46
|
+
import tomllib
|
|
47
|
+
else:
|
|
48
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
49
|
+
data = tomllib.loads(CONFIG_FILE.read_text())
|
|
50
|
+
return data.get("api_key")
|
|
51
|
+
except Exception:
|
|
52
|
+
for line in CONFIG_FILE.read_text().splitlines():
|
|
53
|
+
line = line.strip()
|
|
54
|
+
if line.startswith("api_key"):
|
|
55
|
+
_, _, value = line.partition("=")
|
|
56
|
+
return value.strip().strip('"').strip("'")
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def save_api_key(api_key: str) -> Path:
|
|
61
|
+
"""Save API key to ~/.agentpatch/config.toml. Returns the config file path."""
|
|
62
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
CONFIG_FILE.write_text(f'api_key = "{api_key}"\n')
|
|
64
|
+
try:
|
|
65
|
+
CONFIG_FILE.chmod(0o600)
|
|
66
|
+
except OSError:
|
|
67
|
+
pass # Windows may not support chmod
|
|
68
|
+
return CONFIG_FILE
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def clear_config() -> None:
|
|
72
|
+
"""Delete the config file."""
|
|
73
|
+
if CONFIG_FILE.exists():
|
|
74
|
+
CONFIG_FILE.unlink()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
# HTTP helper
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
def _request(
|
|
82
|
+
method: str,
|
|
83
|
+
url: str,
|
|
84
|
+
headers: dict[str, str],
|
|
85
|
+
body: bytes | None = None,
|
|
86
|
+
timeout: float = 120.0,
|
|
87
|
+
) -> tuple[int, dict[str, Any]]:
|
|
88
|
+
"""Make an HTTP request and return (status_code, parsed_json)."""
|
|
89
|
+
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
|
90
|
+
if body is not None:
|
|
91
|
+
req.add_header("Content-Type", "application/json")
|
|
92
|
+
try:
|
|
93
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
94
|
+
data = json.loads(resp.read().decode())
|
|
95
|
+
return resp.status, data
|
|
96
|
+
except urllib.error.HTTPError as e:
|
|
97
|
+
body = e.read().decode()
|
|
98
|
+
try:
|
|
99
|
+
data = json.loads(body)
|
|
100
|
+
except (json.JSONDecodeError, ValueError):
|
|
101
|
+
data = {"error": body or f"HTTP {e.code}"}
|
|
102
|
+
return e.code, data
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
# Client
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
DEFAULT_BASE_URL = "https://agentpatch.ai"
|
|
110
|
+
DEFAULT_TIMEOUT = 120.0
|
|
111
|
+
DEFAULT_POLL_INTERVAL = 5.0
|
|
112
|
+
DEFAULT_POLL_TIMEOUT = 300.0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class AgentPatchError(Exception):
|
|
116
|
+
"""Base exception for AgentPatch API errors."""
|
|
117
|
+
|
|
118
|
+
def __init__(self, message: str, status_code: int | None = None, body: dict[str, Any] | None = None) -> None:
|
|
119
|
+
super().__init__(message)
|
|
120
|
+
self.status_code = status_code
|
|
121
|
+
self.body = body
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class AgentPatch:
|
|
125
|
+
"""Client for the AgentPatch tool marketplace.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
api_key: API key. Falls back to AGENTPATCH_API_KEY env var, then ~/.agentpatch/config.toml.
|
|
129
|
+
base_url: API base URL (default: https://agentpatch.ai).
|
|
130
|
+
timeout: HTTP request timeout in seconds (default: 120).
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
def __init__(
|
|
134
|
+
self,
|
|
135
|
+
api_key: str | None = None,
|
|
136
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
137
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
138
|
+
) -> None:
|
|
139
|
+
self._api_key = resolve_api_key(api_key)
|
|
140
|
+
self._base_url = base_url.rstrip("/")
|
|
141
|
+
self._timeout = timeout
|
|
142
|
+
self._headers: dict[str, str] = {"User-Agent": f"agentpatch-python/{__version__}"}
|
|
143
|
+
if self._api_key:
|
|
144
|
+
self._headers["Authorization"] = f"Bearer {self._api_key}"
|
|
145
|
+
|
|
146
|
+
def search(
|
|
147
|
+
self,
|
|
148
|
+
query: str | None = None,
|
|
149
|
+
*,
|
|
150
|
+
min_success_rate: float | None = None,
|
|
151
|
+
max_price_credits: int | None = None,
|
|
152
|
+
limit: int = 20,
|
|
153
|
+
) -> dict[str, Any]:
|
|
154
|
+
"""Search the marketplace for tools. Returns {"tools": [...], "count": N}."""
|
|
155
|
+
params: dict[str, Any] = {"limit": limit}
|
|
156
|
+
if query is not None:
|
|
157
|
+
params["q"] = query
|
|
158
|
+
if min_success_rate is not None:
|
|
159
|
+
params["min_success_rate"] = min_success_rate
|
|
160
|
+
if max_price_credits is not None:
|
|
161
|
+
params["max_price_credits"] = max_price_credits
|
|
162
|
+
return self._get("/api/search", params=params)
|
|
163
|
+
|
|
164
|
+
def get_tool(self, username: str, slug: str) -> dict[str, Any]:
|
|
165
|
+
"""Get detailed information about a specific tool."""
|
|
166
|
+
return self._get(f"/api/tools/{username}/{slug}")
|
|
167
|
+
|
|
168
|
+
def invoke(
|
|
169
|
+
self,
|
|
170
|
+
username: str,
|
|
171
|
+
slug: str,
|
|
172
|
+
input: dict[str, Any],
|
|
173
|
+
*,
|
|
174
|
+
timeout_seconds: int | None = None,
|
|
175
|
+
poll: bool = True,
|
|
176
|
+
poll_interval: float = DEFAULT_POLL_INTERVAL,
|
|
177
|
+
poll_timeout: float = DEFAULT_POLL_TIMEOUT,
|
|
178
|
+
) -> dict[str, Any]:
|
|
179
|
+
"""Invoke a tool. Auto-polls async jobs to completion by default.
|
|
180
|
+
|
|
181
|
+
Pass poll=False to return immediately with job_id for manual polling.
|
|
182
|
+
"""
|
|
183
|
+
self._require_auth()
|
|
184
|
+
params: dict[str, Any] = {}
|
|
185
|
+
if timeout_seconds is not None:
|
|
186
|
+
params["timeout_seconds"] = timeout_seconds
|
|
187
|
+
|
|
188
|
+
url = f"{self._base_url}/api/tools/{username}/{slug}"
|
|
189
|
+
if params:
|
|
190
|
+
url += "?" + urllib.parse.urlencode(params)
|
|
191
|
+
|
|
192
|
+
status, data = _request("POST", url, self._headers, json.dumps(input).encode(), self._timeout)
|
|
193
|
+
|
|
194
|
+
if status >= 400:
|
|
195
|
+
raise AgentPatchError(data.get("error", "Request failed"), status, data)
|
|
196
|
+
|
|
197
|
+
if not poll or data.get("status") != "pending":
|
|
198
|
+
return data
|
|
199
|
+
|
|
200
|
+
# Poll until completion
|
|
201
|
+
job_id = data["job_id"]
|
|
202
|
+
start = time.monotonic()
|
|
203
|
+
while time.monotonic() - start < poll_timeout:
|
|
204
|
+
time.sleep(poll_interval)
|
|
205
|
+
job = self.get_job(job_id)
|
|
206
|
+
if job["status"] in ("success", "failed", "timeout"):
|
|
207
|
+
return job
|
|
208
|
+
raise AgentPatchError(f"Job {job_id} did not complete within {poll_timeout}s")
|
|
209
|
+
|
|
210
|
+
def get_job(self, job_id: str) -> dict[str, Any]:
|
|
211
|
+
"""Check the status of an async job."""
|
|
212
|
+
self._require_auth()
|
|
213
|
+
return self._get(f"/api/jobs/{job_id}")
|
|
214
|
+
|
|
215
|
+
def _get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
216
|
+
"""Make a GET request and return parsed JSON."""
|
|
217
|
+
url = f"{self._base_url}{path}"
|
|
218
|
+
if params:
|
|
219
|
+
url += "?" + urllib.parse.urlencode(params)
|
|
220
|
+
status, data = _request("GET", url, self._headers, timeout=self._timeout)
|
|
221
|
+
if status >= 400:
|
|
222
|
+
raise AgentPatchError(data.get("error", "Request failed"), status, data)
|
|
223
|
+
return data
|
|
224
|
+
|
|
225
|
+
def _require_auth(self) -> None:
|
|
226
|
+
"""Raise if no API key is configured."""
|
|
227
|
+
if not self._api_key:
|
|
228
|
+
raise AgentPatchError(
|
|
229
|
+
"No API key configured. Set AGENTPATCH_API_KEY env var, "
|
|
230
|
+
"run 'ap config set-key', or pass api_key= to AgentPatch()."
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def close(self) -> None:
|
|
234
|
+
"""Close the client (no-op — urllib doesn't need connection management)."""
|
|
235
|
+
|
|
236
|
+
def __enter__(self) -> AgentPatch:
|
|
237
|
+
return self
|
|
238
|
+
|
|
239
|
+
def __exit__(self, *args: Any) -> None:
|
|
240
|
+
self.close()
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
# CLI helpers
|
|
245
|
+
# ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
_ANSI = sys.stdout.isatty()
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _green(text: str) -> str:
|
|
251
|
+
return f"\033[32m{text}\033[0m" if _ANSI else text
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _red(text: str) -> str:
|
|
255
|
+
return f"\033[31m{text}\033[0m" if _ANSI else text
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _yellow(text: str) -> str:
|
|
259
|
+
return f"\033[33m{text}\033[0m" if _ANSI else text
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _bold(text: str) -> str:
|
|
263
|
+
return f"\033[1m{text}\033[0m" if _ANSI else text
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _dim(text: str) -> str:
|
|
267
|
+
return f"\033[2m{text}\033[0m" if _ANSI else text
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _print_table(headers: list[str], rows: list[list[str]], title: str | None = None) -> None:
|
|
271
|
+
"""Print a simple column-aligned table."""
|
|
272
|
+
if not rows:
|
|
273
|
+
return
|
|
274
|
+
col_widths = [len(h) for h in headers]
|
|
275
|
+
for row in rows:
|
|
276
|
+
for i, cell in enumerate(row):
|
|
277
|
+
col_widths[i] = max(col_widths[i], len(cell))
|
|
278
|
+
|
|
279
|
+
if title:
|
|
280
|
+
print(f"\n {title}")
|
|
281
|
+
|
|
282
|
+
header_line = " ".join(h.ljust(col_widths[i]) for i, h in enumerate(headers))
|
|
283
|
+
print(f" {header_line}")
|
|
284
|
+
print(f" {' '.join('-' * w for w in col_widths)}")
|
|
285
|
+
for row in rows:
|
|
286
|
+
line = " ".join(cell.ljust(col_widths[i]) for i, cell in enumerate(row))
|
|
287
|
+
print(f" {line}")
|
|
288
|
+
print()
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _output_json(data: Any) -> None:
|
|
292
|
+
"""Print raw JSON to stdout."""
|
|
293
|
+
print(json.dumps(data, indent=2))
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _error(message: str) -> None:
|
|
297
|
+
"""Print error and exit."""
|
|
298
|
+
print(f"{_red('Error:')} {message}", file=sys.stderr)
|
|
299
|
+
sys.exit(1)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# ---------------------------------------------------------------------------
|
|
303
|
+
# CLI subcommands
|
|
304
|
+
# ---------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
def _cmd_search(args: argparse.Namespace) -> None:
|
|
307
|
+
"""Handle 'search' subcommand."""
|
|
308
|
+
client = AgentPatch(api_key=args.api_key, base_url=args.base_url)
|
|
309
|
+
try:
|
|
310
|
+
result = client.search(
|
|
311
|
+
args.query,
|
|
312
|
+
limit=args.limit,
|
|
313
|
+
max_price_credits=args.max_price,
|
|
314
|
+
min_success_rate=args.min_rate,
|
|
315
|
+
)
|
|
316
|
+
except AgentPatchError as e:
|
|
317
|
+
_error(str(e))
|
|
318
|
+
|
|
319
|
+
if args.json:
|
|
320
|
+
_output_json(result)
|
|
321
|
+
return
|
|
322
|
+
|
|
323
|
+
tools = result.get("tools", [])
|
|
324
|
+
if not tools:
|
|
325
|
+
print("No tools found.")
|
|
326
|
+
return
|
|
327
|
+
|
|
328
|
+
rows: list[list[str]] = []
|
|
329
|
+
for t in tools:
|
|
330
|
+
price = t.get("price_credits_per_call", 0)
|
|
331
|
+
rate = t.get("success_rate")
|
|
332
|
+
rate_str = f"{rate:.0%}" if rate is not None else "-"
|
|
333
|
+
owner = t.get("owner_username", "")
|
|
334
|
+
rows.append([
|
|
335
|
+
f"{owner}/{t['slug']}",
|
|
336
|
+
t.get("description", "")[:50],
|
|
337
|
+
f"{price} cr",
|
|
338
|
+
rate_str,
|
|
339
|
+
])
|
|
340
|
+
|
|
341
|
+
_print_table(
|
|
342
|
+
["Tool", "Description", "Price", "Success"],
|
|
343
|
+
rows,
|
|
344
|
+
title=f"Found {result.get('count', len(tools))} tools",
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _cmd_info(args: argparse.Namespace) -> None:
|
|
349
|
+
"""Handle 'info' subcommand."""
|
|
350
|
+
client = AgentPatch(api_key=args.api_key, base_url=args.base_url)
|
|
351
|
+
try:
|
|
352
|
+
tool = client.get_tool(args.username, args.slug)
|
|
353
|
+
except AgentPatchError as e:
|
|
354
|
+
_error(str(e))
|
|
355
|
+
|
|
356
|
+
if args.json:
|
|
357
|
+
_output_json(tool)
|
|
358
|
+
return
|
|
359
|
+
|
|
360
|
+
rate = tool.get("success_rate")
|
|
361
|
+
rate_str = f"{rate * 100:.0f}%" if rate else "-"
|
|
362
|
+
print(f"\n{_bold(tool.get('name', args.slug))}")
|
|
363
|
+
print(
|
|
364
|
+
f"by {tool.get('owner_username', args.username)} | "
|
|
365
|
+
f"{tool.get('price_credits_per_call', '?')} credits/call | "
|
|
366
|
+
f"{rate_str} success rate | "
|
|
367
|
+
f"{tool.get('total_calls', 0) or 0} total calls\n"
|
|
368
|
+
)
|
|
369
|
+
print(f"{tool.get('description', '')}\n")
|
|
370
|
+
|
|
371
|
+
input_schema = tool.get("input_schema", {})
|
|
372
|
+
if input_schema.get("properties"):
|
|
373
|
+
print(f"{_bold('Input Schema:')}")
|
|
374
|
+
print(json.dumps(input_schema, indent=2))
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _cmd_run(args: argparse.Namespace) -> None:
|
|
378
|
+
"""Handle 'run' subcommand."""
|
|
379
|
+
try:
|
|
380
|
+
tool_input = json.loads(args.input)
|
|
381
|
+
except json.JSONDecodeError as e:
|
|
382
|
+
_error(f"Invalid JSON input: {e}")
|
|
383
|
+
|
|
384
|
+
client = AgentPatch(api_key=args.api_key, base_url=args.base_url)
|
|
385
|
+
|
|
386
|
+
try:
|
|
387
|
+
result = client.invoke(
|
|
388
|
+
args.username,
|
|
389
|
+
args.slug,
|
|
390
|
+
tool_input,
|
|
391
|
+
timeout_seconds=args.timeout,
|
|
392
|
+
poll=not args.no_poll,
|
|
393
|
+
)
|
|
394
|
+
except AgentPatchError as e:
|
|
395
|
+
_error(str(e))
|
|
396
|
+
|
|
397
|
+
if args.json:
|
|
398
|
+
_output_json(result)
|
|
399
|
+
return
|
|
400
|
+
|
|
401
|
+
status = result.get("status", "unknown")
|
|
402
|
+
if status == "success":
|
|
403
|
+
credits = result.get("credits_used", 0)
|
|
404
|
+
latency = result.get("latency_ms")
|
|
405
|
+
meta = f"{credits} credits"
|
|
406
|
+
if latency:
|
|
407
|
+
meta += f", {latency}ms"
|
|
408
|
+
print(f"{_green('Success')} ({meta})\n")
|
|
409
|
+
output = result.get("output")
|
|
410
|
+
if output is not None:
|
|
411
|
+
print(json.dumps(output, indent=2, default=str))
|
|
412
|
+
elif status == "pending":
|
|
413
|
+
print(f"{_yellow('Job started:')} {result.get('job_id')}")
|
|
414
|
+
print(f"Poll with: ap job {result.get('job_id')}")
|
|
415
|
+
elif status == "failed":
|
|
416
|
+
print(f"{_red('Failed:')} {result.get('error', 'Unknown error')}")
|
|
417
|
+
else:
|
|
418
|
+
_output_json(result)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _cmd_job(args: argparse.Namespace) -> None:
|
|
422
|
+
"""Handle 'job' subcommand."""
|
|
423
|
+
client = AgentPatch(api_key=args.api_key, base_url=args.base_url)
|
|
424
|
+
|
|
425
|
+
try:
|
|
426
|
+
if args.poll:
|
|
427
|
+
start = time.monotonic()
|
|
428
|
+
while True:
|
|
429
|
+
result = client.get_job(args.job_id)
|
|
430
|
+
if result.get("status") in ("success", "failed", "timeout"):
|
|
431
|
+
break
|
|
432
|
+
if time.monotonic() - start > 300:
|
|
433
|
+
_error("Timed out waiting for job")
|
|
434
|
+
time.sleep(5)
|
|
435
|
+
else:
|
|
436
|
+
result = client.get_job(args.job_id)
|
|
437
|
+
except AgentPatchError as e:
|
|
438
|
+
_error(str(e))
|
|
439
|
+
|
|
440
|
+
if args.json:
|
|
441
|
+
_output_json(result)
|
|
442
|
+
return
|
|
443
|
+
|
|
444
|
+
status = result.get("status", "unknown")
|
|
445
|
+
print(f"Job: {result.get('job_id', args.job_id)}")
|
|
446
|
+
print(f"Status: {status}")
|
|
447
|
+
if result.get("credits_used") is not None:
|
|
448
|
+
print(f"Credits: {result['credits_used']}")
|
|
449
|
+
if result.get("latency_ms") is not None:
|
|
450
|
+
print(f"Latency: {result['latency_ms']}ms")
|
|
451
|
+
|
|
452
|
+
output = result.get("output")
|
|
453
|
+
if output is not None:
|
|
454
|
+
print()
|
|
455
|
+
print(json.dumps(output, indent=2, default=str))
|
|
456
|
+
|
|
457
|
+
if result.get("error"):
|
|
458
|
+
print(f"\n{_red('Error:')} {result['error']}")
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _cmd_config_set_key(args: argparse.Namespace) -> None:
|
|
462
|
+
"""Handle 'config set-key' subcommand."""
|
|
463
|
+
api_key = input("Enter your AgentPatch API key: ")
|
|
464
|
+
path = save_api_key(api_key)
|
|
465
|
+
print(f"API key saved to {path}")
|
|
466
|
+
print("Get your key at: https://agentpatch.ai/dashboard")
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _cmd_config_show(args: argparse.Namespace) -> None:
|
|
470
|
+
"""Handle 'config show' subcommand."""
|
|
471
|
+
key = resolve_api_key()
|
|
472
|
+
if key:
|
|
473
|
+
masked = key[:6] + "..." + key[-4:] if len(key) > 10 else "****"
|
|
474
|
+
print(f"API key: {masked}")
|
|
475
|
+
else:
|
|
476
|
+
print(f"API key: {_dim('not set')}")
|
|
477
|
+
print(f"Config: {CONFIG_FILE}")
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _cmd_config_clear(args: argparse.Namespace) -> None:
|
|
481
|
+
"""Handle 'config clear' subcommand."""
|
|
482
|
+
clear_config()
|
|
483
|
+
print("Config cleared.")
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
# ---------------------------------------------------------------------------
|
|
487
|
+
# CLI entry point
|
|
488
|
+
# ---------------------------------------------------------------------------
|
|
489
|
+
|
|
490
|
+
def main(argv: list[str] | None = None) -> None:
|
|
491
|
+
"""CLI entry point. Pass argv for testing, or None to use sys.argv."""
|
|
492
|
+
parser = argparse.ArgumentParser(prog="ap", description="AgentPatch — discover and use AI tools from the CLI.")
|
|
493
|
+
parser.add_argument("--api-key", default=os.environ.get("AGENTPATCH_API_KEY"), help="API key (overrides config).")
|
|
494
|
+
parser.add_argument("--base-url", default="https://agentpatch.ai", help="API base URL.")
|
|
495
|
+
|
|
496
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
497
|
+
|
|
498
|
+
# search
|
|
499
|
+
p_search = subparsers.add_parser("search", help="Search for tools in the marketplace.")
|
|
500
|
+
p_search.add_argument("query", nargs="?", default=None)
|
|
501
|
+
p_search.add_argument("--limit", type=int, default=20, help="Max results (1-100).")
|
|
502
|
+
p_search.add_argument("--max-price", type=int, default=None, help="Max price in credits.")
|
|
503
|
+
p_search.add_argument("--min-rate", type=float, default=None, help="Min success rate (0-1).")
|
|
504
|
+
p_search.add_argument("--json", action="store_true", help="Output raw JSON.")
|
|
505
|
+
p_search.set_defaults(func=_cmd_search)
|
|
506
|
+
|
|
507
|
+
# info
|
|
508
|
+
p_info = subparsers.add_parser("info", help="Get details about a specific tool.")
|
|
509
|
+
p_info.add_argument("username")
|
|
510
|
+
p_info.add_argument("slug")
|
|
511
|
+
p_info.add_argument("--json", action="store_true", help="Output raw JSON.")
|
|
512
|
+
p_info.set_defaults(func=_cmd_info)
|
|
513
|
+
|
|
514
|
+
# run
|
|
515
|
+
p_run = subparsers.add_parser("run", help="Invoke a tool with input data.")
|
|
516
|
+
p_run.add_argument("username")
|
|
517
|
+
p_run.add_argument("slug")
|
|
518
|
+
p_run.add_argument("--input", required=True, help="Tool input as JSON string.")
|
|
519
|
+
p_run.add_argument("--no-poll", action="store_true", help="Don't wait for async results.")
|
|
520
|
+
p_run.add_argument("--timeout", type=int, default=None, help="Server-side timeout (1-3600s).")
|
|
521
|
+
p_run.add_argument("--json", action="store_true", help="Output raw JSON.")
|
|
522
|
+
p_run.set_defaults(func=_cmd_run)
|
|
523
|
+
|
|
524
|
+
# job
|
|
525
|
+
p_job = subparsers.add_parser("job", help="Check the status of an async job.")
|
|
526
|
+
p_job.add_argument("job_id")
|
|
527
|
+
p_job.add_argument("--poll", action="store_true", help="Poll until job completes.")
|
|
528
|
+
p_job.add_argument("--json", action="store_true", help="Output raw JSON.")
|
|
529
|
+
p_job.set_defaults(func=_cmd_job)
|
|
530
|
+
|
|
531
|
+
# config (with sub-subcommands)
|
|
532
|
+
p_config = subparsers.add_parser("config", help="Manage API key and configuration.")
|
|
533
|
+
config_sub = p_config.add_subparsers(dest="config_command")
|
|
534
|
+
|
|
535
|
+
p_set_key = config_sub.add_parser("set-key", help="Save your API key.")
|
|
536
|
+
p_set_key.set_defaults(func=_cmd_config_set_key)
|
|
537
|
+
|
|
538
|
+
p_show = config_sub.add_parser("show", help="Show current configuration.")
|
|
539
|
+
p_show.set_defaults(func=_cmd_config_show)
|
|
540
|
+
|
|
541
|
+
p_clear = config_sub.add_parser("clear", help="Delete the config file.")
|
|
542
|
+
p_clear.set_defaults(func=_cmd_config_clear)
|
|
543
|
+
|
|
544
|
+
args = parser.parse_args(argv)
|
|
545
|
+
|
|
546
|
+
if not args.command:
|
|
547
|
+
parser.print_help()
|
|
548
|
+
sys.exit(0)
|
|
549
|
+
|
|
550
|
+
if args.command == "config" and not args.config_command:
|
|
551
|
+
p_config.print_help()
|
|
552
|
+
sys.exit(0)
|
|
553
|
+
|
|
554
|
+
args.func(args)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
if __name__ == "__main__":
|
|
558
|
+
main()
|