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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ap = agentpatch:main
@@ -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()