agentpatch 0.1.0__tar.gz

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,23 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ${{ matrix.os }}
11
+ strategy:
12
+ matrix:
13
+ os: [ubuntu-latest, macos-latest, windows-latest]
14
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: actions/setup-python@v5
18
+ with:
19
+ python-version: ${{ matrix.python-version }}
20
+ - run: pip install -e ".[dev]"
21
+ - run: ruff check src/ tests/
22
+ - run: ruff format --check src/ tests/
23
+ - run: pytest tests/ -v
@@ -0,0 +1,19 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ id-token: write
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: actions/setup-python@v5
15
+ with:
16
+ python-version: "3.12"
17
+ - run: pip install build
18
+ - run: python -m build
19
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ .pytest_cache/
8
+ .ruff_cache/
@@ -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.
@@ -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,99 @@
1
+ # AgentPatch
2
+
3
+ Zero-dependency Python SDK and CLI for the [AgentPatch](https://agentpatch.ai) tool marketplace. Single file, stdlib only.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install agentpatch
9
+ ```
10
+
11
+ Or with [pipx](https://pipx.pypa.io/) for CLI-only usage:
12
+
13
+ ```bash
14
+ pipx install agentpatch
15
+ ```
16
+
17
+ Or just copy `src/agentpatch.py` into your project — it has no dependencies beyond Python 3.10+.
18
+
19
+ ## Authentication
20
+
21
+ Get your API key from [agentpatch.ai/dashboard](https://agentpatch.ai/dashboard), then either:
22
+
23
+ ```bash
24
+ # Option 1: Save to config file
25
+ ap config set-key
26
+
27
+ # Option 2: Environment variable
28
+ export AGENTPATCH_API_KEY=ap_your_key_here
29
+ ```
30
+
31
+ ## CLI Usage
32
+
33
+ ```bash
34
+ # Search for tools
35
+ ap search "image generation"
36
+ ap search --max-price 100 --json
37
+
38
+ # Get tool details
39
+ ap info agentpatch google-search
40
+
41
+ # Invoke a tool (waits for result by default)
42
+ ap run agentpatch google-search --input '{"query": "best pizza NYC"}'
43
+
44
+ # Invoke without waiting (for async tools)
45
+ ap run agentpatch generate-image-recraft --input '{"prompt": "a cat"}' --no-poll
46
+
47
+ # Check async job status
48
+ ap job job_abc123
49
+ ap job job_abc123 --poll # wait for completion
50
+ ```
51
+
52
+ Every command supports `--json` for scripting:
53
+
54
+ ```bash
55
+ ap search "email" --json | jq '.[0].slug'
56
+ ap run agentpatch google-search --input '{"query": "test"}' --json | jq '.output'
57
+ ```
58
+
59
+ ## SDK Usage
60
+
61
+ ```python
62
+ from agentpatch import AgentPatch
63
+
64
+ ap = AgentPatch() # uses AGENTPATCH_API_KEY env var or ~/.agentpatch/config.toml
65
+
66
+ # Search for tools
67
+ tools = ap.search("image generation")
68
+ for t in tools["tools"]:
69
+ print(f"{t['owner_username']}/{t['slug']} — {t['price_credits_per_call']} credits")
70
+
71
+ # Get tool details
72
+ tool = ap.get_tool("agentpatch", "google-search")
73
+ print(tool["input_schema"])
74
+
75
+ # Invoke a tool (auto-polls async jobs)
76
+ result = ap.invoke("agentpatch", "google-search", {"query": "best pizza NYC"})
77
+ print(result["output"])
78
+
79
+ # Manual async control
80
+ result = ap.invoke("agentpatch", "generate-image-recraft", {"prompt": "a cat"}, poll=False)
81
+ job = ap.get_job(result["job_id"])
82
+ ```
83
+
84
+ ## Configuration
85
+
86
+ API key resolution order:
87
+ 1. `api_key=` parameter (SDK) or `--api-key` flag (CLI)
88
+ 2. `AGENTPATCH_API_KEY` environment variable
89
+ 3. `~/.agentpatch/config.toml` file
90
+
91
+ ```bash
92
+ ap config set-key # save API key
93
+ ap config show # show current config
94
+ ap config clear # delete config file
95
+ ```
96
+
97
+ ## License
98
+
99
+ MIT
@@ -0,0 +1,50 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "agentpatch"
7
+ version = "0.1.0"
8
+ description = "Python SDK and CLI for the AgentPatch tool marketplace"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "AgentPatch", email = "hello@agentpatch.ai" },
14
+ ]
15
+ keywords = ["agentpatch", "ai", "tools", "marketplace", "agent"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ ]
26
+ dependencies = []
27
+
28
+ [project.optional-dependencies]
29
+ dev = [
30
+ "pytest>=8.0",
31
+ "ruff>=0.5",
32
+ ]
33
+
34
+ [project.scripts]
35
+ ap = "agentpatch:main"
36
+
37
+ [project.urls]
38
+ Homepage = "https://agentpatch.ai"
39
+ Documentation = "https://agentpatch.ai/docs/consumer"
40
+ Repository = "https://github.com/fullthom/agentpatch-python"
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["src/agentpatch.py"]
44
+
45
+ [tool.ruff]
46
+ target-version = "py310"
47
+ line-length = 120
48
+
49
+ [tool.ruff.lint]
50
+ select = ["E", "F", "I"]
@@ -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()
File without changes
@@ -0,0 +1,298 @@
1
+ """Tests for the AgentPatch SDK and CLI (single-file, zero-dependency version)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import io
7
+ import json
8
+ from pathlib import Path
9
+ from typing import Any
10
+ from unittest.mock import MagicMock, patch
11
+
12
+ import pytest
13
+
14
+ from agentpatch import AgentPatch, AgentPatchError, main
15
+
16
+ BASE = "https://agentpatch.ai"
17
+
18
+ SEARCH_RESPONSE = {
19
+ "tools": [
20
+ {
21
+ "slug": "google-search",
22
+ "name": "Google Search",
23
+ "description": "Search the web",
24
+ "price_credits_per_call": 50,
25
+ "input_schema": {"type": "object", "properties": {"query": {"type": "string"}}},
26
+ "output_schema": {"type": "object"},
27
+ "owner_username": "agentpatch",
28
+ "success_rate": 0.99,
29
+ "total_calls": 1000,
30
+ "avg_latency_ms": 500,
31
+ }
32
+ ],
33
+ "count": 1,
34
+ }
35
+
36
+ TOOL_DETAIL = {
37
+ "slug": "google-search",
38
+ "name": "Google Search",
39
+ "description": "Search the web via Google",
40
+ "price_credits_per_call": 50,
41
+ "input_schema": {"type": "object", "properties": {"query": {"type": "string"}}},
42
+ "output_schema": {"type": "object"},
43
+ "owner_username": "agentpatch",
44
+ "success_rate": 0.99,
45
+ "total_calls": 1000,
46
+ "avg_latency_ms": 500,
47
+ "default_timeout_seconds": 60,
48
+ "max_timeout_seconds": 600,
49
+ }
50
+
51
+ INVOKE_SUCCESS = {
52
+ "job_id": "job_123",
53
+ "status": "success",
54
+ "output": {"results": [{"title": "Test"}]},
55
+ "latency_ms": 450,
56
+ "credits_used": 50,
57
+ "credits_remaining": 9950,
58
+ }
59
+
60
+ INVOKE_PENDING = {
61
+ "job_id": "job_456",
62
+ "status": "pending",
63
+ "poll_url": "/api/jobs/job_456",
64
+ "credits_reserved": 800,
65
+ "credits_remaining": 9200,
66
+ }
67
+
68
+ JOB_SUCCESS = {
69
+ "job_id": "job_456",
70
+ "tool_id": "tool_abc",
71
+ "status": "success",
72
+ "output": {"image_url": "https://example.com/img.png"},
73
+ "latency_ms": 12000,
74
+ "credits_used": 800,
75
+ "credits_remaining": 9200,
76
+ "created_at": "2026-03-01T00:00:00Z",
77
+ "completed_at": "2026-03-01T00:00:12Z",
78
+ }
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Helpers
83
+ # ---------------------------------------------------------------------------
84
+
85
+ def _mock_response(status: int, body: dict[str, Any]) -> MagicMock:
86
+ """Create a mock that simulates urllib.request.urlopen return value."""
87
+ resp = MagicMock()
88
+ resp.status = status
89
+ resp.read.return_value = json.dumps(body).encode()
90
+ resp.__enter__ = lambda s: s
91
+ resp.__exit__ = MagicMock(return_value=False)
92
+ return resp
93
+
94
+
95
+ def _mock_http_error(status: int, body: dict[str, Any]) -> Exception:
96
+ """Create a urllib.error.HTTPError with JSON body."""
97
+ import urllib.error
98
+
99
+ err = urllib.error.HTTPError(
100
+ url="https://agentpatch.ai/api/test",
101
+ code=status,
102
+ msg="Error",
103
+ hdrs=None, # type: ignore[arg-type]
104
+ fp=io.BytesIO(json.dumps(body).encode()),
105
+ )
106
+ return err
107
+
108
+
109
+ def _run_cli(argv: list[str]) -> tuple[str, int]:
110
+ """Run the CLI main() capturing stdout. Returns (output, exit_code)."""
111
+ buf = io.StringIO()
112
+ exit_code = 0
113
+ try:
114
+ with contextlib.redirect_stdout(buf):
115
+ main(argv)
116
+ except SystemExit as e:
117
+ exit_code = e.code if isinstance(e.code, int) else 1
118
+ return buf.getvalue(), exit_code
119
+
120
+
121
+ # ---------------------------------------------------------------------------
122
+ # SDK tests
123
+ # ---------------------------------------------------------------------------
124
+
125
+ class TestSearch:
126
+ def test_search(self) -> None:
127
+ with patch("agentpatch._request", return_value=(200, SEARCH_RESPONSE)):
128
+ with AgentPatch(api_key="test_key") as ap:
129
+ result = ap.search()
130
+ assert result["count"] == 1
131
+ assert result["tools"][0]["slug"] == "google-search"
132
+
133
+ def test_search_with_query(self) -> None:
134
+ with patch("agentpatch._request", return_value=(200, SEARCH_RESPONSE)) as mock:
135
+ with AgentPatch(api_key="test_key") as ap:
136
+ result = ap.search("image", limit=10)
137
+ assert result["count"] == 1
138
+ call_url = mock.call_args[0][1]
139
+ assert "q=image" in call_url
140
+ assert "limit=10" in call_url
141
+
142
+
143
+ class TestGetTool:
144
+ def test_get_tool(self) -> None:
145
+ with patch("agentpatch._request", return_value=(200, TOOL_DETAIL)):
146
+ with AgentPatch(api_key="test_key") as ap:
147
+ tool = ap.get_tool("agentpatch", "google-search")
148
+ assert tool["name"] == "Google Search"
149
+ assert tool["default_timeout_seconds"] == 60
150
+
151
+ def test_get_tool_not_found(self) -> None:
152
+ with patch("agentpatch._request", return_value=(404, {"error": "Tool not found"})):
153
+ with AgentPatch(api_key="test_key") as ap:
154
+ with pytest.raises(AgentPatchError) as exc_info:
155
+ ap.get_tool("nobody", "fake")
156
+ assert exc_info.value.status_code == 404
157
+
158
+
159
+ class TestInvoke:
160
+ def test_invoke_sync(self) -> None:
161
+ with patch("agentpatch._request", return_value=(200, INVOKE_SUCCESS)):
162
+ with AgentPatch(api_key="test_key") as ap:
163
+ result = ap.invoke("agentpatch", "google-search", {"query": "test"})
164
+ assert result["status"] == "success"
165
+ assert result["output"]["results"][0]["title"] == "Test"
166
+
167
+ def test_invoke_async_with_poll(self) -> None:
168
+ responses = [(202, INVOKE_PENDING), (200, JOB_SUCCESS)]
169
+ call_count = 0
170
+
171
+ def fake_request(*args: Any, **kwargs: Any) -> tuple[int, dict[str, Any]]:
172
+ nonlocal call_count
173
+ resp = responses[call_count]
174
+ call_count += 1
175
+ return resp
176
+
177
+ with patch("agentpatch._request", side_effect=fake_request), patch("time.sleep"):
178
+ with AgentPatch(api_key="test_key") as ap:
179
+ result = ap.invoke("agentpatch", "recraft", {"prompt": "a cat"}, poll_interval=0.01)
180
+ assert result["status"] == "success"
181
+ assert result["output"]["image_url"] == "https://example.com/img.png"
182
+
183
+ def test_invoke_no_poll(self) -> None:
184
+ with patch("agentpatch._request", return_value=(202, INVOKE_PENDING)):
185
+ with AgentPatch(api_key="test_key") as ap:
186
+ result = ap.invoke("agentpatch", "recraft", {"prompt": "a cat"}, poll=False)
187
+ assert result["status"] == "pending"
188
+ assert result["job_id"] == "job_456"
189
+
190
+ def test_invoke_requires_auth(self) -> None:
191
+ with AgentPatch() as ap:
192
+ with pytest.raises(AgentPatchError, match="No API key"):
193
+ ap.invoke("agentpatch", "google-search", {"query": "test"})
194
+
195
+ def test_invoke_error(self) -> None:
196
+ error_body = {"error": "Insufficient credits", "required": 50, "balance": 10}
197
+ with patch("agentpatch._request", return_value=(402, error_body)):
198
+ with AgentPatch(api_key="test_key") as ap:
199
+ with pytest.raises(AgentPatchError) as exc_info:
200
+ ap.invoke("agentpatch", "google-search", {"query": "test"})
201
+ assert exc_info.value.status_code == 402
202
+ assert exc_info.value.body["balance"] == 10
203
+
204
+
205
+ class TestGetJob:
206
+ def test_get_job(self) -> None:
207
+ with patch("agentpatch._request", return_value=(200, JOB_SUCCESS)):
208
+ with AgentPatch(api_key="test_key") as ap:
209
+ job = ap.get_job("job_456")
210
+ assert job["status"] == "success"
211
+ assert job["credits_used"] == 800
212
+
213
+ def test_get_job_requires_auth(self) -> None:
214
+ with AgentPatch() as ap:
215
+ with pytest.raises(AgentPatchError, match="No API key"):
216
+ ap.get_job("job_123")
217
+
218
+
219
+ # ---------------------------------------------------------------------------
220
+ # CLI tests
221
+ # ---------------------------------------------------------------------------
222
+
223
+ class TestCLISearch:
224
+ def test_search_table(self) -> None:
225
+ with patch("agentpatch._request", return_value=(200, SEARCH_RESPONSE)):
226
+ output, code = _run_cli(["--api-key", "test", "search"])
227
+ assert code == 0
228
+ assert "google-search" in output
229
+
230
+ def test_search_json(self) -> None:
231
+ with patch("agentpatch._request", return_value=(200, SEARCH_RESPONSE)):
232
+ output, code = _run_cli(["--api-key", "test", "search", "web", "--json"])
233
+ assert code == 0
234
+ data = json.loads(output)
235
+ assert data["count"] == 1
236
+
237
+
238
+ class TestCLIInfo:
239
+ def test_info(self) -> None:
240
+ with patch("agentpatch._request", return_value=(200, TOOL_DETAIL)):
241
+ output, code = _run_cli(["--api-key", "test", "info", "agentpatch", "google-search"])
242
+ assert code == 0
243
+ assert "Google Search" in output
244
+
245
+
246
+ class TestCLIRun:
247
+ def test_run_success(self) -> None:
248
+ with patch("agentpatch._request", return_value=(200, INVOKE_SUCCESS)):
249
+ output, code = _run_cli([
250
+ "--api-key", "test",
251
+ "run", "agentpatch", "google-search",
252
+ "--input", '{"query": "test"}',
253
+ ])
254
+ assert code == 0
255
+ assert "Success" in output
256
+
257
+ def test_run_json(self) -> None:
258
+ with patch("agentpatch._request", return_value=(200, INVOKE_SUCCESS)):
259
+ output, code = _run_cli([
260
+ "--api-key", "test",
261
+ "run", "agentpatch", "google-search",
262
+ "--input", '{"query": "test"}',
263
+ "--json",
264
+ ])
265
+ assert code == 0
266
+ data = json.loads(output)
267
+ assert data["status"] == "success"
268
+
269
+
270
+ class TestCLIConfig:
271
+ def test_config_set_key(self, tmp_path: Any, monkeypatch: pytest.MonkeyPatch) -> None:
272
+ config_file = tmp_path / "config.toml"
273
+ monkeypatch.setattr("agentpatch.CONFIG_FILE", config_file)
274
+ monkeypatch.setattr("agentpatch.CONFIG_DIR", tmp_path)
275
+ monkeypatch.setattr("builtins.input", lambda _: "my_secret_key")
276
+
277
+ output, code = _run_cli(["config", "set-key"])
278
+ assert code == 0
279
+ assert "saved" in output
280
+ assert config_file.read_text().strip() == 'api_key = "my_secret_key"'
281
+
282
+ def test_config_show_no_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
283
+ monkeypatch.delenv("AGENTPATCH_API_KEY", raising=False)
284
+ monkeypatch.setattr("agentpatch.CONFIG_FILE", Path("/nonexistent/config.toml"))
285
+
286
+ output, code = _run_cli(["config", "show"])
287
+ assert code == 0
288
+ assert "not set" in output or "Config" in output
289
+
290
+ def test_config_clear(self, tmp_path: Any, monkeypatch: pytest.MonkeyPatch) -> None:
291
+ config_file = tmp_path / "config.toml"
292
+ config_file.write_text('api_key = "test"\n')
293
+ monkeypatch.setattr("agentpatch.CONFIG_FILE", config_file)
294
+
295
+ output, code = _run_cli(["config", "clear"])
296
+ assert code == 0
297
+ assert "cleared" in output
298
+ assert not config_file.exists()