selltostate-cli 0.1.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.
@@ -0,0 +1,70 @@
1
+ Metadata-Version: 2.4
2
+ Name: selltostate-cli
3
+ Version: 0.1.0
4
+ Summary: selltostate: official CLI for the Sell to State government-contracts data API (REST/MCP parity).
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://www.selltostate.com/cli/
7
+ Project-URL: Source, https://github.com/getbeton/selltostate-cli
8
+ Keywords: selltostate,government,tenders,procurement,cli
9
+ Classifier: Environment :: Console
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=3.9
13
+ Description-Content-Type: text/markdown
14
+
15
+ # `selltostate-cli` — `selltostate`
16
+
17
+ Official command-line client for the **Sell to State** government-contracts data API
18
+ (`app.selltostate.com`). Python port of the npm package
19
+ [`@selltostate/cli`](https://www.npmjs.com/package/@selltostate/cli): the same commands,
20
+ flags, output and exit codes. Standard library only, Python 3.9+.
21
+
22
+ ```bash
23
+ pipx install selltostate-cli # or: pip install selltostate-cli / uv tool install selltostate-cli
24
+ selltostate auth login # prompts for the key, checks it with /whoami, saves it
25
+ selltostate search "IT consulting" --country singapore --year-min 2026 --format json
26
+ ```
27
+
28
+ Get a key at `https://app.selltostate.com/dashboard` (Settings -> API keys).
29
+
30
+ ## Auth and configuration
31
+
32
+ The CLI sends a dashboard API key as the `x-api-key` header. Resolution order:
33
+
34
+ 1. `--api-key` / `--api-url` flags
35
+ 2. `STS_API_KEY` / `STS_API_URL` environment variables
36
+ 3. `~/.config/selltostate/config.json` (written by `auth login`, mode `0600`; honours `XDG_CONFIG_HOME`)
37
+ 4. Default API URL `https://app.selltostate.com`
38
+
39
+ The npm and the Python CLI share this config file.
40
+
41
+ ## Commands
42
+
43
+ | Command | Endpoint |
44
+ | --- | --- |
45
+ | `whoami` | `GET /api/v1/whoami` |
46
+ | `search <q> [filters]` | `GET /api/v1/search` |
47
+ | `awards [filters]` | `GET /api/v1/awards` |
48
+ | `get buyer\|supplier\|tender\|sector <id>` | `GET /api/v1/{buyers\|suppliers\|tenders\|sectors}/<id>` (tender: `award_id`) |
49
+ | `sectors`, `suppliers [--limit]`, `buyers [--limit]` | `GET /api/v1/{sectors\|suppliers\|buyers}` |
50
+ | `lists ls\|create\|show\|add\|rm\|rename\|delete` | `/api/v1/lists*` |
51
+ | `saved-searches ls\|save\|show\|rename\|delete` | `/api/v1/saved-searches*` |
52
+ | `export [filters] [-o file]` | `GET /api/v1/awards.csv` |
53
+ | `price` | `GET /api/v1/price` (no auth) |
54
+ | `pay [--token --customer]` | `POST /api/v1/pay` |
55
+
56
+ `--format table|json|csv` (default `table`; `--json` = `--format json`). Run
57
+ `selltostate <command> --help` for the flags of one command.
58
+
59
+ ## Exit codes
60
+
61
+ `0` success · `1` error · `2` unauthorized (401) · `3` payment required (402) · `4` rate limited (429) · `64` usage error
62
+
63
+ ## Development
64
+
65
+ This package lives in the `data-atlas` monorepo next to `packages/cli`. Keep both in lockstep.
66
+
67
+ ```bash
68
+ python -m unittest discover -s tests # no network; compares --help with packages/cli/dist when it is built
69
+ uv build # dist/selltostate_cli-<version>-py3-none-any.whl
70
+ ```
@@ -0,0 +1,6 @@
1
+ selltostate_cli.py,sha256=X2O5VxQx2WbvnvC3g6fUVUpwYpcNCCB5i3Y2ED923kM,41344
2
+ selltostate_cli-0.1.0.dist-info/METADATA,sha256=3nUwAPMVkTmcQCeXQD_BF8RRd3j4IMxxNEi3znfPd0Y,2994
3
+ selltostate_cli-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
4
+ selltostate_cli-0.1.0.dist-info/entry_points.txt,sha256=BOJDddCUFfm1Sq8A0Qgcfj9ZXFXYps95qqqEwyy873I,53
5
+ selltostate_cli-0.1.0.dist-info/top_level.txt,sha256=8DjHuPhMtnBhTay8BnDtfHBICUxZmcvC9PXZbafJsFw,16
6
+ selltostate_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ selltostate = selltostate_cli:main
@@ -0,0 +1 @@
1
+ selltostate_cli
selltostate_cli.py ADDED
@@ -0,0 +1,1088 @@
1
+ """selltostate: official CLI for the Sell to State government-contracts data API.
2
+
3
+ Python port of packages/cli (npm @selltostate/cli). Same commands, flags, output
4
+ and exit codes; stdlib only. Every data command maps 1:1 to a REST /api/v1
5
+ endpoint and sends the dashboard key as x-api-key. A 402 prints the agent-native
6
+ payment_link; a 401 points at `auth login`. Keep in lockstep with packages/cli.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import getpass
12
+ import json
13
+ import math
14
+ import os
15
+ import re
16
+ import sys
17
+ import urllib.error
18
+ import urllib.parse
19
+ import urllib.request
20
+
21
+ VERSION = "0.1.0"
22
+ BIN = "selltostate"
23
+ DEFAULT_API_URL = "https://app.selltostate.com"
24
+
25
+ # Exit codes (same as the npm CLI).
26
+ EXIT_ERROR, EXIT_UNAUTHORIZED, EXIT_PAYMENT, EXIT_RATE_LIMIT, EXIT_USAGE = 1, 2, 3, 4, 64
27
+
28
+
29
+ class Exit(Exception):
30
+ """Raised to end the command with an exit code (main() turns it into sys.exit)."""
31
+
32
+ def __init__(self, code: int):
33
+ super().__init__(code)
34
+ self.code = code
35
+
36
+
37
+ # ── Output ───────────────────────────────────────────────────────────────────
38
+ def out(data) -> None:
39
+ s = data if isinstance(data, str) else dumps(data)
40
+ sys.stdout.write(s if s.endswith("\n") else s + "\n")
41
+
42
+
43
+ def err(message: str) -> None:
44
+ sys.stderr.write(f"{BIN}: {message}\n")
45
+
46
+
47
+ def dumps(data) -> str:
48
+ """JSON.stringify(data, null, 2)."""
49
+ return json.dumps(data, indent=2, ensure_ascii=False)
50
+
51
+
52
+ # ── Config (flag -> env -> ~/.config/selltostate/config.json -> default) ─────
53
+ def config_dir() -> str:
54
+ xdg = (os.environ.get("XDG_CONFIG_HOME") or "").strip()
55
+ return os.path.join(xdg or os.path.join(os.path.expanduser("~"), ".config"), "selltostate")
56
+
57
+
58
+ def config_path() -> str:
59
+ return os.path.join(config_dir(), "config.json")
60
+
61
+
62
+ def read_stored_config() -> dict:
63
+ try:
64
+ with open(config_path(), encoding="utf-8") as f:
65
+ parsed = json.load(f)
66
+ return parsed if isinstance(parsed, dict) else {}
67
+ except (OSError, ValueError):
68
+ # A missing or corrupt config file must never crash the CLI.
69
+ return {}
70
+
71
+
72
+ def write_stored_config(patch: dict) -> dict:
73
+ """Merge patch into the config file. It holds the API key: file 0600, dir 0700."""
74
+ d = config_dir()
75
+ os.makedirs(d, exist_ok=True)
76
+ try:
77
+ os.chmod(d, 0o700)
78
+ except OSError:
79
+ pass
80
+ merged = {**read_stored_config(), **patch}
81
+ merged = {k: v for k, v in merged.items() if v}
82
+ path = config_path()
83
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
84
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
85
+ f.write(json.dumps(merged, indent=2) + "\n")
86
+ try:
87
+ os.chmod(path, 0o600)
88
+ except OSError:
89
+ pass
90
+ return merged
91
+
92
+
93
+ def resolve_config(api_key: str | None = None, api_url: str | None = None) -> dict:
94
+ stored = read_stored_config()
95
+ key, key_src = None, "none"
96
+ for value, src in ((api_key, "flag"), (os.environ.get("STS_API_KEY"), "env"), (stored.get("apiKey"), "file")):
97
+ if value:
98
+ key, key_src = value, src
99
+ break
100
+ url, url_src = DEFAULT_API_URL, "default"
101
+ for value, src in ((api_url, "flag"), (os.environ.get("STS_API_URL"), "env"), (stored.get("apiUrl"), "file")):
102
+ if value:
103
+ url, url_src = value, src
104
+ break
105
+ return {
106
+ "apiUrl": url.strip().rstrip("/"),
107
+ "apiKey": (key or "").strip() or None,
108
+ "source": {"apiKey": key_src, "apiUrl": url_src},
109
+ }
110
+
111
+
112
+ # ── REST client ──────────────────────────────────────────────────────────────
113
+ class ApiError(Exception):
114
+ """A non-2xx response, with the parsed body when JSON."""
115
+
116
+ def __init__(self, status: int, message: str, body=None):
117
+ super().__init__(message)
118
+ self.status = status
119
+ self.body = body
120
+
121
+
122
+ def _query_value(v) -> str:
123
+ if isinstance(v, bool):
124
+ return "true" if v else "false"
125
+ if isinstance(v, float) and v.is_integer():
126
+ return str(int(v))
127
+ return str(v)
128
+
129
+
130
+ def build_query(query: dict | None) -> str:
131
+ pairs = []
132
+ for key, value in (query or {}).items():
133
+ for v in value if isinstance(value, list) else [value]:
134
+ if v is None or v == "":
135
+ continue
136
+ pairs.append((key, _query_value(v)))
137
+ return "?" + urllib.parse.urlencode(pairs) if pairs else ""
138
+
139
+
140
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
141
+ def redirect_request(self, *args, **kwargs): # noqa: D401 - urllib hook
142
+ return None
143
+
144
+
145
+ def _default_open(req: urllib.request.Request, follow: bool):
146
+ opener = urllib.request.build_opener() if follow else urllib.request.build_opener(_NoRedirect)
147
+ return opener.open(req, timeout=60)
148
+
149
+
150
+ def _quote(segment: str) -> str:
151
+ """encodeURIComponent."""
152
+ return urllib.parse.quote(str(segment), safe="!'()*-._~")
153
+
154
+
155
+ class ApiClient:
156
+ """One method per documented machine endpoint. `opener(req, follow)` is injectable for tests."""
157
+
158
+ def __init__(self, api_url: str, api_key: str | None, user_agent: str = "selltostate-cli", opener=None):
159
+ self.api_url = (api_url or DEFAULT_API_URL).rstrip("/")
160
+ self.api_key = api_key
161
+ self.user_agent = user_agent
162
+ self.opener = opener or _default_open
163
+
164
+ def request(self, method: str, path: str, query=None, body=None, raw_text=False, no_follow=False):
165
+ url = f"{self.api_url}{path}{build_query(query)}"
166
+ headers = {"accept": "application/json", "user-agent": self.user_agent}
167
+ if self.api_key:
168
+ headers["x-api-key"] = self.api_key
169
+ data = None
170
+ if body is not None:
171
+ headers["content-type"] = "application/json"
172
+ data = json.dumps(body).encode()
173
+ req = urllib.request.Request(url, data=data, method=method, headers=headers)
174
+
175
+ try:
176
+ res = self.opener(req, not no_follow)
177
+ try:
178
+ status, res_headers, payload = res.status, res.headers, res.read()
179
+ finally:
180
+ getattr(res, "close", lambda: None)()
181
+ except urllib.error.HTTPError as e:
182
+ with e:
183
+ status, res_headers, payload = e.code, e.headers, e.read()
184
+ except (urllib.error.URLError, OSError) as e:
185
+ raise RuntimeError(f"network error talking to {url}: {getattr(e, 'reason', e)}") from e
186
+
187
+ # The awards.csv route redirects to /checkout (no subscription) or /auth
188
+ # (bad key). Map it to the same 402/401 contract the JSON endpoints use,
189
+ # so the CSV file never gets an HTML login page.
190
+ if no_follow and 300 <= status < 400:
191
+ location = res_headers.get("location") or ""
192
+ code = 401 if re.search(r"/auth\b", location) else 402
193
+ message = (
194
+ "export requires a valid API key (redirected to /auth)"
195
+ if code == 401
196
+ else "export requires an active subscription (redirected to /checkout)"
197
+ )
198
+ raise ApiError(code, message, {"error": "unauthorized" if code == 401 else "payment_required", "redirect": location})
199
+
200
+ text = payload.decode("utf-8", errors="replace")
201
+ is_json = "application/json" in (res_headers.get("content-type") or "")
202
+
203
+ if not 200 <= status < 300:
204
+ parsed = text
205
+ if is_json:
206
+ try:
207
+ parsed = json.loads(text)
208
+ except ValueError:
209
+ pass
210
+ message = parsed.get("error") if isinstance(parsed, dict) and parsed.get("error") else f"{method} {path} -> {status}"
211
+ raise ApiError(status, str(message), parsed)
212
+
213
+ if raw_text:
214
+ return text
215
+ if status == 204:
216
+ return None
217
+ return json.loads(text) if is_json and text else text
218
+
219
+ # Identity, pricing, payment
220
+ def whoami(self):
221
+ return self.request("GET", "/api/v1/whoami")
222
+
223
+ def price(self):
224
+ return self.request("GET", "/api/v1/price")
225
+
226
+ def pay(self, body=None):
227
+ return self.request("POST", "/api/v1/pay", body=body or {})
228
+
229
+ # Search and awards
230
+ def search(self, query):
231
+ return self.request("GET", "/api/v1/search", query=query)
232
+
233
+ def awards(self, query):
234
+ return self.request("GET", "/api/v1/awards", query=query)
235
+
236
+ def awards_csv(self, query):
237
+ return self.request("GET", "/api/v1/awards.csv", query=query, raw_text=True, no_follow=True)
238
+
239
+ # Reference lists
240
+ def sectors(self):
241
+ return self.request("GET", "/api/v1/sectors")
242
+
243
+ def suppliers(self, query=None):
244
+ return self.request("GET", "/api/v1/suppliers", query=query)
245
+
246
+ def buyers(self, query=None):
247
+ return self.request("GET", "/api/v1/buyers", query=query)
248
+
249
+ # Entity detail
250
+ def get_buyer(self, slug, query=None):
251
+ return self.request("GET", f"/api/v1/buyers/{_quote(slug)}", query=query)
252
+
253
+ def get_supplier(self, slug, query=None):
254
+ return self.request("GET", f"/api/v1/suppliers/{_quote(slug)}", query=query)
255
+
256
+ def get_tender(self, tender_id):
257
+ return self.request("GET", f"/api/v1/tenders/{_quote(tender_id)}")
258
+
259
+ def get_sector(self, slug, query=None):
260
+ return self.request("GET", f"/api/v1/sectors/{_quote(slug)}", query=query)
261
+
262
+ # Lists (owner-scoped, x-api-key only)
263
+ def list_lists(self):
264
+ return self.request("GET", "/api/v1/lists")
265
+
266
+ def get_list(self, list_id, query=None):
267
+ return self.request("GET", f"/api/v1/lists/{_quote(list_id)}", query=query)
268
+
269
+ def create_list(self, name):
270
+ return self.request("POST", "/api/v1/lists", body={"name": name})
271
+
272
+ def rename_list(self, list_id, name):
273
+ return self.request("PATCH", f"/api/v1/lists/{_quote(list_id)}", body={"name": name})
274
+
275
+ def delete_list(self, list_id):
276
+ return self.request("DELETE", f"/api/v1/lists/{_quote(list_id)}")
277
+
278
+ def add_list_item(self, list_id, item):
279
+ return self.request("POST", f"/api/v1/lists/{_quote(list_id)}/items", body=item)
280
+
281
+ def remove_list_item(self, list_id, tender_no):
282
+ return self.request("DELETE", f"/api/v1/lists/{_quote(list_id)}/items", body={"tender_no": tender_no})
283
+
284
+ # Saved searches
285
+ def list_saved_searches(self):
286
+ return self.request("GET", "/api/v1/saved-searches")
287
+
288
+ def get_saved_search(self, search_id):
289
+ return self.request("GET", f"/api/v1/saved-searches/{_quote(search_id)}")
290
+
291
+ def create_saved_search(self, body):
292
+ return self.request("POST", "/api/v1/saved-searches", body=body)
293
+
294
+ def rename_saved_search(self, search_id, name):
295
+ return self.request("PATCH", f"/api/v1/saved-searches/{_quote(search_id)}", body={"name": name})
296
+
297
+ def delete_saved_search(self, search_id):
298
+ return self.request("DELETE", f"/api/v1/saved-searches/{_quote(search_id)}")
299
+
300
+
301
+ # ── Format (table | json | csv) ──────────────────────────────────────────────
302
+ def parse_format(raw: str | None, json_flag: bool) -> str:
303
+ if json_flag:
304
+ return "json"
305
+ if raw is None:
306
+ return "table"
307
+ v = raw.strip().lower()
308
+ if v in ("json", "table", "csv"):
309
+ return v
310
+ raise ValueError(f'invalid --format "{raw}" (expected: table | json | csv)')
311
+
312
+
313
+ def col(key, label=None, render=None):
314
+ return (key, label or key, render)
315
+
316
+
317
+ def _js_string(v) -> str:
318
+ if isinstance(v, bool):
319
+ return "true" if v else "false"
320
+ if isinstance(v, float) and v.is_integer():
321
+ return str(int(v))
322
+ return str(v)
323
+
324
+
325
+ def _cell(row: dict, column) -> str:
326
+ key, _, render = column
327
+ raw = row.get(key)
328
+ if render:
329
+ return render(raw)
330
+ if raw is None:
331
+ return ""
332
+ if isinstance(raw, (dict, list)):
333
+ return json.dumps(raw, separators=(",", ":"), ensure_ascii=False)
334
+ return _js_string(raw)
335
+
336
+
337
+ def num(value) -> str:
338
+ """toLocaleString("en-US"): thousands separators, up to 3 decimals."""
339
+ if value is None or value == "":
340
+ return ""
341
+ try:
342
+ n = float(value)
343
+ except (TypeError, ValueError):
344
+ return str(value)
345
+ if not math.isfinite(n):
346
+ return str(value)
347
+ if n.is_integer():
348
+ return f"{int(n):,}"
349
+ return f"{n:,.3f}".rstrip("0").rstrip(".")
350
+
351
+
352
+ def truncate(value, max_len: int) -> str:
353
+ s = "" if value is None else _js_string(value)
354
+ return s[: max_len - 1] + "…" if len(s) > max_len else s
355
+
356
+
357
+ def render_table(rows: list, columns: list) -> str:
358
+ if not rows:
359
+ return "(no rows)"
360
+ headers = [c[1] for c in columns]
361
+ matrix = [[_cell(r, c) for c in columns] for r in rows]
362
+ widths = [max([len(headers[i])] + [len(m[i]) for m in matrix]) for i in range(len(columns))]
363
+ lines = [" | ".join(h.ljust(widths[i]) for i, h in enumerate(headers)), "-+-".join("-" * w for w in widths)]
364
+ lines += [" | ".join(c.ljust(widths[i]) for i, c in enumerate(m)) for m in matrix]
365
+ return "\n".join(lines)
366
+
367
+
368
+ def _csv_escape(value: str) -> str:
369
+ return '"' + value.replace('"', '""') + '"' if re.search(r'[",\r\n]', value) else value
370
+
371
+
372
+ def render_csv(rows: list, columns: list) -> str:
373
+ header = ",".join(_csv_escape(c[1]) for c in columns)
374
+ body = "\n".join(",".join(_csv_escape(_cell(r, c)) for c in columns) for r in rows)
375
+ return f"{header}\n{body}\n" if rows else f"{header}\n"
376
+
377
+
378
+ def render_tabular(fmt: str, rows: list, columns: list, raw) -> str:
379
+ if fmt == "json":
380
+ return dumps(raw)
381
+ if fmt == "csv":
382
+ return render_csv(rows, columns)
383
+ return render_table(rows, columns)
384
+
385
+
386
+ # ── Args ─────────────────────────────────────────────────────────────────────
387
+ BOOLEAN_FLAGS = {"help", "version", "json", "yes", "quiet"}
388
+ SHORT_VALUE_FLAGS = {"o": "output"}
389
+
390
+
391
+ def parse_args(argv: list) -> tuple:
392
+ """Positionals + flags: `--flag value`, `--flag=value`, boolean `--flag`, `-o value`, `-h`, `--`."""
393
+ positionals, flags = [], {}
394
+ i = 0
395
+ while i < len(argv):
396
+ tok = argv[i]
397
+ nxt = argv[i + 1] if i + 1 < len(argv) else None
398
+ if tok == "--":
399
+ positionals += argv[i + 1 :]
400
+ break
401
+ if tok.startswith("--"):
402
+ if "=" in tok:
403
+ name, value = tok[2:].split("=", 1)
404
+ flags[name] = value
405
+ elif tok[2:] in BOOLEAN_FLAGS or nxt is None or nxt.startswith("-"):
406
+ flags[tok[2:]] = True
407
+ else:
408
+ flags[tok[2:]] = nxt
409
+ i += 1
410
+ elif tok.startswith("-") and len(tok) > 1:
411
+ short = tok[1:]
412
+ if short == "h":
413
+ flags["help"] = True
414
+ elif short == "v":
415
+ flags["version"] = True
416
+ elif short in SHORT_VALUE_FLAGS:
417
+ if nxt is not None and not nxt.startswith("-"):
418
+ flags[SHORT_VALUE_FLAGS[short]] = nxt
419
+ i += 1
420
+ else:
421
+ flags[SHORT_VALUE_FLAGS[short]] = True
422
+ else:
423
+ flags[short] = True
424
+ else:
425
+ positionals.append(tok)
426
+ i += 1
427
+ return positionals, flags
428
+
429
+
430
+ def flag_str(flags: dict, *names):
431
+ for n in names:
432
+ if isinstance(flags.get(n), str):
433
+ return flags[n]
434
+ return None
435
+
436
+
437
+ def flag_bool(flags: dict, *names) -> bool:
438
+ return any(flags.get(n) is True or flags.get(n) == "true" for n in names)
439
+
440
+
441
+ def flag_num(flags: dict, *names):
442
+ s = flag_str(flags, *names)
443
+ if s is None:
444
+ return None
445
+ try:
446
+ n = float(s.strip() or "0")
447
+ except ValueError:
448
+ return None
449
+ return n if math.isfinite(n) else None
450
+
451
+
452
+ def flag_list(flags: dict, *names) -> list:
453
+ v = flag_str(flags, *names)
454
+ return [s.strip() for s in v.split(",") if s.strip()] if v else []
455
+
456
+
457
+ # ── Shared command plumbing ──────────────────────────────────────────────────
458
+ def client_for(cfg: dict) -> ApiClient:
459
+ return ApiClient(cfg["apiUrl"], cfg["apiKey"], user_agent=f"{BIN}/{VERSION}")
460
+
461
+
462
+ def require_credential(cfg: dict) -> None:
463
+ if not cfg["apiKey"]:
464
+ err(f"no API key configured. Run `{BIN} auth login`, set STS_API_KEY, or pass --api-key.")
465
+ raise Exit(EXIT_UNAUTHORIZED)
466
+
467
+
468
+ def explain_api_error(e: Exception):
469
+ """Map an ApiError to a hint + exit code. Always raises Exit."""
470
+ if isinstance(e, ApiError):
471
+ if e.status == 401:
472
+ err(f"unauthorized — run `{BIN} auth login` to set a valid API key.")
473
+ raise Exit(EXIT_UNAUTHORIZED)
474
+ if e.status == 402:
475
+ body = e.body if isinstance(e.body, dict) else {}
476
+ err("payment required — no active subscription.")
477
+ if body.get("price"):
478
+ err(f"price: {body['price']}" + (f" {body['currency']}" if body.get("currency") else ""))
479
+ if body.get("payment_link"):
480
+ err(f"pay (human): {body['payment_link']}")
481
+ endpoint = (body.get("machine_payment") or {}).get("endpoint")
482
+ if endpoint:
483
+ err(f"pay (agent): POST {endpoint}")
484
+ err(f"or run `{BIN} pay` to activate.")
485
+ raise Exit(EXIT_PAYMENT)
486
+ if e.status == 429:
487
+ err("rate limited (429) — slow down and retry.")
488
+ raise Exit(EXIT_RATE_LIMIT)
489
+ err(f"API error {e.status}: {e}")
490
+ raise Exit(EXIT_ERROR)
491
+ err(str(e))
492
+ raise Exit(EXIT_ERROR)
493
+
494
+
495
+ def call(fn, *args):
496
+ try:
497
+ return fn(*args)
498
+ except (ApiError, RuntimeError) as e:
499
+ explain_api_error(e)
500
+
501
+
502
+ def fmt_of(flags: dict, default: str | None = None) -> str:
503
+ return parse_format(flag_str(flags, "format") or default, flag_bool(flags, "json"))
504
+
505
+
506
+ def usage_error(group: str, form: str):
507
+ err(f"usage: {BIN} {group} {form}")
508
+ raise Exit(EXIT_USAGE)
509
+
510
+
511
+ def pick(obj: dict, keys: list) -> str:
512
+ for k in keys:
513
+ v = obj.get(k)
514
+ if v is not None and v != "":
515
+ return json.dumps(v, separators=(",", ":")) if isinstance(v, (dict, list)) else _js_string(v)
516
+ return ""
517
+
518
+
519
+ PARTNER_COLUMNS = [
520
+ col("name", "name", lambda v: truncate(v, 40)),
521
+ col("slug"),
522
+ col("total", "total", num),
523
+ col("count", "count", num),
524
+ ]
525
+
526
+ AWARD_COLUMNS = [
527
+ col("tender_no"),
528
+ col("supplier", "supplier", lambda v: truncate(v, 30)),
529
+ col("agency", "agency", lambda v: truncate(v, 30)),
530
+ col("category", "category", lambda v: truncate(v, 20)),
531
+ col("awarded_amt", "awarded_amt", num),
532
+ col("published_at"),
533
+ col("awarded_at"),
534
+ ]
535
+
536
+ LISTS_COLUMNS = [
537
+ col("id"),
538
+ col("name", "name", lambda v: truncate(v, 40)),
539
+ col("itemCount", "tenders", num),
540
+ col("buyerCount", "buyers", num),
541
+ col("supplierCount", "suppliers", num),
542
+ ]
543
+
544
+
545
+ # ── Commands ─────────────────────────────────────────────────────────────────
546
+ WHOAMI_USAGE = """selltostate whoami — show the authenticated subject, plan, scopes and export cap
547
+
548
+ Usage: selltostate whoami [--format table|json|csv]
549
+
550
+ Calls GET /api/v1/whoami. A valid-but-unentitled key returns active:false
551
+ (pay to unlock) rather than an error; a missing/invalid key is a 401."""
552
+
553
+
554
+ def cmd_whoami(pos, flags, cfg):
555
+ require_credential(cfg)
556
+ fmt = fmt_of(flags)
557
+ me = call(client_for(cfg).whoami)
558
+ if not (flag_bool(flags, "json") or flag_str(flags, "format") is not None):
559
+ state = "active" if me.get("active") else f"inactive — run `{BIN} pay` to unlock"
560
+ out(
561
+ "\n".join(
562
+ [
563
+ f"subject: {me.get('subject')}",
564
+ f"type: {me.get('type')}",
565
+ f"plan: {me.get('plan') or '—'}",
566
+ f"status: {state}",
567
+ f"scopes: {', '.join(me.get('scopes') or []) or '—'}",
568
+ f"export cap: {_js_string(me.get('export_cap'))}",
569
+ ]
570
+ )
571
+ )
572
+ return
573
+ row = {
574
+ "subject": me.get("subject"),
575
+ "type": me.get("type"),
576
+ "active": me.get("active"),
577
+ "plan": me.get("plan") or "",
578
+ "scopes": " ".join(me.get("scopes") or []),
579
+ "export_cap": me.get("export_cap"),
580
+ }
581
+ out(render_tabular(fmt, [row], [col(k) for k in row], me))
582
+
583
+
584
+ SEARCH_USAGE = """selltostate search <query> [options] — faceted search across tenders/awards
585
+
586
+ Usage: selltostate search <query> [options]
587
+
588
+ Options:
589
+ --scope <name> Collection: tenders (default) | awards | …
590
+ --sector <s> Sector slug (comma-separated for multiple)
591
+ --country <c> Country filter
592
+ --type <t> Procurement type
593
+ --status <s> Status filter
594
+ --year-min <n> Lower bound on year
595
+ --year-max <n> Upper bound on year
596
+ --amount-min <n> Lower bound on amount
597
+ --amount-max <n> Upper bound on amount
598
+ --page <n> Page number (default 1)
599
+ --per-page <n> Results per page (1–50, default 25)
600
+ --format <fmt> table (default) | json | csv
601
+
602
+ Calls GET /api/v1/search."""
603
+
604
+
605
+ def cmd_search(pos, flags, cfg):
606
+ require_credential(cfg)
607
+ fmt = fmt_of(flags)
608
+ query = {
609
+ "q": pos[0] if pos else flag_str(flags, "q") or "",
610
+ "scope": flag_str(flags, "scope"),
611
+ "sector": flag_list(flags, "sector"),
612
+ "country": flag_str(flags, "country"),
613
+ "type": flag_str(flags, "type"),
614
+ "status": flag_str(flags, "status"),
615
+ "year_min": flag_num(flags, "year-min"),
616
+ "year_max": flag_num(flags, "year-max"),
617
+ "amount_min": flag_num(flags, "amount-min"),
618
+ "amount_max": flag_num(flags, "amount-max"),
619
+ "page": flag_num(flags, "page"),
620
+ "per_page": flag_num(flags, "per-page"),
621
+ }
622
+ res = call(client_for(cfg).search, query)
623
+ if fmt == "json":
624
+ return out(dumps(res))
625
+ body = res if isinstance(res, dict) else {}
626
+ hits = body.get("hits") if isinstance(body.get("hits"), list) else []
627
+ rows = [
628
+ {
629
+ "id": pick(h, ["tender_no", "id", "award_id", "slug"]),
630
+ "title": truncate(pick(h, ["title", "name", "description"]), 60),
631
+ "buyer": pick(h, ["buyer", "agency", "buyer_name"]),
632
+ "supplier": pick(h, ["supplier", "supplier_name"]),
633
+ "amount": num(pick(h, ["amount", "awarded_amt", "value"])),
634
+ "year": pick(h, ["year"]),
635
+ }
636
+ for h in hits
637
+ ]
638
+ out(render_tabular(fmt, rows, [col(k) for k in ("id", "title", "buyer", "supplier", "amount", "year")], res))
639
+ if fmt != "csv":
640
+ found = body.get("found", len(hits))
641
+ out(f"\nfound {_js_string(found)}, page {_js_string(body.get('page') or 1)} × {_js_string(body.get('per_page') or len(hits))}")
642
+
643
+
644
+ AWARDS_USAGE = """selltostate awards [options] — list awarded tenders (the explorer dataset)
645
+
646
+ Usage: selltostate awards [options]
647
+
648
+ Options:
649
+ --q <text> Free-text filter
650
+ --agency <name> Buyer / agency filter
651
+ --supplier <name> Supplier filter
652
+ --category <name> Category filter
653
+ --country <slug> Country slug (default singapore)
654
+ --year <n> Award year
655
+ --amount-min <n> Minimum awarded amount
656
+ --amount-max <n> Maximum awarded amount
657
+ --page <n> Page number (default 1)
658
+ --per-page <n> Rows per page (server-capped by your plan)
659
+ --format <fmt> table (default) | json | csv
660
+
661
+ Calls GET /api/v1/awards. For a bulk CSV export use `selltostate export`."""
662
+
663
+
664
+ def awards_query(flags: dict) -> dict:
665
+ return {
666
+ "q": flag_str(flags, "q"),
667
+ "agency": flag_str(flags, "agency"),
668
+ "supplier": flag_str(flags, "supplier"),
669
+ "category": flag_str(flags, "category"),
670
+ "country": flag_str(flags, "country"),
671
+ "year": flag_num(flags, "year"),
672
+ "amount_min": flag_num(flags, "amount-min"),
673
+ "amount_max": flag_num(flags, "amount-max"),
674
+ "page": flag_num(flags, "page"),
675
+ "page_size": flag_num(flags, "per-page"),
676
+ }
677
+
678
+
679
+ def cmd_awards(pos, flags, cfg):
680
+ require_credential(cfg)
681
+ fmt = fmt_of(flags)
682
+ res = call(client_for(cfg).awards, awards_query(flags))
683
+ if fmt == "json":
684
+ return out(dumps(res))
685
+ body = res if isinstance(res, dict) else {}
686
+ rows = body.get("rows") if isinstance(body.get("rows"), list) else []
687
+ out(render_tabular(fmt, rows, AWARD_COLUMNS, res))
688
+ if fmt != "csv":
689
+ total = body.get("total", len(rows))
690
+ cap = body.get("export_cap")
691
+ out(f"\n{len(rows)} rows (page {_js_string(body.get('page') or 1)}, {_js_string(total)} total, export cap {'—' if cap is None else _js_string(cap)})")
692
+
693
+
694
+ GET_USAGE = """selltostate get <kind> <slug-or-id> [options] — fetch one entity in detail
695
+
696
+ Usage: selltostate get buyer|supplier|tender|sector <slug-or-id> [options]
697
+
698
+ Options:
699
+ --page <n> Partner-table page (buyer/supplier/sector)
700
+ --kind <buyers|suppliers> Sector partner table to return (sector only)
701
+ --format <fmt> json (default) | table | csv (table/csv = partner table)
702
+
703
+ Calls GET /api/v1/{buyers|suppliers|tenders|sectors}/<id>. A missing entity is a 404."""
704
+
705
+
706
+ def cmd_get(pos, flags, cfg):
707
+ require_credential(cfg)
708
+ kind = pos[0] if pos else None
709
+ if kind not in ("buyer", "supplier", "tender", "sector"):
710
+ err(f"unknown entity kind: {kind or '(none)'}. Expected: buyer | supplier | tender | sector")
711
+ raise Exit(EXIT_USAGE)
712
+ if len(pos) < 2:
713
+ err(f"missing <slug-or-id>. Usage: selltostate get {kind} <slug-or-id>")
714
+ raise Exit(EXIT_USAGE)
715
+ ident = pos[1]
716
+ fmt = fmt_of(flags, "json")
717
+ page = flag_num(flags, "page")
718
+ client = client_for(cfg)
719
+ if kind == "buyer":
720
+ res, partner_key = call(client.get_buyer, ident, {"sup_page": page} if page else None), "suppliers"
721
+ elif kind == "supplier":
722
+ res, partner_key = call(client.get_supplier, ident, {"buyer_page": page} if page else None), "buyers"
723
+ elif kind == "sector":
724
+ sector_kind = "suppliers" if flag_str(flags, "kind") == "suppliers" else "buyers"
725
+ res, partner_key = call(client.get_sector, ident, {"kind": sector_kind, "page": page}), "partners"
726
+ else:
727
+ res, partner_key = call(client.get_tender, ident), ""
728
+ if fmt == "json":
729
+ return out(dumps(res))
730
+ if not partner_key:
731
+ err("note: tender detail has no flat partner table; emitting JSON.")
732
+ return out(dumps(res))
733
+ block = res.get(partner_key) if isinstance(res, dict) else None
734
+ rows = block.get("rows") if isinstance(block, dict) and isinstance(block.get("rows"), list) else []
735
+ out(render_tabular(fmt, rows, PARTNER_COLUMNS, res))
736
+
737
+
738
+ LISTS_USAGE = """selltostate lists <subcommand> — manage saved lists
739
+
740
+ Subcommands:
741
+ ls List every list you own (tender, buyer and supplier counts)
742
+ create <name> Create a new list
743
+ show <id> Show a list + its items (--page, --per-page)
744
+ add <id> <tender_no> Add an award (or --award-id <id>) to a list
745
+ rm <id> <tender_no> Remove an item from a list
746
+ rename <id> <name> Rename a list
747
+ delete <id> Delete a list
748
+
749
+ Options: --format table|json|csv
750
+
751
+ Calls /api/v1/lists*. Requires a dashboard API key (x-api-key)."""
752
+
753
+
754
+ def format_lists(res, fmt: str) -> str:
755
+ if fmt == "json":
756
+ return dumps(res)
757
+ lists = (res.get("lists") or []) if isinstance(res, dict) else []
758
+ return render_tabular(fmt, lists, LISTS_COLUMNS, res)
759
+
760
+
761
+ def cmd_lists(pos, flags, cfg):
762
+ require_credential(cfg)
763
+ sub, rest = (pos[0] if pos else "ls"), pos[1:]
764
+ fmt = fmt_of(flags)
765
+ client = client_for(cfg)
766
+ if sub == "ls":
767
+ return out(format_lists(call(client.list_lists), fmt))
768
+ if sub == "create":
769
+ name = " ".join(rest).strip()
770
+ if not name:
771
+ usage_error("lists", "create <name>")
772
+ return out(dumps(call(client.create_list, name)))
773
+ if sub == "show":
774
+ if not rest:
775
+ usage_error("lists", "show <id>")
776
+ res = call(client.get_list, rest[0], {"page": flag_num(flags, "page"), "page_size": flag_num(flags, "per-page")})
777
+ if fmt == "json":
778
+ return out(dumps(res))
779
+ body = res if isinstance(res, dict) else {}
780
+ items = body.get("items") if isinstance(body.get("items"), list) else []
781
+ columns = [
782
+ col("tender_no"),
783
+ col("supplier", "supplier", lambda v: truncate(v, 30)),
784
+ col("agency", "agency", lambda v: truncate(v, 30)),
785
+ col("awarded_amt", "awarded_amt", num),
786
+ ]
787
+ table = render_tabular(fmt, items, columns, res)
788
+ if fmt == "csv":
789
+ return out(table)
790
+ out(f"# {(body.get('list') or {}).get('name') or '(list)'}")
791
+ out(table)
792
+ return out(f"\n{len(items)} items (page {_js_string(body.get('page') or 1)}, {_js_string(body.get('total', len(items)))} total)")
793
+ if sub == "add":
794
+ if not rest:
795
+ usage_error("lists", "add <id> <tender_no|--award-id ID>")
796
+ award_id = flag_str(flags, "award-id")
797
+ tender_no = rest[1] if len(rest) > 1 else None
798
+ if not tender_no and not award_id:
799
+ usage_error("lists", "add <id> <tender_no> (or --award-id ID)")
800
+ item = {"tender_no": tender_no} if tender_no else {"award_id": award_id}
801
+ return out(dumps(call(client.add_list_item, rest[0], item)))
802
+ if sub == "rm":
803
+ if len(rest) < 2:
804
+ usage_error("lists", "rm <id> <tender_no>")
805
+ return out(dumps(call(client.remove_list_item, rest[0], rest[1])))
806
+ if sub == "rename":
807
+ name = " ".join(rest[1:]).strip()
808
+ if not rest or not name:
809
+ usage_error("lists", "rename <id> <name>")
810
+ return out(dumps(call(client.rename_list, rest[0], name)))
811
+ if sub == "delete":
812
+ if not rest:
813
+ usage_error("lists", "delete <id>")
814
+ return out(dumps(call(client.delete_list, rest[0])))
815
+ err(f"unknown lists subcommand: {sub}. Try: ls | create | show | add | rm | rename | delete")
816
+ raise Exit(EXIT_USAGE)
817
+
818
+
819
+ SAVED_SEARCHES_USAGE = """selltostate saved-searches <subcommand> — manage saved searches
820
+
821
+ Subcommands:
822
+ ls List every saved search you can see
823
+ save <name> [--query JSON] [--kind atlas] Save a search
824
+ show <id> Show one saved search
825
+ rename <id> <name> Rename a saved search
826
+ delete <id> Delete a saved search
827
+
828
+ Options: --format table|json|csv
829
+
830
+ Calls /api/v1/saved-searches*. Requires a dashboard API key (x-api-key)."""
831
+
832
+
833
+ def cmd_saved_searches(pos, flags, cfg):
834
+ require_credential(cfg)
835
+ sub, rest = (pos[0] if pos else "ls"), pos[1:]
836
+ fmt = fmt_of(flags)
837
+ client = client_for(cfg)
838
+ if sub == "ls":
839
+ res = call(client.list_saved_searches)
840
+ if fmt == "json":
841
+ return out(dumps(res))
842
+ searches = (res.get("searches") or []) if isinstance(res, dict) else []
843
+ return out(render_tabular(fmt, searches, [col("id"), col("name", "name", lambda v: truncate(v, 50))], res))
844
+ if sub == "save":
845
+ name = " ".join(rest).strip()
846
+ if not name:
847
+ usage_error("saved-searches", "save <name> [--query JSON] [--kind atlas]")
848
+ query_raw = flag_str(flags, "query")
849
+ query = {}
850
+ if query_raw:
851
+ try:
852
+ query = json.loads(query_raw)
853
+ except ValueError:
854
+ err(f"--query must be valid JSON (got: {query_raw})")
855
+ raise Exit(EXIT_USAGE)
856
+ body = {"name": name, "query": query}
857
+ if flag_str(flags, "kind"):
858
+ body["kind"] = flag_str(flags, "kind")
859
+ return out(dumps(call(client.create_saved_search, body)))
860
+ if sub == "show":
861
+ if not rest:
862
+ usage_error("saved-searches", "show <id>")
863
+ return out(dumps(call(client.get_saved_search, rest[0])))
864
+ if sub == "rename":
865
+ name = " ".join(rest[1:]).strip()
866
+ if not rest or not name:
867
+ usage_error("saved-searches", "rename <id> <name>")
868
+ return out(dumps(call(client.rename_saved_search, rest[0], name)))
869
+ if sub == "delete":
870
+ if not rest:
871
+ usage_error("saved-searches", "delete <id>")
872
+ return out(dumps(call(client.delete_saved_search, rest[0])))
873
+ err(f"unknown saved-searches subcommand: {sub}. Try: ls | save | show | rename | delete")
874
+ raise Exit(EXIT_USAGE)
875
+
876
+
877
+ EXPORT_USAGE = """selltostate export [filters] [-o file.csv] — export awards as CSV
878
+
879
+ Usage: selltostate export [options] [-o|--output FILE]
880
+
881
+ Filters (same as `awards`):
882
+ --q <text> Free-text filter
883
+ --agency <name> Buyer / agency filter
884
+ --supplier <name> Supplier filter
885
+ --category <name> Category filter
886
+ --country <slug> Country slug (default singapore)
887
+ --year <n> Award year
888
+ --amount-min <n> Minimum awarded amount
889
+ --amount-max <n> Maximum awarded amount
890
+
891
+ Output:
892
+ -o, --output <file> Write CSV to <file> (default: stdout)
893
+
894
+ Calls GET /api/v1/awards.csv. Row count is capped by your plan (paid 5000 / trial 20)."""
895
+
896
+
897
+ def cmd_export(pos, flags, cfg):
898
+ require_credential(cfg)
899
+ output = flag_str(flags, "output", "o")
900
+ csv_text = call(client_for(cfg).awards_csv, awards_query(flags))
901
+ if not output:
902
+ sys.stdout.write(csv_text)
903
+ return
904
+ try:
905
+ with open(output, "w", encoding="utf-8", newline="") as f:
906
+ f.write(csv_text)
907
+ except OSError as e:
908
+ err(f"could not write {output}: {e}")
909
+ raise Exit(EXIT_ERROR)
910
+ trimmed = csv_text.rstrip("\n")
911
+ err(f"wrote {max(0, len(trimmed.split(chr(10))) - 1) if trimmed else 0} rows to {output}")
912
+
913
+
914
+ def _reference(method: str, columns=None):
915
+ def handler(pos, flags, cfg):
916
+ require_credential(cfg)
917
+ fmt = fmt_of(flags)
918
+ limit = flag_num(flags, "limit")
919
+ fn = getattr(client_for(cfg), method)
920
+ res = call(fn) if method == "sectors" else call(fn, {"limit": limit} if limit else None)
921
+ if fmt == "json":
922
+ return out(dumps(res))
923
+ rows = (res.get("rows") or []) if isinstance(res, dict) else []
924
+ out(render_tabular(fmt, rows, columns or PARTNER_COLUMNS, res))
925
+
926
+ return handler
927
+
928
+
929
+ def cmd_price(pos, flags, cfg):
930
+ # price is public: no credential check.
931
+ out(dumps(call(client_for(cfg).price)))
932
+
933
+
934
+ def cmd_pay(pos, flags, cfg):
935
+ require_credential(cfg)
936
+ body = {}
937
+ token = flag_str(flags, "token", "payment-method-token")
938
+ if token:
939
+ body["payment_method_token"] = token
940
+ if flag_str(flags, "customer"):
941
+ body["customer"] = flag_str(flags, "customer")
942
+ out(dumps(call(client_for(cfg).pay, body)))
943
+
944
+
945
+ # ── auth ─────────────────────────────────────────────────────────────────────
946
+ AUTH_USAGE = """selltostate auth <subcommand> — manage credentials
947
+
948
+ Subcommands:
949
+ login [--api-key KEY] [--api-url URL] Save a key to {path}
950
+ status Show the resolved credential + source
951
+ logout Remove the stored API key"""
952
+
953
+
954
+ def cmd_auth(pos, flags, cfg):
955
+ sub = pos[0] if pos else None
956
+ if sub == "login":
957
+ api_key = flag_str(flags, "api-key")
958
+ api_url = flag_str(flags, "api-url")
959
+ if not api_key:
960
+ out(f"Get a dashboard API key at {cfg['apiUrl']}/dashboard (Settings → API keys).")
961
+ api_key = getpass.getpass("API key: ").strip()
962
+ if not api_key:
963
+ err("no API key entered.")
964
+ raise Exit(EXIT_UNAUTHORIZED)
965
+ stored = write_stored_config({"apiKey": api_key, "apiUrl": api_url if api_url and api_url != DEFAULT_API_URL else None})
966
+ out(f"Saved API key to {config_path()}")
967
+ try:
968
+ me = client_for(resolve_config(stored["apiKey"], api_url)).whoami()
969
+ except ApiError as e:
970
+ if e.status == 401:
971
+ err("warning: the key was saved but /whoami rejected it (401). Double-check the key.")
972
+ else:
973
+ err(f"warning: saved the key but could not verify it: {e}")
974
+ return
975
+ except RuntimeError as e:
976
+ err(f"warning: saved the key but could not verify it: {e}")
977
+ return
978
+ if me.get("active"):
979
+ out(f"Authenticated as {me.get('subject')} (plan: {me.get('plan') or '—'}, active).")
980
+ else:
981
+ out(f"Authenticated as {me.get('subject')} (inactive — run `{BIN} pay` to unlock data).")
982
+ return
983
+ if sub == "status":
984
+ key = cfg["apiKey"]
985
+ out(
986
+ {
987
+ "api_url": cfg["apiUrl"],
988
+ "api_url_source": cfg["source"]["apiUrl"],
989
+ "api_key": f"{key[:4]}…{key[-4:]}" if key else "(none)",
990
+ "api_key_source": cfg["source"]["apiKey"],
991
+ "config_file": config_path(),
992
+ }
993
+ )
994
+ return
995
+ if sub == "logout":
996
+ write_stored_config({"apiKey": None})
997
+ out(f"Removed the stored API key from {config_path()}.")
998
+ return
999
+ err(f"unknown auth subcommand: {sub or '(none)'}. Try: login | status | logout")
1000
+ raise Exit(EXIT_USAGE)
1001
+
1002
+
1003
+ # ── Registry + entry point ───────────────────────────────────────────────────
1004
+ COMMANDS = {
1005
+ "auth": ("Manage credentials (login | status | logout)", cmd_auth, AUTH_USAGE),
1006
+ "whoami": ("Show the authenticated subject, plan, scopes and export cap", cmd_whoami, WHOAMI_USAGE),
1007
+ "search": ("Faceted search across tenders/awards (GET /api/v1/search)", cmd_search, SEARCH_USAGE),
1008
+ "awards": ("List awarded tenders (GET /api/v1/awards)", cmd_awards, AWARDS_USAGE),
1009
+ "get": ("Fetch one buyer|supplier|tender|sector in detail (R1)", cmd_get, GET_USAGE),
1010
+ "sectors": ("List the curated sector taxonomy (GET /api/v1/sectors)", _reference("sectors", [col("slug"), col("name")]), None),
1011
+ "suppliers": ("Top suppliers by awarded total (GET /api/v1/suppliers)", _reference("suppliers"), None),
1012
+ "buyers": ("Top buyers by spend (GET /api/v1/buyers)", _reference("buyers"), None),
1013
+ "lists": ("Manage saved lists (ls|create|show|add|rm|rename|delete)", cmd_lists, LISTS_USAGE),
1014
+ "saved-searches": ("Manage saved searches (ls|save|show|rename|delete)", cmd_saved_searches, SAVED_SEARCHES_USAGE),
1015
+ "export": ("Export awards as CSV to stdout or -o file (GET /api/v1/awards.csv)", cmd_export, EXPORT_USAGE),
1016
+ "price": ("Show the live subscription price (GET /api/v1/price)", cmd_price, None),
1017
+ "pay": ("Activate a subscription autonomously (POST /api/v1/pay)", cmd_pay, None),
1018
+ }
1019
+
1020
+
1021
+ def help_text() -> str:
1022
+ width = max(len(k) for k in COMMANDS)
1023
+ lines = [
1024
+ f"{BIN} v{VERSION} — CLI for the Sell to State government-contracts data API",
1025
+ "",
1026
+ f"Usage: {BIN} <command> [subcommand] [options]",
1027
+ "",
1028
+ "Commands:",
1029
+ ]
1030
+ lines += [f" {name.ljust(width)} {summary}" for name, (summary, _, _) in COMMANDS.items()]
1031
+ lines += [
1032
+ "",
1033
+ "Global options:",
1034
+ " --api-key <key> API key (else $STS_API_KEY, else stored config)",
1035
+ f" --api-url <url> API base URL (default $STS_API_URL or {DEFAULT_API_URL})",
1036
+ " --format <fmt> Output format: table | json | csv (where supported)",
1037
+ " --json Shorthand for --format json",
1038
+ f" -h, --help Show help (per-command: `{BIN} <command> --help`)",
1039
+ " -v, --version Show version",
1040
+ "",
1041
+ "Auth:",
1042
+ f" {BIN} auth login Save an API key to {config_path()}",
1043
+ f" {BIN} auth status Show the resolved credential + its source",
1044
+ "",
1045
+ "Environment: STS_API_KEY, STS_API_URL",
1046
+ ]
1047
+ return "\n".join(lines)
1048
+
1049
+
1050
+ def run(argv: list) -> int:
1051
+ pos, flags = parse_args(argv)
1052
+ if flags.get("version"):
1053
+ out(VERSION)
1054
+ return 0
1055
+ if not pos:
1056
+ out(help_text())
1057
+ return 0
1058
+ entry = COMMANDS.get(pos[0])
1059
+ if not entry:
1060
+ err(f"unknown command: {pos[0]}")
1061
+ out(help_text())
1062
+ return EXIT_USAGE
1063
+ _, handler, usage = entry
1064
+ if flags.get("help"):
1065
+ out(usage.replace("{path}", config_path()) if usage else help_text())
1066
+ return 0
1067
+ cfg = resolve_config(flag_str(flags, "api-key"), flag_str(flags, "api-url"))
1068
+ try:
1069
+ handler(pos[1:], flags, cfg)
1070
+ except Exit as e:
1071
+ return e.code
1072
+ except (ValueError, RuntimeError) as e:
1073
+ err(str(e))
1074
+ return EXIT_ERROR
1075
+ return 0
1076
+
1077
+
1078
+ def main() -> None:
1079
+ try:
1080
+ sys.exit(run(sys.argv[1:]))
1081
+ except KeyboardInterrupt:
1082
+ sys.exit(130)
1083
+ except BrokenPipeError:
1084
+ sys.exit(0)
1085
+
1086
+
1087
+ if __name__ == "__main__":
1088
+ main()