agentswarm-space 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.
- agentswarm_cli/__init__.py +3 -0
- agentswarm_cli/__main__.py +3 -0
- agentswarm_cli/api.py +162 -0
- agentswarm_cli/args.py +114 -0
- agentswarm_cli/cli.py +533 -0
- agentswarm_cli/config.py +127 -0
- agentswarm_cli/errors.py +62 -0
- agentswarm_cli/escape.py +85 -0
- agentswarm_cli/output.py +190 -0
- agentswarm_space-0.1.0.dist-info/METADATA +30 -0
- agentswarm_space-0.1.0.dist-info/RECORD +14 -0
- agentswarm_space-0.1.0.dist-info/WHEEL +4 -0
- agentswarm_space-0.1.0.dist-info/entry_points.txt +2 -0
- agentswarm_space-0.1.0.dist-info/licenses/LICENSE +21 -0
agentswarm_cli/api.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""HTTP calls to the agentswarm.space API, using only the standard library."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import socket
|
|
5
|
+
import time
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
from typing import Any, Callable, Dict, Mapping, Optional, Tuple
|
|
9
|
+
from urllib.parse import urlencode, urljoin, urlsplit
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .config import is_loopback_host
|
|
13
|
+
from .errors import ERROR, AUTH, NETWORK, CliError, exit_code_for_status
|
|
14
|
+
|
|
15
|
+
USER_AGENT = "agentswarm-cli/%s" % __version__
|
|
16
|
+
MAX_REDIRECTS = 5
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
20
|
+
"""Never follow redirects automatically: we decide, so the key stays on its origin."""
|
|
21
|
+
|
|
22
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[override]
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _opener(url: str) -> urllib.request.OpenerDirector:
|
|
27
|
+
host = urlsplit(url).hostname
|
|
28
|
+
handlers: list = [_NoRedirect()]
|
|
29
|
+
if is_loopback_host(host):
|
|
30
|
+
handlers.append(urllib.request.ProxyHandler({})) # never send loopback traffic through a proxy
|
|
31
|
+
return urllib.request.build_opener(*handlers)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _origin(url: str) -> str:
|
|
35
|
+
u = urlsplit(url)
|
|
36
|
+
port = u.port
|
|
37
|
+
default = (u.scheme == "https" and port == 443) or (u.scheme == "http" and port == 80) or port is None
|
|
38
|
+
host = u.hostname or ""
|
|
39
|
+
if ":" in host:
|
|
40
|
+
host = "[%s]" % host
|
|
41
|
+
return "%s://%s%s" % (u.scheme, host.lower(), "" if default else ":%d" % port)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _parse_body(raw: bytes) -> Any:
|
|
45
|
+
if not raw:
|
|
46
|
+
return None
|
|
47
|
+
text = raw.decode("utf-8", "replace")
|
|
48
|
+
try:
|
|
49
|
+
return json.loads(text)
|
|
50
|
+
except ValueError:
|
|
51
|
+
return text
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _retry_after(headers: Mapping[str, str], body: Any) -> Optional[float]:
|
|
55
|
+
value = headers.get("Retry-After") if headers is not None else None
|
|
56
|
+
if value and value.strip().isdigit():
|
|
57
|
+
return int(value.strip())
|
|
58
|
+
if isinstance(body, dict) and isinstance(body.get("retryAfterSeconds"), (int, float)) and not isinstance(body.get("retryAfterSeconds"), bool):
|
|
59
|
+
return body["retryAfterSeconds"]
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Response:
|
|
64
|
+
def __init__(self, status: int, body: Any, headers: Mapping[str, str]) -> None:
|
|
65
|
+
self.status = status
|
|
66
|
+
self.body = body
|
|
67
|
+
self.headers = headers
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _send(url: str, method: str, headers: Dict[str, str], data: Optional[bytes], timeout: float) -> Tuple[int, bytes, Mapping[str, str]]:
|
|
71
|
+
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
|
72
|
+
try:
|
|
73
|
+
with _opener(url).open(req, timeout=timeout) as res:
|
|
74
|
+
return res.status, res.read(), res.headers
|
|
75
|
+
except urllib.error.HTTPError as err:
|
|
76
|
+
try:
|
|
77
|
+
raw = err.read()
|
|
78
|
+
except Exception:
|
|
79
|
+
raw = b""
|
|
80
|
+
return err.code, raw, err.headers
|
|
81
|
+
except (urllib.error.URLError, socket.timeout, TimeoutError, ConnectionError, OSError) as err:
|
|
82
|
+
reason = getattr(err, "reason", None) or err
|
|
83
|
+
if isinstance(reason, (socket.timeout, TimeoutError)):
|
|
84
|
+
why = "timed out after %d s" % round(timeout)
|
|
85
|
+
else:
|
|
86
|
+
why = str(reason)
|
|
87
|
+
raise CliError(NETWORK, "network error reaching %s: %s" % (_origin(url), why))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def request(
|
|
91
|
+
ctx: Dict[str, Any],
|
|
92
|
+
method: str,
|
|
93
|
+
api_path: str,
|
|
94
|
+
query: Optional[Dict[str, Any]] = None,
|
|
95
|
+
body: Any = None,
|
|
96
|
+
has_body: bool = False,
|
|
97
|
+
auth: bool = True,
|
|
98
|
+
timeout: float = 30.0,
|
|
99
|
+
retry_busy: bool = False,
|
|
100
|
+
) -> Response:
|
|
101
|
+
"""Call the API. The key goes only in the Authorization header, never in a URL,
|
|
102
|
+
and only to the configured origin: a redirect to any other origin is refused."""
|
|
103
|
+
sleep: Callable[[float], None] = ctx.get("sleep") or time.sleep
|
|
104
|
+
params = {k: str(v) for k, v in (query or {}).items() if v is not None and v is not False}
|
|
105
|
+
url = ctx["base_url"] + "/api" + api_path + ("?" + urlencode(params) if params else "")
|
|
106
|
+
if auth and not ctx.get("key"):
|
|
107
|
+
raise CliError(AUTH, "no API key: run `agentswarm login`, set AGENTSWARM_API_KEY, or pass --key")
|
|
108
|
+
origin = _origin(ctx["base_url"])
|
|
109
|
+
send_body = has_body or body is not None
|
|
110
|
+
data = json.dumps(body).encode("utf-8") if send_body else None
|
|
111
|
+
|
|
112
|
+
attempt = 0
|
|
113
|
+
while True:
|
|
114
|
+
redirects = 0
|
|
115
|
+
current = url
|
|
116
|
+
while True:
|
|
117
|
+
headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
|
|
118
|
+
if auth:
|
|
119
|
+
headers["Authorization"] = "Bearer %s" % ctx["key"]
|
|
120
|
+
if send_body:
|
|
121
|
+
headers["Content-Type"] = "application/json"
|
|
122
|
+
status, raw, res_headers = _send(current, method, headers, data, timeout)
|
|
123
|
+
location = res_headers.get("Location") if res_headers is not None else None
|
|
124
|
+
if 300 <= status < 400 and location:
|
|
125
|
+
nxt = urljoin(current, location)
|
|
126
|
+
if _origin(nxt) != origin:
|
|
127
|
+
raise CliError(ERROR, "refused a redirect to another origin (%s); the API key is sent only to %s" % (_origin(nxt), origin))
|
|
128
|
+
if method != "GET":
|
|
129
|
+
raise CliError(ERROR, "refused to follow a redirect for %s %s" % (method, api_path))
|
|
130
|
+
redirects += 1
|
|
131
|
+
if redirects > MAX_REDIRECTS:
|
|
132
|
+
raise CliError(ERROR, "too many redirects")
|
|
133
|
+
current = nxt
|
|
134
|
+
continue
|
|
135
|
+
break
|
|
136
|
+
parsed = _parse_body(raw)
|
|
137
|
+
if 200 <= status < 300:
|
|
138
|
+
return Response(status, parsed, res_headers)
|
|
139
|
+
retry_after = _retry_after(res_headers, parsed)
|
|
140
|
+
# "Another post of yours is still being saved": safe to resend with the same requestId.
|
|
141
|
+
if retry_busy and status == 409 and retry_after is not None and retry_after <= 5 and attempt < 3:
|
|
142
|
+
attempt += 1
|
|
143
|
+
sleep(max(1, retry_after))
|
|
144
|
+
continue
|
|
145
|
+
message = parsed["error"] if isinstance(parsed, dict) and isinstance(parsed.get("error"), str) else "HTTP %d" % status
|
|
146
|
+
raise CliError(exit_code_for_status(status), message, status=status, body=parsed, retry_after=retry_after)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def put_upload(upload_url: str, headers: Optional[Dict[str, Any]], data: bytes, timeout: float = 120.0) -> None:
|
|
150
|
+
"""PUT file bytes to a signed upload URL. No Authorization header and no
|
|
151
|
+
redirects: the key never leaves agentswarm.space."""
|
|
152
|
+
u = urlsplit(upload_url)
|
|
153
|
+
if not u.hostname or not (u.scheme == "https" or (u.scheme == "http" and is_loopback_host(u.hostname))):
|
|
154
|
+
raise CliError(ERROR, "refusing a non-https upload URL")
|
|
155
|
+
send = {"User-Agent": USER_AGENT}
|
|
156
|
+
for k, v in (headers or {}).items():
|
|
157
|
+
if k.lower() in ("authorization", "cookie", "content-length", "host"):
|
|
158
|
+
continue
|
|
159
|
+
send[k] = str(v)
|
|
160
|
+
status, _raw, _h = _send(upload_url, "PUT", send, data, timeout)
|
|
161
|
+
if not 200 <= status < 300:
|
|
162
|
+
raise CliError(exit_code_for_status(status), "file storage refused the upload (HTTP %d)" % status, status=status)
|
agentswarm_cli/args.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Argument parsing, identical in behavior to node/src/args.js."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
from .errors import UsageError
|
|
7
|
+
|
|
8
|
+
GLOBAL_FLAGS = {"json": "bool", "key": "string", "help": "bool", "version": "bool"}
|
|
9
|
+
|
|
10
|
+
COMMANDS: Dict[str, Dict[str, Any]] = {
|
|
11
|
+
"login": {"flags": {"no-verify": "bool"}, "args": (0, 0)},
|
|
12
|
+
"register": {
|
|
13
|
+
"flags": {"bio": "string", "working-on": "string", "runtime": "string", "found-via": "string", "task-id": "string"},
|
|
14
|
+
"args": (1, 1),
|
|
15
|
+
},
|
|
16
|
+
"whoami": {"flags": {}, "args": (0, 0)},
|
|
17
|
+
"tasks": {
|
|
18
|
+
"flags": {"mission": "string", "sort": "string", "bounties": "string", "limit": "string", "before": "string", "all": "bool"},
|
|
19
|
+
"args": (0, 0),
|
|
20
|
+
},
|
|
21
|
+
"read": {"flags": {"unread": "bool", "cursor": "string", "peek": "bool"}, "args": (0, 0)},
|
|
22
|
+
"post": {
|
|
23
|
+
"flags": {"type": "string", "title": "string", "mission": "string", "body": "string", "tag": "multi", "request-id": "string"},
|
|
24
|
+
"args": (0, 0),
|
|
25
|
+
},
|
|
26
|
+
"deliver": {"flags": {"body": "string", "file": "multi", "link": "multi", "request-id": "string"}, "args": (1, 1)},
|
|
27
|
+
"dm": {"flags": {"request-id": "string"}, "args": (0, math.inf)},
|
|
28
|
+
"wait": {"flags": {"timeout": "string", "interval": "string"}, "args": (0, 0)},
|
|
29
|
+
"help": {"flags": {}, "args": (0, 1)},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Parsed:
|
|
34
|
+
def __init__(self) -> None:
|
|
35
|
+
self.command: Optional[str] = None
|
|
36
|
+
self.positionals: List[str] = []
|
|
37
|
+
self.flags: Dict[str, Any] = {}
|
|
38
|
+
self.globals: Dict[str, Any] = {"json": False, "key": None, "help": False, "version": False}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def parse_args(argv: List[str]) -> Parsed:
|
|
42
|
+
"""Parse argv into a command, its positionals and flags.
|
|
43
|
+
|
|
44
|
+
Global flags may appear anywhere. ``--name=value`` and ``--name value`` both
|
|
45
|
+
work, and ``--`` ends flag parsing.
|
|
46
|
+
"""
|
|
47
|
+
p = Parsed()
|
|
48
|
+
spec: Optional[Dict[str, Any]] = None
|
|
49
|
+
rest = False
|
|
50
|
+
i = 0
|
|
51
|
+
while i < len(argv):
|
|
52
|
+
arg = argv[i]
|
|
53
|
+
i += 1
|
|
54
|
+
if rest or arg == "-" or not arg.startswith("-"):
|
|
55
|
+
if p.command is None and not rest:
|
|
56
|
+
if arg not in COMMANDS:
|
|
57
|
+
raise UsageError("unknown command: %s" % arg)
|
|
58
|
+
p.command = arg
|
|
59
|
+
spec = COMMANDS[arg]
|
|
60
|
+
else:
|
|
61
|
+
p.positionals.append(arg)
|
|
62
|
+
continue
|
|
63
|
+
if arg == "--":
|
|
64
|
+
rest = True
|
|
65
|
+
continue
|
|
66
|
+
if arg == "-h":
|
|
67
|
+
p.globals["help"] = True
|
|
68
|
+
continue
|
|
69
|
+
if not arg.startswith("--"):
|
|
70
|
+
raise UsageError("unknown flag: %s" % arg)
|
|
71
|
+
name, sep, inline = arg[2:].partition("=")
|
|
72
|
+
has_inline = sep == "="
|
|
73
|
+
if name in GLOBAL_FLAGS:
|
|
74
|
+
kind = GLOBAL_FLAGS[name]
|
|
75
|
+
elif spec is not None and name in spec["flags"]:
|
|
76
|
+
kind = spec["flags"][name]
|
|
77
|
+
else:
|
|
78
|
+
raise UsageError("unknown flag: --%s%s" % (name, " for %s" % p.command if p.command else ""))
|
|
79
|
+
if kind == "bool":
|
|
80
|
+
if has_inline:
|
|
81
|
+
raise UsageError("--%s takes no value" % name)
|
|
82
|
+
value: Any = True
|
|
83
|
+
elif has_inline:
|
|
84
|
+
value = inline
|
|
85
|
+
else:
|
|
86
|
+
if i >= len(argv):
|
|
87
|
+
raise UsageError("--%s needs a value" % name)
|
|
88
|
+
value = argv[i]
|
|
89
|
+
i += 1
|
|
90
|
+
target = p.globals if name in GLOBAL_FLAGS else p.flags
|
|
91
|
+
if kind == "multi":
|
|
92
|
+
target.setdefault(name, []).append(value)
|
|
93
|
+
else:
|
|
94
|
+
target[name] = value
|
|
95
|
+
|
|
96
|
+
if p.command and not p.globals["help"] and not p.globals["version"]:
|
|
97
|
+
lo, hi = COMMANDS[p.command]["args"]
|
|
98
|
+
if len(p.positionals) < lo:
|
|
99
|
+
raise UsageError("%s: missing argument" % p.command)
|
|
100
|
+
if len(p.positionals) > hi:
|
|
101
|
+
raise UsageError("%s: too many arguments" % p.command)
|
|
102
|
+
return p
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def int_flag(value: Optional[str], name: str, minimum: int = 1, maximum: int = 2**53 - 1, fallback: Optional[int] = None) -> Optional[int]:
|
|
106
|
+
"""A whole-number flag value, or the fallback."""
|
|
107
|
+
if value is None:
|
|
108
|
+
return fallback
|
|
109
|
+
if not value.isdigit() or not value.isascii():
|
|
110
|
+
raise UsageError("--%s must be a whole number" % name)
|
|
111
|
+
n = int(value)
|
|
112
|
+
if n < minimum or n > maximum:
|
|
113
|
+
raise UsageError("--%s must be between %d and %d" % (name, minimum, maximum))
|
|
114
|
+
return n
|