sellerclaw-cli 0.0.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.
- sellerclaw_cli/__init__.py +10 -0
- sellerclaw_cli/__main__.py +6 -0
- sellerclaw_cli/_auth.py +78 -0
- sellerclaw_cli/_client.py +157 -0
- sellerclaw_cli/_config.py +82 -0
- sellerclaw_cli/_errors.py +39 -0
- sellerclaw_cli/_generated/.gitkeep +0 -0
- sellerclaw_cli/_output.py +79 -0
- sellerclaw_cli/_runtime.py +69 -0
- sellerclaw_cli/_spec.json +21413 -0
- sellerclaw_cli/_spec.py +70 -0
- sellerclaw_cli/cli.py +51 -0
- sellerclaw_cli/commands/__init__.py +1 -0
- sellerclaw_cli/commands/_auth_cli.py +111 -0
- sellerclaw_cli/commands/_generic.py +94 -0
- sellerclaw_cli/commands/_index.py +28 -0
- sellerclaw_cli/commands/agent_auth.py +48 -0
- sellerclaw_cli/commands/agent_chat.py +40 -0
- sellerclaw_cli/commands/agent_connection.py +79 -0
- sellerclaw_cli/commands/agent_context.py +46 -0
- sellerclaw_cli/commands/agent_goals.py +281 -0
- sellerclaw_cli/commands/agent_hooks.py +28 -0
- sellerclaw_cli/commands/agent_orders.py +52 -0
- sellerclaw_cli/commands/agent_products.py +51 -0
- sellerclaw_cli/commands/agent_sales_channels.py +39 -0
- sellerclaw_cli/commands/ebay_listings.py +53 -0
- sellerclaw_cli/commands/ebay_orders.py +48 -0
- sellerclaw_cli/commands/ebay_shop.py +73 -0
- sellerclaw_cli/commands/facebook_ads.py +225 -0
- sellerclaw_cli/commands/google_ads.py +183 -0
- sellerclaw_cli/commands/listing_sync.py +27 -0
- sellerclaw_cli/commands/research_catalog.py +27 -0
- sellerclaw_cli/commands/research_seo.py +126 -0
- sellerclaw_cli/commands/research_social.py +158 -0
- sellerclaw_cli/commands/research_trends.py +104 -0
- sellerclaw_cli/commands/research_web_search.py +27 -0
- sellerclaw_cli/commands/stores.py +533 -0
- sellerclaw_cli/commands/suppliers.py +171 -0
- sellerclaw_cli-0.0.0.dist-info/METADATA +537 -0
- sellerclaw_cli-0.0.0.dist-info/RECORD +42 -0
- sellerclaw_cli-0.0.0.dist-info/WHEEL +4 -0
- sellerclaw_cli-0.0.0.dist-info/entry_points.txt +2 -0
sellerclaw_cli/_auth.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from sellerclaw_cli._client import Client
|
|
7
|
+
from sellerclaw_cli._errors import AuthError
|
|
8
|
+
|
|
9
|
+
_DEVICE_CODE_PATH = "/agent/auth/device/code"
|
|
10
|
+
_DEVICE_TOKEN_PATH = "/agent/auth/device/token"
|
|
11
|
+
_PASSWORD_TOKEN_PATH = "/agent/auth/token"
|
|
12
|
+
_SLOW_DOWN_STEP_SECONDS = 5
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class DeviceCode:
|
|
17
|
+
device_code: str
|
|
18
|
+
user_code: str
|
|
19
|
+
verification_uri: str
|
|
20
|
+
expires_in: int
|
|
21
|
+
interval: int
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def request_device_code(api_url: str) -> DeviceCode:
|
|
25
|
+
"""POST /agent/auth/device/code. Returns parsed DeviceCode."""
|
|
26
|
+
with Client(base_url=api_url, token=None) as client:
|
|
27
|
+
body = client.request("POST", _DEVICE_CODE_PATH)
|
|
28
|
+
return DeviceCode(
|
|
29
|
+
device_code=body["device_code"],
|
|
30
|
+
user_code=body["user_code"],
|
|
31
|
+
verification_uri=body["verification_uri"],
|
|
32
|
+
expires_in=int(body["expires_in"]),
|
|
33
|
+
interval=int(body["interval"]),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def poll_device_token(
|
|
38
|
+
api_url: str,
|
|
39
|
+
device_code: str,
|
|
40
|
+
*,
|
|
41
|
+
interval: int,
|
|
42
|
+
expires_in: int,
|
|
43
|
+
) -> str:
|
|
44
|
+
"""Poll /agent/auth/device/token until granted or expired. Returns the agent_token (sca_...)."""
|
|
45
|
+
current_interval = interval
|
|
46
|
+
started = time.monotonic()
|
|
47
|
+
|
|
48
|
+
with Client(base_url=api_url, token=None) as client:
|
|
49
|
+
while True:
|
|
50
|
+
if time.monotonic() - started >= expires_in:
|
|
51
|
+
raise AuthError("Device code expired before authorization was granted.")
|
|
52
|
+
|
|
53
|
+
body = client.request("POST", _DEVICE_TOKEN_PATH, json={"device_code": device_code})
|
|
54
|
+
|
|
55
|
+
token = body.get("agent_token") if isinstance(body, dict) else None
|
|
56
|
+
if isinstance(token, str) and token:
|
|
57
|
+
return token
|
|
58
|
+
|
|
59
|
+
err = body.get("error") if isinstance(body, dict) else None
|
|
60
|
+
if err is None or err == "authorization_pending":
|
|
61
|
+
pass
|
|
62
|
+
elif err == "slow_down":
|
|
63
|
+
current_interval += _SLOW_DOWN_STEP_SECONDS
|
|
64
|
+
else:
|
|
65
|
+
raise AuthError(f"Device authorization failed: {err}")
|
|
66
|
+
|
|
67
|
+
time.sleep(current_interval)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def password_login(api_url: str, email: str, password: str) -> str:
|
|
71
|
+
"""POST /agent/auth/token with email+password. Returns the agent_token."""
|
|
72
|
+
with Client(base_url=api_url, token=None) as client:
|
|
73
|
+
body = client.request("POST", _PASSWORD_TOKEN_PATH, json={"email": email, "password": password})
|
|
74
|
+
|
|
75
|
+
token = body.get("agent_token") if isinstance(body, dict) else None
|
|
76
|
+
if not isinstance(token, str) or not token:
|
|
77
|
+
raise AuthError("Login response did not include an agent_token.")
|
|
78
|
+
return token
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from sellerclaw_cli._errors import ApiError, AuthError, NetworkError, ServerError
|
|
11
|
+
|
|
12
|
+
DEFAULT_TIMEOUT_SECONDS = 30.0
|
|
13
|
+
RETRY_STATUS_CODES = frozenset({429, 502, 503, 504})
|
|
14
|
+
MAX_RETRIES = 3
|
|
15
|
+
_BACKOFF_CAP_SECONDS = 10.0
|
|
16
|
+
_BACKOFF_JITTER_MAX = 0.25
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Client:
|
|
21
|
+
"""Thin HTTP client for the SellerClaw Agent API. Shared between CLI and (future) MCP server."""
|
|
22
|
+
|
|
23
|
+
base_url: str
|
|
24
|
+
token: str | None
|
|
25
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS
|
|
26
|
+
_http: httpx.Client | None = field(default=None, init=False, repr=False)
|
|
27
|
+
|
|
28
|
+
def __post_init__(self) -> None:
|
|
29
|
+
headers = {"Accept": "application/json"}
|
|
30
|
+
if self.token:
|
|
31
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
32
|
+
self._http = httpx.Client(
|
|
33
|
+
base_url=self.base_url,
|
|
34
|
+
headers=headers,
|
|
35
|
+
timeout=self.timeout,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def from_env(cls) -> Client:
|
|
40
|
+
"""Build a Client from Config.load() results."""
|
|
41
|
+
from sellerclaw_cli import _config
|
|
42
|
+
|
|
43
|
+
cfg = _config.load()
|
|
44
|
+
return cls(base_url=cfg.api_url, token=cfg.token)
|
|
45
|
+
|
|
46
|
+
def request(
|
|
47
|
+
self,
|
|
48
|
+
method: str,
|
|
49
|
+
path: str,
|
|
50
|
+
*,
|
|
51
|
+
params: dict[str, Any] | None = None,
|
|
52
|
+
json: Any = None,
|
|
53
|
+
) -> Any:
|
|
54
|
+
"""Execute an HTTP request and return the parsed JSON body.
|
|
55
|
+
|
|
56
|
+
Raises AuthError (401/403), ApiError (other 4xx), ServerError (5xx after retries),
|
|
57
|
+
NetworkError (timeout/connection) — never raises httpx exceptions directly.
|
|
58
|
+
"""
|
|
59
|
+
assert self._http is not None # noqa: S101 — invariant from __post_init__
|
|
60
|
+
last_transport_error: httpx.HTTPError | None = None
|
|
61
|
+
|
|
62
|
+
for attempt in range(MAX_RETRIES):
|
|
63
|
+
is_last = attempt == MAX_RETRIES - 1
|
|
64
|
+
try:
|
|
65
|
+
response = self._http.request(method, path, params=params, json=json)
|
|
66
|
+
except (httpx.ConnectError, httpx.TimeoutException, httpx.ReadError, httpx.WriteError) as exc:
|
|
67
|
+
last_transport_error = exc
|
|
68
|
+
if is_last:
|
|
69
|
+
raise NetworkError(str(exc) or exc.__class__.__name__) from exc
|
|
70
|
+
time.sleep(_backoff_seconds(attempt))
|
|
71
|
+
continue
|
|
72
|
+
except httpx.HTTPError as exc:
|
|
73
|
+
# Anything else from httpx we consider a network-ish failure, but don't retry — it's unknown.
|
|
74
|
+
raise NetworkError(str(exc) or exc.__class__.__name__) from exc
|
|
75
|
+
|
|
76
|
+
status = response.status_code
|
|
77
|
+
|
|
78
|
+
if 200 <= status < 300:
|
|
79
|
+
return _decode_body(response)
|
|
80
|
+
|
|
81
|
+
if status in RETRY_STATUS_CODES and not is_last:
|
|
82
|
+
time.sleep(_retry_delay(response, attempt))
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
raise _error_for_response(response)
|
|
86
|
+
|
|
87
|
+
# Loop exhausted only via transport errors; the `raise NetworkError` above should have fired.
|
|
88
|
+
raise NetworkError(str(last_transport_error) if last_transport_error else "exhausted retries")
|
|
89
|
+
|
|
90
|
+
def close(self) -> None:
|
|
91
|
+
if self._http is not None:
|
|
92
|
+
self._http.close()
|
|
93
|
+
self._http = None
|
|
94
|
+
|
|
95
|
+
def __enter__(self) -> Client:
|
|
96
|
+
return self
|
|
97
|
+
|
|
98
|
+
def __exit__(self, *_: object) -> None:
|
|
99
|
+
self.close()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _decode_body(response: httpx.Response) -> Any:
|
|
103
|
+
if not response.content:
|
|
104
|
+
return None
|
|
105
|
+
try:
|
|
106
|
+
return response.json()
|
|
107
|
+
except ValueError:
|
|
108
|
+
return response.text
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _error_for_response(response: httpx.Response) -> Exception:
|
|
112
|
+
status = response.status_code
|
|
113
|
+
message, details = _parse_error_body(response)
|
|
114
|
+
|
|
115
|
+
if status in (401, 403):
|
|
116
|
+
return AuthError(message, status=status, details=details)
|
|
117
|
+
if 400 <= status < 500:
|
|
118
|
+
return ApiError(message, status=status, details=details)
|
|
119
|
+
if 500 <= status < 600:
|
|
120
|
+
return ServerError(message, status=status, details=details)
|
|
121
|
+
return ApiError(message or f"unexpected status {status}", status=status, details=details)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _parse_error_body(response: httpx.Response) -> tuple[str, dict | None]:
|
|
125
|
+
if not response.content:
|
|
126
|
+
return f"HTTP {response.status_code}", None
|
|
127
|
+
try:
|
|
128
|
+
body = response.json()
|
|
129
|
+
except ValueError:
|
|
130
|
+
return response.text or f"HTTP {response.status_code}", None
|
|
131
|
+
|
|
132
|
+
if isinstance(body, dict):
|
|
133
|
+
message = (
|
|
134
|
+
body.get("message")
|
|
135
|
+
or body.get("detail")
|
|
136
|
+
or body.get("error")
|
|
137
|
+
or f"HTTP {response.status_code}"
|
|
138
|
+
)
|
|
139
|
+
if not isinstance(message, str):
|
|
140
|
+
message = f"HTTP {response.status_code}"
|
|
141
|
+
return message, body
|
|
142
|
+
return f"HTTP {response.status_code}", {"body": body}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _retry_delay(response: httpx.Response, attempt: int) -> float:
|
|
146
|
+
retry_after = response.headers.get("Retry-After")
|
|
147
|
+
if retry_after is not None:
|
|
148
|
+
try:
|
|
149
|
+
return float(retry_after)
|
|
150
|
+
except ValueError:
|
|
151
|
+
pass
|
|
152
|
+
return _backoff_seconds(attempt)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _backoff_seconds(attempt: int) -> float:
|
|
156
|
+
base = min(2.0**attempt, _BACKOFF_CAP_SECONDS)
|
|
157
|
+
return base + random.uniform(0, _BACKOFF_JITTER_MAX)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tomllib
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import tomli_w
|
|
9
|
+
|
|
10
|
+
DEFAULT_API_URL = "https://api.sellerclaw.com"
|
|
11
|
+
ENV_TOKEN = "SELLERCLAW_TOKEN"
|
|
12
|
+
ENV_API_URL = "SELLERCLAW_API_URL"
|
|
13
|
+
ENV_XDG_CONFIG_HOME = "XDG_CONFIG_HOME"
|
|
14
|
+
|
|
15
|
+
_CONFIG_DIR = "sellerclaw"
|
|
16
|
+
_CONFIG_FILE = "config.toml"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Config:
|
|
21
|
+
api_url: str
|
|
22
|
+
token: str | None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def config_path() -> Path:
|
|
26
|
+
"""Return the absolute path to the config.toml file, respecting XDG_CONFIG_HOME."""
|
|
27
|
+
xdg = os.environ.get(ENV_XDG_CONFIG_HOME)
|
|
28
|
+
base = Path(xdg) if xdg else Path.home() / ".config"
|
|
29
|
+
return base / _CONFIG_DIR / _CONFIG_FILE
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load() -> Config:
|
|
33
|
+
"""Resolve config from env > config file > defaults."""
|
|
34
|
+
file_values = _read_file()
|
|
35
|
+
api_url = os.environ.get(ENV_API_URL) or file_values.get("api_url") or DEFAULT_API_URL
|
|
36
|
+
token = os.environ.get(ENV_TOKEN) or file_values.get("token") or None
|
|
37
|
+
return Config(api_url=api_url, token=token)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def save_token(token: str) -> None:
|
|
41
|
+
"""Persist the token to config.toml with 0600 permissions. Preserves any existing api_url."""
|
|
42
|
+
values = _read_file()
|
|
43
|
+
values["token"] = token
|
|
44
|
+
_write_file(values)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def clear_token() -> None:
|
|
48
|
+
"""Remove the token key from config.toml. No-op if file or key is missing."""
|
|
49
|
+
path = config_path()
|
|
50
|
+
if not path.exists():
|
|
51
|
+
return
|
|
52
|
+
values = _read_file()
|
|
53
|
+
if "token" not in values:
|
|
54
|
+
return
|
|
55
|
+
values.pop("token", None)
|
|
56
|
+
_write_file(values)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _read_file() -> dict[str, str]:
|
|
60
|
+
path = config_path()
|
|
61
|
+
if not path.exists():
|
|
62
|
+
return {}
|
|
63
|
+
try:
|
|
64
|
+
with path.open("rb") as fh:
|
|
65
|
+
parsed = tomllib.load(fh)
|
|
66
|
+
except tomllib.TOMLDecodeError:
|
|
67
|
+
return {}
|
|
68
|
+
return {k: v for k, v in parsed.items() if isinstance(v, str)}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _write_file(values: dict[str, str]) -> None:
|
|
72
|
+
path = config_path()
|
|
73
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
with path.open("wb") as fh:
|
|
75
|
+
tomli_w.dump(values, fh)
|
|
76
|
+
# chmod after write — trade-off: tiny window where the file is world-readable on permissive umasks,
|
|
77
|
+
# but avoids platform-specific os.open(..., O_CREAT, 0o600) dance. Acceptable for a CLI.
|
|
78
|
+
try:
|
|
79
|
+
path.chmod(0o600)
|
|
80
|
+
except OSError:
|
|
81
|
+
# Non-POSIX filesystems may not support chmod; silently skip.
|
|
82
|
+
pass
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CliError(Exception):
|
|
5
|
+
"""Base class for all CLI-raised errors that should be translated into the structured stderr contract."""
|
|
6
|
+
|
|
7
|
+
exit_code: int = 1
|
|
8
|
+
code: str = "error"
|
|
9
|
+
|
|
10
|
+
def __init__(self, message: str, *, status: int | None = None, details: dict | None = None) -> None:
|
|
11
|
+
super().__init__(message)
|
|
12
|
+
self.message = message
|
|
13
|
+
self.status = status
|
|
14
|
+
self.details = details
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class UserInputError(CliError):
|
|
18
|
+
exit_code = 1
|
|
19
|
+
code = "user_error"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ApiError(CliError):
|
|
23
|
+
exit_code = 1
|
|
24
|
+
code = "api_error"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AuthError(CliError):
|
|
28
|
+
exit_code = 3
|
|
29
|
+
code = "auth_error"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ServerError(CliError):
|
|
33
|
+
exit_code = 2
|
|
34
|
+
code = "server_error"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class NetworkError(CliError):
|
|
38
|
+
exit_code = 2
|
|
39
|
+
code = "network_error"
|
|
File without changes
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from typing import IO, Any
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from sellerclaw_cli._errors import AuthError, CliError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OutputFormat(StrEnum):
|
|
14
|
+
JSON = "json"
|
|
15
|
+
PRETTY = "pretty"
|
|
16
|
+
YAML = "yaml"
|
|
17
|
+
TABLE = "table"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
AUTH_HINT = "Run `sellerclaw auth login` to authenticate."
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def print_ok(
|
|
24
|
+
data: Any,
|
|
25
|
+
*,
|
|
26
|
+
fmt: OutputFormat = OutputFormat.JSON,
|
|
27
|
+
stdout: IO[str] | None = None,
|
|
28
|
+
) -> int:
|
|
29
|
+
"""Serialize a successful response and write it to stdout. Returns exit code 0."""
|
|
30
|
+
out = stdout if stdout is not None else sys.stdout
|
|
31
|
+
envelope = {"data": data}
|
|
32
|
+
|
|
33
|
+
if fmt is OutputFormat.JSON:
|
|
34
|
+
out.write(json.dumps(envelope, separators=(",", ":"), ensure_ascii=False) + "\n")
|
|
35
|
+
elif fmt is OutputFormat.PRETTY:
|
|
36
|
+
out.write(json.dumps(envelope, indent=2, ensure_ascii=False) + "\n")
|
|
37
|
+
elif fmt is OutputFormat.YAML:
|
|
38
|
+
out.write(yaml.safe_dump(envelope, sort_keys=False, allow_unicode=True))
|
|
39
|
+
elif fmt is OutputFormat.TABLE:
|
|
40
|
+
out.write(_format_table(data))
|
|
41
|
+
else: # pragma: no cover — enum exhaustiveness
|
|
42
|
+
raise ValueError(f"unsupported format: {fmt}")
|
|
43
|
+
|
|
44
|
+
return 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def print_error(
|
|
48
|
+
error: CliError,
|
|
49
|
+
*,
|
|
50
|
+
stderr: IO[str] | None = None,
|
|
51
|
+
) -> int:
|
|
52
|
+
"""Serialize a CliError as compact JSON to stderr. Returns the error's exit code."""
|
|
53
|
+
err_out = stderr if stderr is not None else sys.stderr
|
|
54
|
+
|
|
55
|
+
payload: dict[str, Any] = {"code": error.code, "message": error.message}
|
|
56
|
+
if error.status is not None:
|
|
57
|
+
payload["status"] = error.status
|
|
58
|
+
if error.details is not None:
|
|
59
|
+
payload["details"] = error.details
|
|
60
|
+
if isinstance(error, AuthError):
|
|
61
|
+
payload["hint"] = AUTH_HINT
|
|
62
|
+
|
|
63
|
+
envelope = {"error": payload}
|
|
64
|
+
err_out.write(json.dumps(envelope, separators=(",", ":"), ensure_ascii=False) + "\n")
|
|
65
|
+
return error.exit_code
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _format_table(data: Any) -> str:
|
|
69
|
+
"""Cheap ASCII table for a list of flat dicts; falls back to pretty JSON otherwise."""
|
|
70
|
+
if isinstance(data, list) and data and all(isinstance(row, dict) for row in data):
|
|
71
|
+
columns = list(data[0].keys())
|
|
72
|
+
rows = [[str(row.get(c, "")) for c in columns] for row in data]
|
|
73
|
+
widths = [max(len(c), *(len(r[i]) for r in rows)) for i, c in enumerate(columns)]
|
|
74
|
+
sep = " "
|
|
75
|
+
lines = [sep.join(c.ljust(w) for c, w in zip(columns, widths, strict=True))]
|
|
76
|
+
lines.append(sep.join("-" * w for w in widths))
|
|
77
|
+
lines.extend(sep.join(r[i].ljust(widths[i]) for i in range(len(columns))) for r in rows)
|
|
78
|
+
return "\n".join(lines) + "\n"
|
|
79
|
+
return json.dumps({"data": data}, indent=2, ensure_ascii=False) + "\n"
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from sellerclaw_cli._client import Client
|
|
11
|
+
from sellerclaw_cli._errors import CliError, UserInputError
|
|
12
|
+
from sellerclaw_cli._output import OutputFormat, print_error, print_ok
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run_operation(
|
|
16
|
+
ctx: typer.Context,
|
|
17
|
+
method: str,
|
|
18
|
+
path: str,
|
|
19
|
+
*,
|
|
20
|
+
params: dict[str, Any] | None = None,
|
|
21
|
+
json_body: Any = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Execute an API call and print its result in the user-selected format.
|
|
24
|
+
|
|
25
|
+
On any CliError, prints the structured error to stderr and exits with the mapped code —
|
|
26
|
+
generated commands should never need their own try/except.
|
|
27
|
+
"""
|
|
28
|
+
try:
|
|
29
|
+
with Client.from_env() as client:
|
|
30
|
+
result = client.request(method, path, params=params, json=json_body)
|
|
31
|
+
except CliError as err:
|
|
32
|
+
code = print_error(err)
|
|
33
|
+
raise typer.Exit(code=code) from err
|
|
34
|
+
print_ok(result, fmt=_format_from_ctx(ctx))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_json_body(arg: str | None) -> Any:
|
|
38
|
+
"""Parse the --json-body flag: literal JSON, '@-' for stdin, or '@/path' for a file."""
|
|
39
|
+
if arg is None:
|
|
40
|
+
return None
|
|
41
|
+
if arg == "@-":
|
|
42
|
+
return _decode_json(sys.stdin.read(), source="stdin")
|
|
43
|
+
if arg.startswith("@"):
|
|
44
|
+
path = Path(arg[1:]).expanduser()
|
|
45
|
+
if not path.exists():
|
|
46
|
+
raise UserInputError(f"--json-body file not found: {path}")
|
|
47
|
+
return _decode_json(path.read_text(), source=str(path))
|
|
48
|
+
return _decode_json(arg, source="--json-body")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def emit_error(err: CliError) -> None:
|
|
52
|
+
"""Write a CliError to stderr and raise typer.Exit with its mapped code."""
|
|
53
|
+
code = print_error(err)
|
|
54
|
+
raise typer.Exit(code=code)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _format_from_ctx(ctx: typer.Context) -> OutputFormat:
|
|
58
|
+
if ctx.obj is None:
|
|
59
|
+
return OutputFormat.JSON
|
|
60
|
+
return ctx.obj.get("format", OutputFormat.JSON)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _decode_json(text: str, *, source: str) -> Any:
|
|
64
|
+
try:
|
|
65
|
+
return json.loads(text)
|
|
66
|
+
except json.JSONDecodeError as err:
|
|
67
|
+
raise UserInputError(
|
|
68
|
+
f"invalid JSON from {source}: {err.msg} (line {err.lineno}, col {err.colno})"
|
|
69
|
+
) from err
|