botu-cli 0.1.0__tar.gz → 0.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: botu-cli
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
4
  Summary: Agent-first CLI for botu — embeddable AI agent for any website
5
5
  Project-URL: Homepage, https://botu.io
6
6
  Project-URL: Repository, https://github.com/jiangjin11/botu-web
@@ -19,7 +19,7 @@ Classifier: Programming Language :: Python :: 3.13
19
19
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
20
  Requires-Python: >=3.10
21
21
  Requires-Dist: httpx>=0.28
22
- Requires-Dist: rich>=13.0
22
+ Requires-Dist: paradigx-cli-core<0.2,>=0.1
23
23
  Requires-Dist: typer<1.0,>=0.15
24
24
  Provides-Extra: dev
25
25
  Requires-Dist: pytest-mock>=3.14; extra == 'dev'
@@ -49,7 +49,6 @@ botu login # OAuth device-flow, opens b
49
49
  botu sites create --name acme --domain acme.com # create a site + first embed key
50
50
  botu embed --site <site-id> --new-key --write index.html # inject the <script>
51
51
  botu sites verify <site-id> --domain acme.com # start domain verification
52
- botu sites verify <site-id> --domain acme.com --check # confirm it
53
52
  botu test --site <site-id> # check the embed key works
54
53
  ```
55
54
 
@@ -63,28 +62,45 @@ output that's friendly to agents and CI.
63
62
  | `botu login` / `logout` / `whoami` | OAuth device-flow session |
64
63
  | `botu sites create\|list\|get\|delete` | Manage sites |
65
64
  | `botu sites verify <id> --domain <d> [--check]` | Domain ownership (DNS TXT) |
66
- | `botu keys create\|list\|revoke --site <id>` | Manage embed API keys |
65
+ | `botu keys create\|list\|revoke --site <id>` | Manage site embed API keys |
66
+ | `botu tokens create\|list\|revoke` | Account access tokens (PATs) for CI |
67
67
  | `botu embed --site <id>` | Print / write the `<script>` embed snippet |
68
68
  | `botu usage [--site <id>]` | Per-site quota and usage |
69
69
  | `botu test --site <id>` | Verify an embed key via the loader auth exchange |
70
70
 
71
- ### About embed keys
71
+ ## Non-interactive use (CI / headless agents)
72
72
 
73
- The plaintext of an API key is shown **once** at creation. `botu embed`
74
- therefore can't retrieve the key of an existing site. Either pass
75
- `--key pk_live_...`, or use `--new-key` to mint a fresh one and drop it
76
- straight into the snippet.
73
+ `botu login` needs a browser. For CI or headless agents, create an account
74
+ **access token** once and pass it via the `BOTU_TOKEN` env var no login,
75
+ no browser:
76
+
77
+ ```bash
78
+ botu tokens create --name ci --json # → {"token": "bpat_...", ...} shown ONCE
79
+ export BOTU_TOKEN=bpat_...
80
+ botu sites list # uses BOTU_TOKEN, no ~/.paradigx needed
81
+ ```
82
+
83
+ Interactive sessions don't need this — the device-flow JWT is cached and
84
+ **auto-refreshed**, so `botu login` is a one-time step per machine.
85
+
86
+ ### About embed keys vs account tokens
87
+
88
+ - `pk_live_*` / `pk_test_*` — **site embed keys**, go in the `<script>` tag.
89
+ - `bpat_*` — **account access tokens**, authenticate the CLI itself.
90
+
91
+ Both plaintexts are shown **once**, at creation. `botu embed` can't retrieve
92
+ an existing site's key — pass `--key` or use `--new-key` to mint a fresh one.
77
93
 
78
94
  ## Configuration
79
95
 
80
96
  | Env var | Default | Purpose |
81
97
  |---|---|---|
82
- | `BOTU_API_URL` | `https://botu.io` | Target deployment (set to `https://qa.botu.io` for QA) |
98
+ | `BOTU_API_URL` | `https://botu.io` | Target deployment (`https://qa.botu.io` for QA) |
99
+ | `BOTU_TOKEN` | — | Account access token — skips login (CI / agents) |
83
100
  | `BOTU_JSON` | — | `1` forces JSON output globally |
84
101
 
