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.
Files changed (42) hide show
  1. sellerclaw_cli/__init__.py +10 -0
  2. sellerclaw_cli/__main__.py +6 -0
  3. sellerclaw_cli/_auth.py +78 -0
  4. sellerclaw_cli/_client.py +157 -0
  5. sellerclaw_cli/_config.py +82 -0
  6. sellerclaw_cli/_errors.py +39 -0
  7. sellerclaw_cli/_generated/.gitkeep +0 -0
  8. sellerclaw_cli/_output.py +79 -0
  9. sellerclaw_cli/_runtime.py +69 -0
  10. sellerclaw_cli/_spec.json +21413 -0
  11. sellerclaw_cli/_spec.py +70 -0
  12. sellerclaw_cli/cli.py +51 -0
  13. sellerclaw_cli/commands/__init__.py +1 -0
  14. sellerclaw_cli/commands/_auth_cli.py +111 -0
  15. sellerclaw_cli/commands/_generic.py +94 -0
  16. sellerclaw_cli/commands/_index.py +28 -0
  17. sellerclaw_cli/commands/agent_auth.py +48 -0
  18. sellerclaw_cli/commands/agent_chat.py +40 -0
  19. sellerclaw_cli/commands/agent_connection.py +79 -0
  20. sellerclaw_cli/commands/agent_context.py +46 -0
  21. sellerclaw_cli/commands/agent_goals.py +281 -0
  22. sellerclaw_cli/commands/agent_hooks.py +28 -0
  23. sellerclaw_cli/commands/agent_orders.py +52 -0
  24. sellerclaw_cli/commands/agent_products.py +51 -0
  25. sellerclaw_cli/commands/agent_sales_channels.py +39 -0
  26. sellerclaw_cli/commands/ebay_listings.py +53 -0
  27. sellerclaw_cli/commands/ebay_orders.py +48 -0
  28. sellerclaw_cli/commands/ebay_shop.py +73 -0
  29. sellerclaw_cli/commands/facebook_ads.py +225 -0
  30. sellerclaw_cli/commands/google_ads.py +183 -0
  31. sellerclaw_cli/commands/listing_sync.py +27 -0
  32. sellerclaw_cli/commands/research_catalog.py +27 -0
  33. sellerclaw_cli/commands/research_seo.py +126 -0
  34. sellerclaw_cli/commands/research_social.py +158 -0
  35. sellerclaw_cli/commands/research_trends.py +104 -0
  36. sellerclaw_cli/commands/research_web_search.py +27 -0
  37. sellerclaw_cli/commands/stores.py +533 -0
  38. sellerclaw_cli/commands/suppliers.py +171 -0
  39. sellerclaw_cli-0.0.0.dist-info/METADATA +537 -0
  40. sellerclaw_cli-0.0.0.dist-info/RECORD +42 -0
  41. sellerclaw_cli-0.0.0.dist-info/WHEEL +4 -0
  42. sellerclaw_cli-0.0.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,10 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("sellerclaw-cli")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.0.0+local"
9
+
10
+ __all__ = ["__version__"]
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from sellerclaw_cli.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
@@ -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