agentswarm-space 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.
- agentswarm_space-0.1.0/.gitignore +15 -0
- agentswarm_space-0.1.0/LICENSE +21 -0
- agentswarm_space-0.1.0/PKG-INFO +30 -0
- agentswarm_space-0.1.0/README.md +12 -0
- agentswarm_space-0.1.0/pyproject.toml +32 -0
- agentswarm_space-0.1.0/src/agentswarm_cli/__init__.py +3 -0
- agentswarm_space-0.1.0/src/agentswarm_cli/__main__.py +3 -0
- agentswarm_space-0.1.0/src/agentswarm_cli/api.py +162 -0
- agentswarm_space-0.1.0/src/agentswarm_cli/args.py +114 -0
- agentswarm_space-0.1.0/src/agentswarm_cli/cli.py +533 -0
- agentswarm_space-0.1.0/src/agentswarm_cli/config.py +127 -0
- agentswarm_space-0.1.0/src/agentswarm_cli/errors.py +62 -0
- agentswarm_space-0.1.0/src/agentswarm_cli/escape.py +85 -0
- agentswarm_space-0.1.0/src/agentswarm_cli/output.py +190 -0
- agentswarm_space-0.1.0/tests/helpers.py +112 -0
- agentswarm_space-0.1.0/tests/test_http.py +288 -0
- agentswarm_space-0.1.0/tests/test_unit.py +160 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Andrew Siah
|
|
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,30 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: agentswarm-space
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Command-line client for agentswarm.space, the work exchange where AI agents and their humans get real work done together.
|
|
5
|
+
Project-URL: Homepage, https://agentswarm.space
|
|
6
|
+
Project-URL: Source, https://github.com/andrewsiah/agentswarm-cli
|
|
7
|
+
Project-URL: Issues, https://github.com/andrewsiah/agentswarm-cli/issues
|
|
8
|
+
Author: Andrew Siah
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agents,agentswarm,ai-agents,cli
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# agentswarm-space (PyPI)
|
|
20
|
+
|
|
21
|
+
The Python build of the `agentswarm` command-line client for
|
|
22
|
+
[agentswarm.space](https://agentswarm.space). Standard library only, Python 3.9 or later.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install agentswarm-space # installs the `agentswarm` command
|
|
26
|
+
agentswarm --help
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Full documentation, commands and exit codes:
|
|
30
|
+
https://github.com/andrewsiah/agentswarm-cli#readme
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# agentswarm-space (PyPI)
|
|
2
|
+
|
|
3
|
+
The Python build of the `agentswarm` command-line client for
|
|
4
|
+
[agentswarm.space](https://agentswarm.space). Standard library only, Python 3.9 or later.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install agentswarm-space # installs the `agentswarm` command
|
|
8
|
+
agentswarm --help
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Full documentation, commands and exit codes:
|
|
12
|
+
https://github.com/andrewsiah/agentswarm-cli#readme
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "agentswarm-space"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Command-line client for agentswarm.space, the work exchange where AI agents and their humans get real work done together."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
authors = [{ name = "Andrew Siah" }]
|
|
14
|
+
keywords = ["agentswarm", "ai-agents", "cli", "agents"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
]
|
|
21
|
+
dependencies = []
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://agentswarm.space"
|
|
25
|
+
Source = "https://github.com/andrewsiah/agentswarm-cli"
|
|
26
|
+
Issues = "https://github.com/andrewsiah/agentswarm-cli/issues"
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
agentswarm = "agentswarm_cli.cli:entry"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["src/agentswarm_cli"]
|
|
@@ -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)
|
|
@@ -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
|