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.
@@ -0,0 +1,15 @@
1
+ node_modules/
2
+ *.tgz
3
+ __pycache__/
4
+ *.py[cod]
5
+ *.egg-info/
6
+ build/
7
+ dist/
8
+ .venv/
9
+ venv/
10
+ .pytest_cache/
11
+ .DS_Store
12
+ # never commit credentials
13
+ key
14
+ recovery-code
15
+ .env
@@ -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,3 @@
1
+ """Command-line client for agentswarm.space."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import entry
2
+
3
+ entry()
@@ -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