botu-cli 0.1.0__tar.gz → 0.2.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.2.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.2.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,7 @@ 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}")
207
194
  except ApiError as e:
208
195
  _fail(e)
209
196
  emit(out)
@@ -217,7 +204,7 @@ def sites_delete(
217
204
  """Delete a site (cascades to its keys, users and usage)."""
218
205
  _confirm(f"Delete site {site_id} and all its keys?", yes)
219
206
  try:
220
- out = request("DELETE", f"/api/sites/{site_id}")
207
+ out = api("DELETE", f"/api/sites/{site_id}")
221
208
  except ApiError as e:
222
209
  _fail(e)
223
210
  emit(out or {"ok": True})
@@ -234,9 +221,7 @@ def sites_verify(
234
221
  """Verify domain ownership. Without --check: start it and print DNS instructions."""
235
222
  if check:
236
223
  try:
237
- out = request(
238
- "POST", f"/api/sites/{site_id}/verify-domain/check", json_body={"domain": domain}
239
- )
224
+ out = api("POST", f"/api/sites/{site_id}/verify-domain/check", {"domain": domain})
240
225
  except ApiError as e:
241
226
  _fail(e)
242
227
  emit(out)
@@ -245,9 +230,7 @@ def sites_verify(
245
230
  return
246
231
 
247
232
  try:
248
- out = request(
249
- "POST", f"/api/sites/{site_id}/verify-domain/init", json_body={"domain": domain}
250
- )
233
+ out = api("POST", f"/api/sites/{site_id}/verify-domain/init", {"domain": domain})
251
234
  except ApiError as e:
252
235
  _fail(e)
253
236
  emit(out)
@@ -272,9 +255,7 @@ def keys_create(
272
255
  ) -> None:
273
256
  """Create a new embed API key. The plaintext key is shown ONCE."""
274
257
  try:
275
- out = request(
276
- "POST", f"/api/sites/{site_id}/keys", json_body={"label": label, "test": test}
277
- )
258
+ out = api("POST", f"/api/sites/{site_id}/keys", {"label": label, "test": test})
278
259
  except ApiError as e:
279
260
  _fail(e)
280
261
  emit(out)
@@ -288,7 +269,7 @@ def keys_list(
288
269
  ) -> None:
289
270
  """List a site's API keys (raw values never shown)."""
290
271
  try:
291
- out = request("GET", f"/api/sites/{site_id}")
272
+ out = api("GET", f"/api/sites/{site_id}")
292
273
  except ApiError as e:
293
274
  _fail(e)
294
275
  emit(
@@ -313,9 +294,69 @@ def keys_revoke(
313
294
  """Revoke an API key. Calls embedding it will then fail."""
314
295
  _confirm(f"Revoke key {key_id} on site {site_id}?", yes)
315
296
  try:
316
- out = request(
317
- "POST", f"/api/sites/{site_id}/keys/revoke", json_body={"keyId": key_id}
318
- )
297
+ out = api("POST", f"/api/sites/{site_id}/keys/revoke", {"keyId": key_id})
298
+ except ApiError as e:
299
+ _fail(e)
300
+ emit(out or {"ok": True})
301
+
302
+
303
+ # ─── tokens (account-level PATs) ─────────────────────────────────────
304
+
305
+
306
+ @tokens_app.command("create")
307
+ def tokens_create(
308
+ name: str = typer.Option(..., "--name", "-n", help="Token label."),
309
+ expires_days: int = typer.Option(
310
+ None, "--expires-days", help="Expire after N days (default: never)."
311
+ ),
312
+ ) -> None:
313
+ """Create an account access token (PAT). The plaintext is shown ONCE.
314
+
315
+ Use it non-interactively via the BOTU_TOKEN env var — for CI / agents.
316
+ """
317
+ body: dict = {"name": name}
318
+ if expires_days is not None:
319
+ body["expiresInDays"] = expires_days
320
+ try:
321
+ out = api("POST", "/api/tokens", body)
322
+ except ApiError as e:
323
+ _fail(e)
324
+ emit(out)
325
+ if not is_json_mode():
326
+ info("\n[yellow]Save the `token` — the plaintext is shown only once.[/yellow]")
327
+ info("[dim]Use it as: export BOTU_TOKEN=<token>[/dim]")
328
+
329
+
330
+ @tokens_app.command("list")
331
+ def tokens_list() -> None:
332
+ """List your account access tokens (plaintext never shown)."""
333
+ try:
334
+ out = api("GET", "/api/tokens")
335
+ except ApiError as e:
336
+ _fail(e)
337
+ emit(
338
+ out.get("tokens", []),
339
+ table_columns=[
340
+ ("ID", "id"),
341
+ ("Name", "name"),
342
+ ("Prefix", "token_prefix"),
343
+ ("Last used", "last_used_at"),
344
+ ("Revoked", "revoked_at"),
345
+ ("Expires", "expires_at"),
346
+ ("Created", "created_at"),
347
+ ],
348
+ )
349
+
350
+
351
+ @tokens_app.command("revoke")
352
+ def tokens_revoke(
353
+ token_id: str = typer.Argument(..., help="Token id (uuid, from `tokens list`)."),
354
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
355
+ ) -> None:
356
+ """Revoke an account access token."""
357
+ _confirm(f"Revoke token {token_id}?", yes)
358
+ try:
359
+ out = api("POST", f"/api/tokens/{token_id}/revoke")
319
360
  except ApiError as e:
320
361
  _fail(e)
321
362
  emit(out or {"ok": True})
@@ -381,11 +422,10 @@ def embed(
381
422
  """Print the <script> embed snippet — or inject it into an HTML file.
382
423
 
383
424
  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).
425
+ uses a `<YOUR_API_KEY>` placeholder.
386
426
  """
387
427
  try:
388
- detail = request("GET", f"/api/sites/{site_id}")
428
+ detail = api("GET", f"/api/sites/{site_id}")
389
429
  except ApiError as e:
390
430
  _fail(e)
391
431
  site = detail.get("site") or {}
@@ -393,9 +433,7 @@ def embed(
393
433
  api_key = key
394
434
  if not api_key and new_key:
395
435
  try:
396
- out = request(
397
- "POST", f"/api/sites/{site_id}/keys", json_body={"label": "embed", "test": False}
398
- )
436
+ out = api("POST", f"/api/sites/{site_id}/keys", {"label": "embed", "test": False})
399
437
  except ApiError as e:
400
438
  _fail(e)
401
439
  api_key = out.get("apiKey")
@@ -413,7 +451,11 @@ def embed(
413
451
  action = "replaced"
414
452
  elif re.search(r"</body>", html, re.IGNORECASE):
415
453
  new_html = re.sub(
416
- r"</body>", lambda _m: f"{snippet}\n{_m.group(0)}", html, count=1, flags=re.IGNORECASE
454
+ r"</body>",
455
+ lambda _m: f"{snippet}\n{_m.group(0)}",
456
+ html,
457
+ count=1,
458
+ flags=re.IGNORECASE,
417
459
  )
418
460
  action = "injected"
419
461
  else:
@@ -451,7 +493,7 @@ def usage(
451
493
  """Per-site quota and usage."""
452
494
  path = "/api/usage" + (f"?site={site_id}" if site_id else "")
453
495
  try:
454
- out = request("GET", path)
496
+ out = api("GET", path)
455
497
  except ApiError as e:
456
498
  _fail(e)
457
499
  if is_json_mode():
@@ -484,11 +526,7 @@ def test(
484
526
  minted = False
485
527
  if not api_key:
486
528
  try:
487
- out = request(
488
- "POST",
489
- f"/api/sites/{site_id}/keys",
490
- json_body={"label": "cli-test", "test": True},
491
- )
529
+ out = api("POST", f"/api/sites/{site_id}/keys", {"label": "cli-test", "test": True})
492
530
  except ApiError as e:
493
531
  _fail(e)
494
532
  api_key = out.get("apiKey")
@@ -0,0 +1,56 @@
1
+ """botu-specific glue around paradigx-cli-core.
2
+
3
+ Holds the values that distinguish botu from other Paradigx CLIs (API URL,
4
+ discovery path, the trailingSlash quirk, the PAT env var) and the auth
5
+ resolution policy.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+
11
+ from paradigx_cli_core import ApiError, NeedLogin, request, valid_access_token
12
+
13
+ from . import __version__
14
+
15
+ DEFAULT_API_URL = "https://botu.io"
16
+ API_URL_ENV = "BOTU_API_URL"
17
+ TOKEN_ENV = "BOTU_TOKEN" # account-level PAT for CI / headless agents
18
+ DISCOVERY_PATH = "/api/auth/discovery/"
19
+ TRAILING_SLASH = True # botu-web is a Next.js app with trailingSlash:true
20
+
21
+
22
+ def api_url() -> str:
23
+ """Target botu-web deployment. Override with BOTU_API_URL for qa / local."""
24
+ return os.environ.get(API_URL_ENV, DEFAULT_API_URL).rstrip("/")
25
+
26
+
27
+ def discovery_url() -> str:
28
+ return f"{api_url()}{DISCOVERY_PATH}"
29
+
30
+
31
+ def resolve_token() -> str:
32
+ """Bearer token for an API call.
33
+
34
+ 1. ``BOTU_TOKEN`` env (account-level PAT) — CI / headless, no login.
35
+ 2. else the cached device-flow JWT, auto-refreshed when near expiry.
36
+ """
37
+ env = os.environ.get(TOKEN_ENV)
38
+ if env and env.strip():
39
+ return env.strip()
40
+ try:
41
+ return valid_access_token(api_url())
42
+ except NeedLogin as e:
43
+ raise ApiError(401, str(e)) from e
44
+
45
+
46
+ def api(method: str, path: str, json_body=None):
47
+ """Call a botu-web console endpoint with the resolved bearer token."""
48
+ return request(
49
+ method,
50
+ api_url(),
51
+ path,
52
+ token=resolve_token(),
53
+ json_body=json_body,
54
+ trailing_slash=TRAILING_SLASH,
55
+ user_agent=f"botu-cli/{__version__}",
56
+ )