85
102
  Credentials are stored in `~/.paradigx/auth.json`, **shared** with other
86
- Paradigx product CLIs (e.g. `tokenroute`) — they authenticate against the
87
- same Logto, so logging in once is reused across them.
103
+ Paradigx product CLIs (e.g. `tokenroute`) — log in once, reuse everywhere.
88
104
 
89
105
  ## Exit codes
90
106
 
@@ -21,7 +21,6 @@ botu login # OAuth device-flow, opens b
21
21
  botu sites create --name acme --domain acme.com # create a site + first embed key
22
22
  botu embed --site <site-id> --new-key --write index.html # inject the <script>
23
23
  botu sites verify <site-id> --domain acme.com # start domain verification
24
- botu sites verify <site-id> --domain acme.com --check # confirm it
25
24
  botu test --site <site-id> # check the embed key works
26
25
  ```
27
26
 
@@ -35,28 +34,45 @@ output that's friendly to agents and CI.
35
34
  | `botu login` / `logout` / `whoami` | OAuth device-flow session |
36
35
  | `botu sites create\|list\|get\|delete` | Manage sites |
37
36
  | `botu sites verify <id> --domain <d> [--check]` | Domain ownership (DNS TXT) |
38
- | `botu keys create\|list\|revoke --site <id>` | Manage embed API keys |
37
+ | `botu keys create\|list\|revoke --site <id>` | Manage site embed API keys |
38
+ | `botu tokens create\|list\|revoke` | Account access tokens (PATs) for CI |
39
39
  | `botu embed --site <id>` | Print / write the `<script>` embed snippet |
40
40
  | `botu usage [--site <id>]` | Per-site quota and usage |
41
41
  | `botu test --site <id>` | Verify an embed key via the loader auth exchange |
42
42
 
43
- ### About embed keys
43
+ ## Non-interactive use (CI / headless agents)
44
44
 
45
- The plaintext of an API key is shown **once** at creation. `botu embed`
46
- therefore can't retrieve the key of an existing site. Either pass
47
- `--key pk_live_...`, or use `--new-key` to mint a fresh one and drop it
48
- straight into the snippet.
45
+ `botu login` needs a browser. For CI or headless agents, create an account
46
+ **access token** once and pass it via the `BOTU_TOKEN` env var no login,
47
+ no browser:
48
+
49
+ ```bash
50
+ botu tokens create --name ci --json # → {"token": "bpat_...", ...} shown ONCE
51
+ export BOTU_TOKEN=bpat_...
52
+ botu sites list # uses BOTU_TOKEN, no ~/.paradigx needed
53
+ ```
54
+
55
+ Interactive sessions don't need this — the device-flow JWT is cached and
56
+ **auto-refreshed**, so `botu login` is a one-time step per machine.
57
+
58
+ ### About embed keys vs account tokens
59
+
60
+ - `pk_live_*` / `pk_test_*` — **site embed keys**, go in the `<script>` tag.
61
+ - `bpat_*` — **account access tokens**, authenticate the CLI itself.
62
+
63
+ Both plaintexts are shown **once**, at creation. `botu embed` can't retrieve
64
+ an existing site's key — pass `--key` or use `--new-key` to mint a fresh one.
49
65
 
50
66
  ## Configuration
51
67
 
52
68
  | Env var | Default | Purpose |
53
69
  |---|---|---|
54
- | `BOTU_API_URL` | `https://botu.io` | Target deployment (set to `https://qa.botu.io` for QA) |
70
+ | `BOTU_API_URL` | `https://botu.io` | Target deployment (`https://qa.botu.io` for QA) |
71
+ | `BOTU_TOKEN` | — | Account access token — skips login (CI / agents) |
55
72
  | `BOTU_JSON` | — | `1` forces JSON output globally |
56
73
 
57
74
  Credentials are stored in `~/.paradigx/auth.json`, **shared** with other
58
- Paradigx product CLIs (e.g. `tokenroute`) — they authenticate against the
59
- same Logto, so logging in once is reused across them.
75
+ Paradigx product CLIs (e.g. `tokenroute`) — log in once, reuse everywhere.
60
76
 
61
77
  ## Exit codes
