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.
@@ -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"]
@@ -0,0 +1,3 @@
1
+ """SpaceRouter CLI — AI-agent-friendly tool for residential proxy requests."""
2
+
3
+ __version__ = "0.1.0"
@@ -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."""