spacerouter-cli 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.
- spacerouter_cli-0.1.0/.gitignore +25 -0
- spacerouter_cli-0.1.0/PKG-INFO +13 -0
- spacerouter_cli-0.1.0/pyproject.toml +31 -0
- spacerouter_cli-0.1.0/src/spacerouter_cli/__init__.py +3 -0
- spacerouter_cli-0.1.0/src/spacerouter_cli/commands/__init__.py +0 -0
- spacerouter_cli-0.1.0/src/spacerouter_cli/commands/api_key.py +63 -0
- spacerouter_cli-0.1.0/src/spacerouter_cli/commands/config_cmd.py +52 -0
- spacerouter_cli-0.1.0/src/spacerouter_cli/commands/node.py +30 -0
- spacerouter_cli-0.1.0/src/spacerouter_cli/commands/request.py +220 -0
- spacerouter_cli-0.1.0/src/spacerouter_cli/commands/status.py +71 -0
- spacerouter_cli-0.1.0/src/spacerouter_cli/config.py +110 -0
- spacerouter_cli-0.1.0/src/spacerouter_cli/main.py +39 -0
- spacerouter_cli-0.1.0/src/spacerouter_cli/output.py +102 -0
- spacerouter_cli-0.1.0/tests/__init__.py +0 -0
- spacerouter_cli-0.1.0/tests/conftest.py +49 -0
- spacerouter_cli-0.1.0/tests/test_api_key.py +98 -0
- spacerouter_cli-0.1.0/tests/test_config_cmd.py +64 -0
- spacerouter_cli-0.1.0/tests/test_integration.py +98 -0
- spacerouter_cli-0.1.0/tests/test_node.py +70 -0
- spacerouter_cli-0.1.0/tests/test_request.py +189 -0
- spacerouter_cli-0.1.0/tests/test_status.py +78 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
dist/
|
|
6
|
+
build/
|
|
7
|
+
*.egg
|
|
8
|
+
.pytest_cache/
|
|
9
|
+
.venv/
|
|
10
|
+
venv/
|
|
11
|
+
|
|
12
|
+
# JavaScript
|
|
13
|
+
node_modules/
|
|
14
|
+
dist/
|
|
15
|
+
*.tsbuildinfo
|
|
16
|
+
|
|
17
|
+
# IDE
|
|
18
|
+
.idea/
|
|
19
|
+
.vscode/
|
|
20
|
+
*.swp
|
|
21
|
+
*.swo
|
|
22
|
+
|
|
23
|
+
# OS
|
|
24
|
+
.DS_Store
|
|
25
|
+
Thumbs.db
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: spacerouter-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI for the Space Router residential proxy network — designed for AI agents
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: httpx<1.0,>=0.27
|
|
8
|
+
Requires-Dist: rich<14.0,>=13.0
|
|
9
|
+
Requires-Dist: spacerouter>=0.1.0
|
|
10
|
+
Requires-Dist: typer<1.0,>=0.12
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
13
|
+
Requires-Dist: respx>=0.22; extra == 'dev'
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "spacerouter-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "CLI for the Space Router residential proxy network — designed for AI agents"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"typer>=0.12,<1.0",
|
|
13
|
+
"httpx>=0.27,<1.0",
|
|
14
|
+
"spacerouter>=0.1.0",
|
|
15
|
+
"rich>=13.0,<14.0",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
spacerouter = "spacerouter_cli.main:app"
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
dev = [
|
|
23
|
+
"pytest>=8.0",
|
|
24
|
+
"respx>=0.22",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[tool.hatch.build.targets.wheel]
|
|
28
|
+
packages = ["src/spacerouter_cli"]
|
|
29
|
+
|
|
30
|
+
[tool.pytest.ini_options]
|
|
31
|
+
testpaths = ["tests"]
|
|
File without changes
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""``spacerouter api-key`` — manage API keys."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated, Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from spacerouter import SpaceRouterAdmin
|
|
10
|
+
|
|
11
|
+
from spacerouter_cli.config import resolve_config
|
|
12
|
+
from spacerouter_cli.output import cli_error_handler, print_json
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(no_args_is_help=True)
|
|
15
|
+
|
|
16
|
+
CoordinationUrlOpt = Annotated[
|
|
17
|
+
Optional[str],
|
|
18
|
+
typer.Option("--coordination-url", help="Coordination API URL."),
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.command()
|
|
23
|
+
@cli_error_handler
|
|
24
|
+
def create(
|
|
25
|
+
name: Annotated[str, typer.Option("--name", help="Human-readable key name.")],
|
|
26
|
+
rate_limit: Annotated[int, typer.Option("--rate-limit", help="Requests per minute.")] = 60,
|
|
27
|
+
coordination_url: CoordinationUrlOpt = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Create a new API key."""
|
|
30
|
+
cfg = resolve_config(coordination_api_url=coordination_url)
|
|
31
|
+
with SpaceRouterAdmin(cfg.coordination_api_url) as admin:
|
|
32
|
+
key = admin.create_api_key(name, rate_limit_rpm=rate_limit)
|
|
33
|
+
print_json({
|
|
34
|
+
"id": key.id,
|
|
35
|
+
"name": key.name,
|
|
36
|
+
"api_key": key.api_key,
|
|
37
|
+
"rate_limit_rpm": key.rate_limit_rpm,
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@app.command("list")
|
|
42
|
+
@cli_error_handler
|
|
43
|
+
def list_keys(
|
|
44
|
+
coordination_url: CoordinationUrlOpt = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""List all API keys."""
|
|
47
|
+
cfg = resolve_config(coordination_api_url=coordination_url)
|
|
48
|
+
with SpaceRouterAdmin(cfg.coordination_api_url) as admin:
|
|
49
|
+
keys = admin.list_api_keys()
|
|
50
|
+
print_json([k.model_dump() for k in keys])
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.command()
|
|
54
|
+
@cli_error_handler
|
|
55
|
+
def revoke(
|
|
56
|
+
key_id: Annotated[str, typer.Argument(help="ID of the API key to revoke.")],
|
|
57
|
+
coordination_url: CoordinationUrlOpt = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Revoke (soft-delete) an API key."""
|
|
60
|
+
cfg = resolve_config(coordination_api_url=coordination_url)
|
|
61
|
+
with SpaceRouterAdmin(cfg.coordination_api_url) as admin:
|
|
62
|
+
admin.revoke_api_key(key_id)
|
|
63
|
+
print_json({"ok": True, "revoked_key_id": key_id})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""``spacerouter config`` — configuration management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from spacerouter_cli.config import (
|
|
10
|
+
ALLOWED_CONFIG_KEYS,
|
|
11
|
+
CONFIG_FILE,
|
|
12
|
+
mask_key,
|
|
13
|
+
resolve_config,
|
|
14
|
+
save_config,
|
|
15
|
+
)
|
|
16
|
+
from spacerouter_cli.output import print_error, print_json
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(no_args_is_help=True)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.command()
|
|
22
|
+
def show() -> None:
|
|
23
|
+
"""Display the resolved configuration (API key is masked)."""
|
|
24
|
+
cfg = resolve_config()
|
|
25
|
+
print_json({
|
|
26
|
+
"api_key": mask_key(cfg.api_key),
|
|
27
|
+
"gateway_url": cfg.gateway_url,
|
|
28
|
+
"coordination_api_url": cfg.coordination_api_url,
|
|
29
|
+
"gateway_management_url": cfg.gateway_management_url,
|
|
30
|
+
"timeout": cfg.timeout,
|
|
31
|
+
"config_file": str(CONFIG_FILE),
|
|
32
|
+
"config_file_exists": CONFIG_FILE.exists(),
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.command("set")
|
|
37
|
+
def set_value(
|
|
38
|
+
key: Annotated[str, typer.Argument(help=f"Config key. Allowed: {', '.join(sorted(ALLOWED_CONFIG_KEYS))}.")],
|
|
39
|
+
value: Annotated[str, typer.Argument(help="Value to set.")],
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Set a configuration value in ~/.spacerouter/config.json."""
|
|
42
|
+
if key not in ALLOWED_CONFIG_KEYS:
|
|
43
|
+
print_error(
|
|
44
|
+
"invalid_config_key",
|
|
45
|
+
f"Unknown key: {key}",
|
|
46
|
+
allowed_keys=sorted(ALLOWED_CONFIG_KEYS),
|
|
47
|
+
)
|
|
48
|
+
raise typer.Exit(code=1)
|
|
49
|
+
|
|
50
|
+
save_config({key: value})
|
|
51
|
+
display_value = mask_key(value) if "key" in key else value
|
|
52
|
+
print_json({"ok": True, "key": key, "value": display_value})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""``spacerouter node`` — view node information."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from spacerouter_cli.config import resolve_config
|
|
11
|
+
from spacerouter_cli.output import cli_error_handler, print_json
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(no_args_is_help=True)
|
|
14
|
+
|
|
15
|
+
CoordinationUrlOpt = Annotated[
|
|
16
|
+
Optional[str],
|
|
17
|
+
typer.Option("--coordination-url", help="Coordination API URL."),
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.command("list")
|
|
22
|
+
@cli_error_handler
|
|
23
|
+
def list_nodes(
|
|
24
|
+
coordination_url: CoordinationUrlOpt = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
"""List all registered nodes."""
|
|
27
|
+
cfg = resolve_config(coordination_api_url=coordination_url)
|
|
28
|
+
response = httpx.get(f"{cfg.coordination_api_url}/nodes", timeout=10.0)
|
|
29
|
+
response.raise_for_status()
|
|
30
|
+
print_json(response.json())
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""``spacerouter request`` — make proxied HTTP requests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json as _json
|
|
6
|
+
from typing import Annotated, Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from spacerouter import SpaceRouter
|
|
11
|
+
|
|
12
|
+
from spacerouter_cli.config import resolve_config
|
|
13
|
+
from spacerouter_cli.output import cli_error_handler, print_error, print_json
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(no_args_is_help=True)
|
|
16
|
+
|
|
17
|
+
# -- shared option types -----------------------------------------------------
|
|
18
|
+
|
|
19
|
+
ApiKeyOpt = Annotated[Optional[str], typer.Option("--api-key", help="API key for proxy auth.")]
|
|
20
|
+
GatewayOpt = Annotated[Optional[str], typer.Option("--gateway-url", help="Proxy gateway URL.")]
|
|
21
|
+
HeaderOpt = Annotated[Optional[list[str]], typer.Option("--header", "-H", help="Custom header (Name: Value). Repeatable.")]
|
|
22
|
+
IpTypeOpt = Annotated[Optional[str], typer.Option("--ip-type", help="IP type filter: residential, mobile, datacenter, business.")]
|
|
23
|
+
RegionOpt = Annotated[Optional[str], typer.Option("--region", help="Region filter substring.")]
|
|
24
|
+
TimeoutOpt = Annotated[Optional[float], typer.Option("--timeout", help="Request timeout in seconds.")]
|
|
25
|
+
OutputOpt = Annotated[str, typer.Option("--output", help="Output mode: json (structured) or raw (body only).")]
|
|
26
|
+
FollowOpt = Annotated[bool, typer.Option("--follow-redirects", help="Follow HTTP redirects.")]
|
|
27
|
+
DataOpt = Annotated[Optional[str], typer.Option("--data", "-d", help="JSON request body.")]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _parse_headers(raw: list[str] | None) -> dict[str, str]:
|
|
31
|
+
"""Parse ``["Name: Value", ...]`` into a dict."""
|
|
32
|
+
if not raw:
|
|
33
|
+
return {}
|
|
34
|
+
headers: dict[str, str] = {}
|
|
35
|
+
for item in raw:
|
|
36
|
+
name, _, value = item.partition(":")
|
|
37
|
+
headers[name.strip()] = value.strip()
|
|
38
|
+
return headers
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _try_parse_json(text: str):
|
|
42
|
+
"""Attempt to parse *text* as JSON; return raw string on failure."""
|
|
43
|
+
try:
|
|
44
|
+
return _json.loads(text)
|
|
45
|
+
except (ValueError, TypeError):
|
|
46
|
+
return text
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _do_request(
|
|
50
|
+
method: str,
|
|
51
|
+
url: str,
|
|
52
|
+
*,
|
|
53
|
+
api_key: str | None,
|
|
54
|
+
gateway_url: str | None,
|
|
55
|
+
header: list[str] | None,
|
|
56
|
+
ip_type: str | None,
|
|
57
|
+
region: str | None,
|
|
58
|
+
timeout: float | None,
|
|
59
|
+
output: str,
|
|
60
|
+
follow_redirects: bool,
|
|
61
|
+
data: str | None = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
cfg = resolve_config(api_key=api_key, gateway_url=gateway_url, timeout=timeout)
|
|
64
|
+
|
|
65
|
+
if not cfg.api_key:
|
|
66
|
+
print_error("configuration_error", "API key is required. Set SR_API_KEY or pass --api-key.")
|
|
67
|
+
raise typer.Exit(code=1)
|
|
68
|
+
|
|
69
|
+
headers = _parse_headers(header)
|
|
70
|
+
kwargs: dict = {"headers": headers}
|
|
71
|
+
if data is not None:
|
|
72
|
+
try:
|
|
73
|
+
kwargs["json"] = _json.loads(data)
|
|
74
|
+
except (ValueError, TypeError):
|
|
75
|
+
print_error("configuration_error", "Invalid JSON in --data flag.")
|
|
76
|
+
raise typer.Exit(code=1)
|
|
77
|
+
|
|
78
|
+
with SpaceRouter(
|
|
79
|
+
cfg.api_key,
|
|
80
|
+
gateway_url=cfg.gateway_url,
|
|
81
|
+
ip_type=ip_type,
|
|
82
|
+
region=region,
|
|
83
|
+
timeout=cfg.timeout,
|
|
84
|
+
coordination_url=cfg.coordination_api_url,
|
|
85
|
+
follow_redirects=follow_redirects,
|
|
86
|
+
) as client:
|
|
87
|
+
resp = client.request(method, url, **kwargs)
|
|
88
|
+
|
|
89
|
+
if output == "raw":
|
|
90
|
+
typer.echo(resp.text)
|
|
91
|
+
else:
|
|
92
|
+
print_json({
|
|
93
|
+
"status_code": resp.status_code,
|
|
94
|
+
"headers": dict(resp.headers),
|
|
95
|
+
"body": _try_parse_json(resp.text),
|
|
96
|
+
"spacerouter": {
|
|
97
|
+
"node_id": resp.node_id,
|
|
98
|
+
"request_id": resp.request_id,
|
|
99
|
+
},
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# -- subcommands --------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@app.command()
|
|
107
|
+
@cli_error_handler
|
|
108
|
+
def get(
|
|
109
|
+
url: str,
|
|
110
|
+
api_key: ApiKeyOpt = None,
|
|
111
|
+
gateway_url: GatewayOpt = None,
|
|
112
|
+
header: HeaderOpt = None,
|
|
113
|
+
ip_type: IpTypeOpt = None,
|
|
114
|
+
region: RegionOpt = None,
|
|
115
|
+
timeout: TimeoutOpt = None,
|
|
116
|
+
output: OutputOpt = "json",
|
|
117
|
+
follow_redirects: FollowOpt = False,
|
|
118
|
+
) -> None:
|
|
119
|
+
"""Send a GET request through the residential proxy."""
|
|
120
|
+
_do_request("GET", url, api_key=api_key, gateway_url=gateway_url, header=header,
|
|
121
|
+
ip_type=ip_type, region=region, timeout=timeout, output=output,
|
|
122
|
+
follow_redirects=follow_redirects)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@app.command()
|
|
126
|
+
@cli_error_handler
|
|
127
|
+
def post(
|
|
128
|
+
url: str,
|
|
129
|
+
api_key: ApiKeyOpt = None,
|
|
130
|
+
gateway_url: GatewayOpt = None,
|
|
131
|
+
header: HeaderOpt = None,
|
|
132
|
+
data: DataOpt = None,
|
|
133
|
+
ip_type: IpTypeOpt = None,
|
|
134
|
+
region: RegionOpt = None,
|
|
135
|
+
timeout: TimeoutOpt = None,
|
|
136
|
+
output: OutputOpt = "json",
|
|
137
|
+
follow_redirects: FollowOpt = False,
|
|
138
|
+
) -> None:
|
|
139
|
+
"""Send a POST request through the residential proxy."""
|
|
140
|
+
_do_request("POST", url, api_key=api_key, gateway_url=gateway_url, header=header,
|
|
141
|
+
ip_type=ip_type, region=region, timeout=timeout, output=output,
|
|
142
|
+
follow_redirects=follow_redirects, data=data)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@app.command()
|
|
146
|
+
@cli_error_handler
|
|
147
|
+
def put(
|
|
148
|
+
url: str,
|
|
149
|
+
api_key: ApiKeyOpt = None,
|
|
150
|
+
gateway_url: GatewayOpt = None,
|
|
151
|
+
header: HeaderOpt = None,
|
|
152
|
+
data: DataOpt = None,
|
|
153
|
+
ip_type: IpTypeOpt = None,
|
|
154
|
+
region: RegionOpt = None,
|
|
155
|
+
timeout: TimeoutOpt = None,
|
|
156
|
+
output: OutputOpt = "json",
|
|
157
|
+
follow_redirects: FollowOpt = False,
|
|
158
|
+
) -> None:
|
|
159
|
+
"""Send a PUT request through the residential proxy."""
|
|
160
|
+
_do_request("PUT", url, api_key=api_key, gateway_url=gateway_url, header=header,
|
|
161
|
+
ip_type=ip_type, region=region, timeout=timeout, output=output,
|
|
162
|
+
follow_redirects=follow_redirects, data=data)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@app.command()
|
|
166
|
+
@cli_error_handler
|
|
167
|
+
def patch(
|
|
168
|
+
url: str,
|
|
169
|
+
api_key: ApiKeyOpt = None,
|
|
170
|
+
gateway_url: GatewayOpt = None,
|
|
171
|
+
header: HeaderOpt = None,
|
|
172
|
+
data: DataOpt = None,
|
|
173
|
+
ip_type: IpTypeOpt = None,
|
|
174
|
+
region: RegionOpt = None,
|
|
175
|
+
timeout: TimeoutOpt = None,
|
|
176
|
+
output: OutputOpt = "json",
|
|
177
|
+
follow_redirects: FollowOpt = False,
|
|
178
|
+
) -> None:
|
|
179
|
+
"""Send a PATCH request through the residential proxy."""
|
|
180
|
+
_do_request("PATCH", url, api_key=api_key, gateway_url=gateway_url, header=header,
|
|
181
|
+
ip_type=ip_type, region=region, timeout=timeout, output=output,
|
|
182
|
+
follow_redirects=follow_redirects, data=data)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@app.command()
|
|
186
|
+
@cli_error_handler
|
|
187
|
+
def delete(
|
|
188
|
+
url: str,
|
|
189
|
+
api_key: ApiKeyOpt = None,
|
|
190
|
+
gateway_url: GatewayOpt = None,
|
|
191
|
+
header: HeaderOpt = None,
|
|
192
|
+
ip_type: IpTypeOpt = None,
|
|
193
|
+
region: RegionOpt = None,
|
|
194
|
+
timeout: TimeoutOpt = None,
|
|
195
|
+
output: OutputOpt = "json",
|
|
196
|
+
follow_redirects: FollowOpt = False,
|
|
197
|
+
) -> None:
|
|
198
|
+
"""Send a DELETE request through the residential proxy."""
|
|
199
|
+
_do_request("DELETE", url, api_key=api_key, gateway_url=gateway_url, header=header,
|
|
200
|
+
ip_type=ip_type, region=region, timeout=timeout, output=output,
|
|
201
|
+
follow_redirects=follow_redirects)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@app.command()
|
|
205
|
+
@cli_error_handler
|
|
206
|
+
def head(
|
|
207
|
+
url: str,
|
|
208
|
+
api_key: ApiKeyOpt = None,
|
|
209
|
+
gateway_url: GatewayOpt = None,
|
|
210
|
+
header: HeaderOpt = None,
|
|
211
|
+
ip_type: IpTypeOpt = None,
|
|
212
|
+
region: RegionOpt = None,
|
|
213
|
+
timeout: TimeoutOpt = None,
|
|
214
|
+
output: OutputOpt = "json",
|
|
215
|
+
follow_redirects: FollowOpt = False,
|
|
216
|
+
) -> None:
|
|
217
|
+
"""Send a HEAD request through the residential proxy."""
|
|
218
|
+
_do_request("HEAD", url, api_key=api_key, gateway_url=gateway_url, header=header,
|
|
219
|
+
ip_type=ip_type, region=region, timeout=timeout, output=output,
|
|
220
|
+
follow_redirects=follow_redirects)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""``spacerouter status`` — check service health."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from spacerouter_cli.config import resolve_config
|
|
11
|
+
from spacerouter_cli.output import cli_error_handler, print_json
|
|
12
|
+
|
|
13
|
+
CoordinationUrlOpt = Annotated[
|
|
14
|
+
Optional[str],
|
|
15
|
+
typer.Option("--coordination-url", help="Coordination API URL."),
|
|
16
|
+
]
|
|
17
|
+
GatewayMgmtOpt = Annotated[
|
|
18
|
+
Optional[str],
|
|
19
|
+
typer.Option("--gateway-management-url", help="Gateway management API URL."),
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@cli_error_handler
|
|
24
|
+
def status(
|
|
25
|
+
coordination_url: CoordinationUrlOpt = None,
|
|
26
|
+
gateway_management_url: GatewayMgmtOpt = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Check Coordination API and Proxy Gateway health."""
|
|
29
|
+
cfg = resolve_config(
|
|
30
|
+
coordination_api_url=coordination_url,
|
|
31
|
+
gateway_management_url=gateway_management_url,
|
|
32
|
+
)
|
|
33
|
+
results: dict = {}
|
|
34
|
+
|
|
35
|
+
# Coordination API
|
|
36
|
+
try:
|
|
37
|
+
resp = httpx.get(f"{cfg.coordination_api_url}/healthz", timeout=5.0)
|
|
38
|
+
results["coordination_api"] = {
|
|
39
|
+
"url": cfg.coordination_api_url,
|
|
40
|
+
"status": "healthy" if resp.status_code == 200 else "unhealthy",
|
|
41
|
+
"status_code": resp.status_code,
|
|
42
|
+
}
|
|
43
|
+
except httpx.HTTPError as e:
|
|
44
|
+
results["coordination_api"] = {
|
|
45
|
+
"url": cfg.coordination_api_url,
|
|
46
|
+
"status": "unreachable",
|
|
47
|
+
"error": str(e),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# Proxy Gateway management
|
|
51
|
+
try:
|
|
52
|
+
health = httpx.get(f"{cfg.gateway_management_url}/healthz", timeout=5.0)
|
|
53
|
+
ready = httpx.get(f"{cfg.gateway_management_url}/readyz", timeout=5.0)
|
|
54
|
+
results["gateway"] = {
|
|
55
|
+
"url": cfg.gateway_management_url,
|
|
56
|
+
"healthy": health.status_code == 200,
|
|
57
|
+
"ready": ready.json().get("status") == "ready",
|
|
58
|
+
}
|
|
59
|
+
except httpx.HTTPError as e:
|
|
60
|
+
results["gateway"] = {
|
|
61
|
+
"url": cfg.gateway_management_url,
|
|
62
|
+
"status": "unreachable",
|
|
63
|
+
"error": str(e),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
coord_ok = results.get("coordination_api", {}).get("status") == "healthy"
|
|
67
|
+
gw_ok = results.get("gateway", {}).get("healthy", False)
|
|
68
|
+
results["overall"] = "healthy" if (coord_ok and gw_ok) else "degraded"
|
|
69
|
+
|
|
70
|
+
print_json(results)
|
|
71
|
+
raise typer.Exit(code=0 if results["overall"] == "healthy" else 1)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Configuration resolution for the SpaceRouter CLI.
|
|
2
|
+
|
|
3
|
+
Priority (highest to lowest):
|
|
4
|
+
1. CLI flags (--api-key, --gateway-url, etc.)
|
|
5
|
+
2. Environment variables (SR_API_KEY, SR_GATEWAY_URL, SR_COORDINATION_API_URL)
|
|
6
|
+
3. Config file (~/.spacerouter/config.json)
|
|
7
|
+
4. Built-in defaults
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
CONFIG_DIR = Path.home() / ".spacerouter"
|
|
18
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
19
|
+
|
|
20
|
+
ENV_API_KEY = "SR_API_KEY"
|
|
21
|
+
ENV_GATEWAY_URL = "SR_GATEWAY_URL"
|
|
22
|
+
ENV_COORDINATION_API_URL = "SR_COORDINATION_API_URL"
|
|
23
|
+
ENV_GATEWAY_MANAGEMENT_URL = "SR_GATEWAY_MANAGEMENT_URL"
|
|
24
|
+
|
|
25
|
+
DEFAULT_GATEWAY_URL = "https://gateway.spacerouter.org:8080"
|
|
26
|
+
DEFAULT_COORDINATION_API_URL = "https://coordination.spacerouter.org"
|
|
27
|
+
DEFAULT_GATEWAY_MANAGEMENT_URL = "http://gateway.spacerouter.org:8081"
|
|
28
|
+
DEFAULT_TIMEOUT = 30.0
|
|
29
|
+
|
|
30
|
+
ALLOWED_CONFIG_KEYS = {
|
|
31
|
+
"api_key",
|
|
32
|
+
"gateway_url",
|
|
33
|
+
"coordination_api_url",
|
|
34
|
+
"gateway_management_url",
|
|
35
|
+
"timeout",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class CLIConfig:
|
|
41
|
+
api_key: str | None = None
|
|
42
|
+
gateway_url: str = DEFAULT_GATEWAY_URL
|
|
43
|
+
coordination_api_url: str = DEFAULT_COORDINATION_API_URL
|
|
44
|
+
gateway_management_url: str = DEFAULT_GATEWAY_MANAGEMENT_URL
|
|
45
|
+
timeout: float = DEFAULT_TIMEOUT
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_config_file() -> dict:
|
|
49
|
+
"""Load config file, returning empty dict if missing or invalid."""
|
|
50
|
+
if not CONFIG_FILE.exists():
|
|
51
|
+
return {}
|
|
52
|
+
try:
|
|
53
|
+
return json.loads(CONFIG_FILE.read_text())
|
|
54
|
+
except (json.JSONDecodeError, OSError):
|
|
55
|
+
return {}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def resolve_config(**cli_overrides: str | float | None) -> CLIConfig:
|
|
59
|
+
"""Merge config file -> env vars -> CLI overrides.
|
|
60
|
+
|
|
61
|
+
Values that are ``None`` in a higher-priority layer are skipped so
|
|
62
|
+
lower-priority values can fill in.
|
|
63
|
+
"""
|
|
64
|
+
file_cfg = load_config_file()
|
|
65
|
+
|
|
66
|
+
def _pick(key: str, env_var: str | None = None, default: str | float | None = None):
|
|
67
|
+
# CLI flag first
|
|
68
|
+
cli_val = cli_overrides.get(key)
|
|
69
|
+
if cli_val is not None:
|
|
70
|
+
return cli_val
|
|
71
|
+
# Env var second
|
|
72
|
+
if env_var:
|
|
73
|
+
env_val = os.environ.get(env_var)
|
|
74
|
+
if env_val is not None:
|
|
75
|
+
return env_val
|
|
76
|
+
# Config file third
|
|
77
|
+
file_val = file_cfg.get(key)
|
|
78
|
+
if file_val is not None:
|
|
79
|
+
return file_val
|
|
80
|
+
# Default last
|
|
81
|
+
return default
|
|
82
|
+
|
|
83
|
+
return CLIConfig(
|
|
84
|
+
api_key=_pick("api_key", ENV_API_KEY),
|
|
85
|
+
gateway_url=_pick("gateway_url", ENV_GATEWAY_URL, DEFAULT_GATEWAY_URL),
|
|
86
|
+
coordination_api_url=_pick(
|
|
87
|
+
"coordination_api_url", ENV_COORDINATION_API_URL, DEFAULT_COORDINATION_API_URL
|
|
88
|
+
),
|
|
89
|
+
gateway_management_url=_pick(
|
|
90
|
+
"gateway_management_url", ENV_GATEWAY_MANAGEMENT_URL, DEFAULT_GATEWAY_MANAGEMENT_URL
|
|
91
|
+
),
|
|
92
|
+
timeout=float(_pick("timeout", None, DEFAULT_TIMEOUT)),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def save_config(updates: dict) -> None:
|
|
97
|
+
"""Write *updates* into ~/.spacerouter/config.json (merge, not overwrite)."""
|
|
98
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
existing = load_config_file()
|
|
100
|
+
existing.update(updates)
|
|
101
|
+
CONFIG_FILE.write_text(json.dumps(existing, indent=2) + "\n")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def mask_key(value: str | None) -> str | None:
|
|
105
|
+
"""Mask an API key for display: ``sr_live_abc1****``."""
|
|
106
|
+
if not value:
|
|
107
|
+
return value
|
|
108
|
+
if len(value) <= 12:
|
|
109
|
+
return value[:4] + "****"
|
|
110
|
+
return value[:12] + "****"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""SpaceRouter CLI — AI-agent-friendly tool for residential proxy requests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from spacerouter_cli import __version__
|
|
10
|
+
from spacerouter_cli.commands import api_key, config_cmd, node, request, status
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(
|
|
13
|
+
name="spacerouter",
|
|
14
|
+
help="CLI for the Space Router residential proxy network. Designed for AI agents.",
|
|
15
|
+
no_args_is_help=True,
|
|
16
|
+
pretty_exceptions_enable=False,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
app.add_typer(request.app, name="request", help="Make proxied HTTP requests")
|
|
20
|
+
app.add_typer(api_key.app, name="api-key", help="Manage API keys")
|
|
21
|
+
app.add_typer(node.app, name="node", help="View node information")
|
|
22
|
+
app.add_typer(config_cmd.app, name="config", help="Configuration management")
|
|
23
|
+
app.command(name="status", help="Check service health")(status.status)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _version_callback(value: bool) -> None:
|
|
27
|
+
if value:
|
|
28
|
+
typer.echo(json.dumps({"version": __version__}))
|
|
29
|
+
raise typer.Exit()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@app.callback()
|
|
33
|
+
def main(
|
|
34
|
+
version: bool = typer.Option(
|
|
35
|
+
False, "--version", callback=_version_callback, is_eager=True,
|
|
36
|
+
help="Show CLI version and exit.",
|
|
37
|
+
),
|
|
38
|
+
) -> None:
|
|
39
|
+
"""SpaceRouter CLI — residential proxy requests for AI agents."""
|