62
78
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "botu-cli"
3
- version = "0.1.0"
3
+ version = "0.3.0"
4
4
  description = "Agent-first CLI for botu — embeddable AI agent for any website"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -21,9 +21,9 @@ classifiers = [
21
21
  ]
22
22
 
23
23
  dependencies = [
24
+ "paradigx-cli-core>=0.1,<0.2",
24
25
  "typer>=0.15,<1.0",
25
26
  "httpx>=0.28",
26
- "rich>=13.0",
27
27
  ]
28
28
 
29
29
  [project.optional-dependencies]
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -1,28 +1,23 @@
1
1
  """botu CLI entry point — agent-first provisioning for the botu embed agent.
2
2
 
3
- botu login # OAuth device-flow
4
- botu logout
5
- botu whoami
6
- botu sites create --name <n> --domain <d>
7
- botu sites list | get <id> | delete <id>
8
- botu sites verify <id> --domain <d> [--check]
9
- botu keys create --site <id> [--test]
10
- botu keys list --site <id>
11
- botu keys revoke <key-id> --site <id>
12
- botu embed --site <id> [--key <k> | --new-key] [--write <file>]
3
+ botu login / logout / whoami
4
+ botu sites create|list|get|delete|verify
5
+ botu keys create|list|revoke
6
+ botu tokens create|list|revoke # account-level PATs (CI / headless)
7
+ botu embed --site <id>
13
8
  botu usage [--site <id>]
14
- botu test --site <id> [--key <k>]
9
+ botu test --site <id>
15
10
 
16
- All commands accept `--json` (or env BOTU_JSON=1) for machine-parseable
17
- output. Auth: `botu login` writes ~/.paradigx/auth.json (shared across
18
- Paradigx CLIs); later commands reuse it. See docs/specs/agent-first-cli.md.
11
+ All commands accept `--json` (or env BOTU_JSON=1). Auth: `botu login`
12
+ (device-flow, cached + auto-refreshed in ~/.paradigx/auth.json), or set
13
+ `BOTU_TOKEN` to an account PAT for non-interactive use. See
14
+ docs/specs/agent-first-cli.md and docs/specs/cli-phase-b.md.
19
15
  """
20
16
  from __future__ import annotations
21
17
 
22
18
  import os
23
19
  import re
24
20
  import sys
25
- import time
26
21
  import uuid
27
22
 
28
23
  # Force UTF-8 on Windows so rich's coloured / unicode output doesn't crash on
@@ -37,12 +32,22 @@ if sys.platform == "win32":
37
32
 
38
33
  import httpx
39
34
  import typer
35
+ from paradigx_cli_core import (
36
+ ApiError,
37
+ clear_credentials,
38
+ do_login,
39
+ emit,
40
+ error,
41
+ exit_code_for,
42
+ info,
43
+ is_json_mode,
44
+ open_browser,
45
+ set_json_mode,
46
+ success,
47
+ )
40
48
 
41
49
  from . import __version__
42
- from .client import ApiError, exit_code_for, request
43
- from .config import Credentials, api_url, clear_credentials, save_credentials
44
- from .device_flow import fetch_discovery, open_browser, poll_for_token, request_device_code
45
- from .output import emit, error, info, is_json_mode, success
50
+ from .product import api, api_url, discovery_url
46
51
 
47
52
  app = typer.Typer(
48
53
  name="botu",
@@ -52,8 +57,12 @@ app = typer.Typer(
52
57
  )
53
58
  sites_app = typer.Typer(name="sites", help="Manage your botu sites.", no_args_is_help=True)
54
59
  keys_app = typer.Typer(name="keys", help="Manage site embed API keys.", no_args_is_help=True)
60
+ tokens_app = typer.Typer(
61
+ name="tokens", help="Manage account access tokens (PATs).", no_args_is_help=True
62
+ )
55
63
  app.add_typer(sites_app, name="sites")
56
64
  app.add_typer(keys_app, name="keys")
65
+ app.add_typer(tokens_app, name="tokens")
57
66
 
58
67
 
59
68
  def _global_callback(
@@ -62,7 +71,7 @@ def _global_callback(
62
71
  ),
63
72
  ) -> None:
64
73
  if json_output or os.environ.get("BOTU_JSON") == "1":
65
- os.environ["_BOTU_OUTPUT_JSON"] = "1"
74
+ set_json_mode(True)
66
75
 
67
76
 
68
77
  app.callback()(_global_callback)
@@ -88,50 +97,31 @@ def _confirm(prompt: str, yes: bool) -> None:
88
97
  @app.command()
89
98
  def login() -> None:
90
99
  """Log in via OAuth device-flow (opens browser)."""
100
+
101
+ def on_code(code) -> None:
102
+ info(f"\nVisit: [bold cyan]{code.verification_uri}[/bold cyan]")
103
+ info(f"And enter code: [bold yellow]{code.user_code}[/bold yellow]\n")
104
+ info("(opening browser automatically — if it doesn't, use the URL above)")
105
+ open_browser(code.verification_uri_complete)
106
+ info("Waiting for authorization...")
107
+
91
108
  try:
92
- disc = fetch_discovery()
109
+ do_login(discovery_url(), api_url(), on_code=on_code)
93
110
  except httpx.HTTPStatusError as e:
94
111
  if e.response.status_code == 503:
95
112
  error("CLI login is not enabled on this botu deployment yet", code=3)
96
113
  error(f"discovery failed: {e}", code=3)
97
- except Exception as e: # noqa: BLE001
114
+ except httpx.RequestError as e:
98
115
  error(f"could not reach botu API: {e}", code=2)
99
-
100
- try:
101
- code = request_device_code(disc)
102
- except Exception as e: # noqa: BLE001
103
- error(f"device-flow init failed: {e}", code=3)
104
-
105
- info(f"\nVisit: [bold cyan]{code.verification_uri}[/bold cyan]")
106
- info(f"And enter code: [bold yellow]{code.user_code}[/bold yellow]\n")
107
- info("(opening browser automatically — if it doesn't, use the URL above)")
108
- open_browser(code.verification_uri_complete)
109
-
110
- info("Waiting for authorization...")
111
- try:
112
- token = poll_for_token(disc, code)
113
116
  except RuntimeError as e:
114
117
  error(str(e), code=3)
115
-
116
- expires_at = int(time.time()) + int(token.get("expires_in", 3600))
117
- save_credentials(
118
- Credentials(
119
- access_token=token["access_token"],
120
- refresh_token=token.get("refresh_token"),
121
- expires_at=expires_at,
122
- issuer=disc.issuer,
123
- client_id=disc.client_id,
124
- resource=disc.resource,
125
- api_url=api_url(),
126
- )
127
- )
128
118
  success(f"logged in ({api_url()})")
129
119
 
130
120
 
131
121
  @app.command()
132
122
  def logout() -> None:
133
123
  """Forget locally stored credentials for this deployment."""
134
- if clear_credentials():
124
+ if clear_credentials(api_url()):
135
125
  success("logged out")
136
126
  else:
137
127
  info("(no stored credentials)")
@@ -141,7 +131,7 @@ def logout() -> None:
141
131
  def whoami() -> None:
142
132
  """Show the current logged-in identity."""
143
133
  try:
144
- body = request("GET", "/api/user/me")
134
+ body = api("GET", "/api/user/me")
145
135
  except ApiError as e:
146
136
  _fail(e)
147
137
  emit(body.get("user", body))
@@ -154,20 +144,17 @@ def whoami() -> None:
154
144
  def sites_create(
155
145
  name: str = typer.Option(..., "--name", "-n", help="Site name."),
156
146
  domain: str = typer.Option(None, "--domain", "-d", help="Primary domain (verify later)."),
157
- origin: list[str] = typer.Option(
158
- None, "--origin", "-o", help="Allowed origin (repeatable)."
159
- ),
147
+ origin: list[str] = typer.Option(None, "--origin", "-o", help="Allowed origin (repeatable)."),
160
148
  description: str = typer.Option("", "--description", help="Optional description."),
161
149
  test: bool = typer.Option(False, "--test", help="Issue a pk_test_ key instead of pk_live_."),
162
150
  ) -> None:
163
151
  """Create a site. Returns the site + its first embed key (shown ONCE)."""
164
152
  origins = list(origin or [])
165
- # A domain is also a natural allowed origin — fold it in if not given.
166
153
  if domain and not origins:
167
154
  origins = [f"https://{domain}"]
168
155
  body = {"name": name, "description": description, "allowedOrigins": origins, "test": test}
169
156
  try:
170
- out = request("POST", "/api/sites", json_body=body)
157
+ out = api("POST", "/api/sites", body)
171
158
  except ApiError as e:
172
159
  _fail(e)
173
160
  emit(out)
@@ -182,7 +169,7 @@ def sites_create(
182
169
  def sites_list() -> None:
183
170
  """List your sites."""
184
171
  try:
185
- out = request("GET", "/api/sites")
172
+ out = api("GET", "/api/sites")
186
173
  except ApiError as e:
187
174
  _fail(e)
188
175
  sites = out.get("sites", []) if isinstance(out, dict) else out
@@ -203,7 +190,93 @@ def sites_list() -> None:
203
190
  def sites_get(site_id: str = typer.Argument(..., help="Site id (uuid).")) -> None:
204
191
  """Show one site with all its (masked) keys."""
205
192
  try:
206
- out = request("GET", f"/api/sites/{site_id}")
193
+ out = api("GET", f"/api/sites/{site_id}")
194
+ except ApiError as e:
195
+ _fail(e)
196
+ emit(out)
197
+
198
+
199
+ @sites_app.command("update")
200
+ def sites_update(
201
+ site_id: str = typer.Argument(..., help="Site id (uuid)."),
202
+ name: str = typer.Option(None, "--name", "-n", help="New name."),
203
+ description: str = typer.Option(None, "--description", help="New description."),
204
+ add_origin: list[str] = typer.Option(
205
+ None,
206
+ "--add-origin",
207
+ help="Add an allowed origin (repeatable). Merges with existing origins.",
208
+ ),
209
+ remove_origin: list[str] = typer.Option(
210
+ None,
211
+ "--remove-origin",
212
+ help="Remove an allowed origin (repeatable). Merges with existing origins.",
213
+ ),
214
+ set_origins: list[str] = typer.Option(
215
+ None,
216
+ "--set-origin",
217
+ help=(
218
+ "Replace allowed origins (repeatable). Mutually exclusive with "
219
+ "--add-origin / --remove-origin."
220
+ ),
221
+ ),
222
+ theme_mode: str = typer.Option(
223
+ None,
224
+ "--theme-mode",
225
+ help="Theme detection mode: auto | light | dark.",
226
+ ),
227
+ theme_target: str = typer.Option(
228
+ None,
229
+ "--theme-target",
230
+ help="Theme target element selector: html | body | <custom-css-selector>.",
231
+ ),
232
+ ) -> None:
233
+ """Update an existing site (name / description / allowed origins / theme).
234
+
235
+ Origins:
236
+ • --add-origin / --remove-origin merge against the current allowlist
237
+ (1 GET + 1 PATCH); use these for incremental changes.
238
+ • --set-origin replaces the list outright. Mixing modes is an error.
239
+ """
240
+ if set_origins and (add_origin or remove_origin):
241
+ _fail(
242
+ ApiError(
243
+ 400,
244
+ "--set-origin cannot combine with --add-origin / --remove-origin",
245
+ )
246
+ )
247
+
248
+ patch: dict = {}
249
+ if name is not None:
250
+ patch["name"] = name
251
+ if description is not None:
252
+ patch["description"] = description
253
+ if theme_mode is not None or theme_target is not None:
254
+ patch["themeStrategy"] = {
255
+ **({"mode": theme_mode} if theme_mode is not None else {}),
256
+ **({"target": theme_target} if theme_target is not None else {}),
257
+ }
258
+
259
+ needs_origin_merge = bool(add_origin or remove_origin)
260
+ if set_origins:
261
+ patch["allowedOrigins"] = [o.strip() for o in set_origins if o and o.strip()]
262
+ elif needs_origin_merge:
263
+ try:
264
+ current = api("GET", f"/api/sites/{site_id}")
265
+ except ApiError as e:
266
+ _fail(e)
267
+ existing = list((current.get("site") or {}).get("allowed_origins") or [])
268
+ merged = [o for o in existing if o not in (remove_origin or [])]
269
+ for o in add_origin or []:
270
+ o_clean = o.strip()
271
+ if o_clean and o_clean not in merged:
272
+ merged.append(o_clean)
273
+ patch["allowedOrigins"] = merged
274
+
275
+ if not patch:
276
+ _fail(ApiError(400, "nothing to update — supply at least one field"))
277
+
278
+ try:
279
+ out = api("PATCH", f"/api/sites/{site_id}", patch)
207
280
  except ApiError as e:
208
281
  _fail(e)
209
282
  emit(out)
@@ -217,7 +290,7 @@ def sites_delete(
217
290
  """Delete a site (cascades to its keys, users and usage)."""
218
291
  _confirm(f"Delete site {site_id} and all its keys?", yes)
219
292
  try:
220
- out = request("DELETE", f"/api/sites/{site_id}")
293
+ out = api("DELETE", f"/api/sites/{site_id}")
221
294
  except ApiError as e:
222
295
  _fail(e)
223
296
  emit(out or {"ok": True})
@@ -234,9 +307,7 @@ def sites_verify(
234
307
  """Verify domain ownership. Without --check: start it and print DNS instructions."""
235
308
  if check:
236
309
  try:
237
- out = request(
238
- "POST", f"/api/sites/{site_id}/verify-domain/check", json_body={"domain": domain}
239
- )
310
+ out = api("POST", f"/api/sites/{site_id}/verify-domain/check", {"domain": domain})
240
311
  except ApiError as e:
241
312
  _fail(e)
242
313
  emit(out)
@@ -245,9 +316,7 @@ def sites_verify(
245
316
  return
246
317
 
247
318
  try:
248
- out = request(
249
- "POST", f"/api/sites/{site_id}/verify-domain/init", json_body={"domain": domain}
250
- )
319
+ out = api("POST", f"/api/sites/{site_id}/verify-domain/init", {"domain": domain})
251
320
  except ApiError as e:
252
321
  _fail(e)
253
322
  emit(out)
@@ -272,9 +341,7 @@ def keys_create(
272
341
  ) -> None:
273
342
  """Create a new embed API key. The plaintext key is shown ONCE."""
274
343
  try:
275
- out = request(
276
- "POST", f"/api/sites/{site_id}/keys", json_body={"label": label, "test": test}
277
- )
344
+ out = api("POST", f"/api/sites/{site_id}/keys", {"label": label, "test": test})
278
345
  except ApiError as e:
279
346
  _fail(e)
280
347
  emit(out)
@@ -288,7 +355,7 @@ def keys_list(
288
355
  ) -> None:
289
356
  """List a site's API keys (raw values never shown)."""
290
357
  try:
291
- out = request("GET", f"/api/sites/{site_id}")
358
+ out = api("GET", f"/api/sites/{site_id}")
292
359
  except ApiError as e:
293
360
  _fail(e)
294
361
  emit(
@@ -313,9 +380,69 @@ def keys_revoke(
313
380
  """Revoke an API key. Calls embedding it will then fail."""
314
381
  _confirm(f"Revoke key {key_id} on site {site_id}?", yes)
315
382
  try:
316
- out = request(
317
- "POST", f"/api/sites/{site_id}/keys/revoke", json_body={"keyId": key_id}
318
- )
383
+ out = api("POST", f"/api/sites/{site_id}/keys/revoke", {"keyId": key_id})
384
+ except ApiError as e:
385
+ _fail(e)
386
+ emit(out or {"ok": True})
387
+
388
+
389
+ # ─── tokens (account-level PATs) ─────────────────────────────────────
390
+
391
+
392
+ @tokens_app.command("create")
393
+ def tokens_create(
394
+ name: str = typer.Option(..., "--name", "-n", help="Token label."),
395
+ expires_days: int = typer.Option(
396
+ None, "--expires-days", help="Expire after N days (default: never)."
397
+ ),
398
+ ) -> None:
399
+ """Create an account access token (PAT). The plaintext is shown ONCE.
400
+
401
+ Use it non-interactively via the BOTU_TOKEN env var — for CI / agents.
402
+ """
403
+ body: dict = {"name": name}
404
+ if expires_days is not None:
405
+ body["expiresInDays"] = expires_days
406
+ try:
407
+ out = api("POST", "/api/tokens", body)
408
+ except ApiError as e:
409
+ _fail(e)
410
+ emit(out)
411
+ if not is_json_mode():
412
+ info("\n[yellow]Save the `token` — the plaintext is shown only once.[/yellow]")
413
+ info("[dim]Use it as: export BOTU_TOKEN=<token>[/dim]")
414
+
415
+
416
+ @tokens_app.command("list")
417
+ def tokens_list() -> None:
418
+ """List your account access tokens (plaintext never shown)."""
419
+ try:
420
+ out = api("GET", "/api/tokens")
421
+ except ApiError as e:
422
+ _fail(e)
423
+ emit(
424
+ out.get("tokens", []),
425
+ table_columns=[
426
+ ("ID", "id"),
427
+ ("Name", "name"),
428
+ ("Prefix", "token_prefix"),
429
+ ("Last used", "last_used_at"),
430
+ ("Revoked", "revoked_at"),
431
+ ("Expires", "expires_at"),
432
+ ("Created", "created_at"),
433
+ ],
434
+ )
435
+
436
+
437
+ @tokens_app.command("revoke")
438
+ def tokens_revoke(
439
+ token_id: str = typer.Argument(..., help="Token id (uuid, from `tokens list`)."),
440
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
441
+ ) -> None:
442
+ """Revoke an account access token."""
443
+ _confirm(f"Revoke token {token_id}?", yes)
444
+ try:
445
+ out = api("POST", f"/api/tokens/{token_id}/revoke")
319
446
  except ApiError as e:
320
447
  _fail(e)
321
448
  emit(out or {"ok": True})
@@ -381,11 +508,10 @@ def embed(
381
508
  """Print the <script> embed snippet — or inject it into an HTML file.
382
509
 
383
510
  Key resolution: --key wins; else --new-key mints one; else the snippet
384
- uses a `<YOUR_API_KEY>` placeholder (the plaintext of existing keys is
385
- never retrievable — mint a new one or pass --key).
511
+ uses a `<YOUR_API_KEY>` placeholder.
386
512
  """
387
513
  try:
388
- detail = request("GET", f"/api/sites/{site_id}")
514
+ detail = api("GET", f"/api/sites/{site_id}")
389
515
  except ApiError as e:
390
516
  _fail(e)
391
517
  site = detail.get("site") or {}
@@ -393,9 +519,7 @@ def embed(
393
519
  api_key = key
394
520
  if not api_key and new_key:
395
521
  try:
396
- out = request(
397
- "POST", f"/api/sites/{site_id}/keys", json_body={"label": "embed", "test": False}
398
- )
522
+ out = api("POST", f"/api/sites/{site_id}/keys", {"label": "embed", "test": False})
399
523
  except ApiError as e:
400
524
  _fail(e)
401
525
  api_key = out.get("apiKey")
@@ -413,7 +537,11 @@ def embed(
413
537
  action = "replaced"
414
538
  elif re.search(r"</body>", html, re.IGNORECASE):
415
539
  new_html = re.sub(
416
- r"</body>", lambda _m: f"{snippet}\n{_m.group(0)}", html, count=1, flags=re.IGNORECASE
540
+ r"</body>",
541
+ lambda _m: f"{snippet}\n{_m.group(0)}",
542
+ html,
543
+ count=1,
544
+ flags=re.IGNORECASE,
417
545
  )
418
546
  action = "injected"
419
547
  else:
@@ -451,7 +579,7 @@ def usage(
451
579
  """Per-site quota and usage."""
452
580
  path = "/api/usage" + (f"?site={site_id}" if site_id else "")
453
581
  try:
454
- out = request("GET", path)
582
+ out = api("GET", path)
455
583
  except ApiError as e:
456
584
  _fail(e)
457
585
  if is_json_mode():
@@ -484,11 +612,7 @@ def test(
484
612
  minted = False
485
613
  if not api_key:
486
614
  try:
487
- out = request(
488
- "POST",
489
- f"/api/sites/{site_id}/keys",
490
- json_body={"label": "cli-test", "test": True},
491
- )
615
+ out = api("POST", f"/api/sites/{site_id}/keys", {"label": "cli-test", "test": True})
492
616
  except ApiError as e:
493
617
  _fail(e)
494
618
  api_key = out.get("apiKey")