botu-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.
botu_cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
botu_cli/__main__.py ADDED
@@ -0,0 +1,535 @@
1
+ """botu CLI entry point — agent-first provisioning for the botu embed agent.
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>]
13
+ botu usage [--site <id>]
14
+ botu test --site <id> [--key <k>]
15
+
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.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ import re
24
+ import sys
25
+ import time
26
+ import uuid
27
+
28
+ # Force UTF-8 on Windows so rich's coloured / unicode output doesn't crash on
29
+ # legacy GBK / CP936 consoles. errors='replace' degrades gracefully.
30
+ if sys.platform == "win32":
31
+ for _stream in (sys.stdout, sys.stderr):
32
+ if hasattr(_stream, "reconfigure"):
33
+ try:
34
+ _stream.reconfigure(encoding="utf-8", errors="replace")
35
+ except Exception:
36
+ pass
37
+
38
+ import httpx
39
+ import typer
40
+
41
+ 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
46
+
47
+ app = typer.Typer(
48
+ name="botu",
49
+ help="Agent-first CLI for botu — embeddable AI agent for any website.",
50
+ add_completion=False,
51
+ no_args_is_help=True,
52
+ )
53
+ sites_app = typer.Typer(name="sites", help="Manage your botu sites.", no_args_is_help=True)
54
+ keys_app = typer.Typer(name="keys", help="Manage site embed API keys.", no_args_is_help=True)
55
+ app.add_typer(sites_app, name="sites")
56
+ app.add_typer(keys_app, name="keys")
57
+
58
+
59
+ def _global_callback(
60
+ json_output: bool = typer.Option(
61
+ False, "--json", help="Machine-parseable JSON output (for agents / scripts)."
62
+ ),
63
+ ) -> None:
64
+ if json_output or os.environ.get("BOTU_JSON") == "1":
65
+ os.environ["_BOTU_OUTPUT_JSON"] = "1"
66
+
67
+
68
+ app.callback()(_global_callback)
69
+
70
+
71
+ def _fail(e: ApiError) -> None:
72
+ error(e.message, code=exit_code_for(e))
73
+
74
+
75
+ def _confirm(prompt: str, yes: bool) -> None:
76
+ """Abort unless confirmed. In --json / non-interactive mode --yes is required."""
77
+ if yes:
78
+ return
79
+ if is_json_mode() or not sys.stdin.isatty():
80
+ error("refusing without --yes (non-interactive)", code=1)
81
+ if not typer.confirm(prompt):
82
+ error("aborted", code=1)
83
+
84
+
85
+ # ─── login / logout / whoami ─────────────────────────────────────────
86
+
87
+
88
+ @app.command()
89
+ def login() -> None:
90
+ """Log in via OAuth device-flow (opens browser)."""
91
+ try:
92
+ disc = fetch_discovery()
93
+ except httpx.HTTPStatusError as e:
94
+ if e.response.status_code == 503:
95
+ error("CLI login is not enabled on this botu deployment yet", code=3)
96
+ error(f"discovery failed: {e}", code=3)
97
+ except Exception as e: # noqa: BLE001
98
+ 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
+ except RuntimeError as e:
114
+ 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
+ success(f"logged in ({api_url()})")
129
+
130
+
131
+ @app.command()
132
+ def logout() -> None:
133
+ """Forget locally stored credentials for this deployment."""
134
+ if clear_credentials():
135
+ success("logged out")
136
+ else:
137
+ info("(no stored credentials)")
138
+
139
+
140
+ @app.command()
141
+ def whoami() -> None:
142
+ """Show the current logged-in identity."""
143
+ try:
144
+ body = request("GET", "/api/user/me")
145
+ except ApiError as e:
146
+ _fail(e)
147
+ emit(body.get("user", body))
148
+
149
+
150
+ # ─── sites ───────────────────────────────────────────────────────────
151
+
152
+
153
+ @sites_app.command("create")
154
+ def sites_create(
155
+ name: str = typer.Option(..., "--name", "-n", help="Site name."),
156
+ 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
+ ),
160
+ description: str = typer.Option("", "--description", help="Optional description."),
161
+ test: bool = typer.Option(False, "--test", help="Issue a pk_test_ key instead of pk_live_."),
162
+ ) -> None:
163
+ """Create a site. Returns the site + its first embed key (shown ONCE)."""
164
+ origins = list(origin or [])
165
+ # A domain is also a natural allowed origin — fold it in if not given.
166
+ if domain and not origins:
167
+ origins = [f"https://{domain}"]
168
+ body = {"name": name, "description": description, "allowedOrigins": origins, "test": test}
169
+ try:
170
+ out = request("POST", "/api/sites", json_body=body)
171
+ except ApiError as e:
172
+ _fail(e)
173
+ emit(out)
174
+ if not is_json_mode():
175
+ info("\n[yellow]Save the `apiKey` — the plaintext is shown only once.[/yellow]")
176
+ if domain:
177
+ sid = out.get("site", {}).get("id")
178
+ info(f"[dim]Next: botu sites verify {sid} --domain {domain}[/dim]")
179
+
180
+
181
+ @sites_app.command("list")
182
+ def sites_list() -> None:
183
+ """List your sites."""
184
+ try:
185
+ out = request("GET", "/api/sites")
186
+ except ApiError as e:
187
+ _fail(e)
188
+ sites = out.get("sites", []) if isinstance(out, dict) else out
189
+ emit(
190
+ sites,
191
+ table_columns=[
192
+ ("ID", "id"),
193
+ ("Name", "name"),
194
+ ("Domain", "primary_domain"),
195
+ ("Status", "status"),
196
+ ("Key", "primaryKeyMasked"),
197
+ ("Created", "created_at"),
198
+ ],
199
+ )
200
+
201
+
202
+ @sites_app.command("get")
203
+ def sites_get(site_id: str = typer.Argument(..., help="Site id (uuid).")) -> None:
204
+ """Show one site with all its (masked) keys."""
205
+ try:
206
+ out = request("GET", f"/api/sites/{site_id}")
207
+ except ApiError as e:
208
+ _fail(e)
209
+ emit(out)
210
+
211
+
212
+ @sites_app.command("delete")
213
+ def sites_delete(
214
+ site_id: str = typer.Argument(..., help="Site id (uuid)."),
215
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
216
+ ) -> None:
217
+ """Delete a site (cascades to its keys, users and usage)."""
218
+ _confirm(f"Delete site {site_id} and all its keys?", yes)
219
+ try:
220
+ out = request("DELETE", f"/api/sites/{site_id}")
221
+ except ApiError as e:
222
+ _fail(e)
223
+ emit(out or {"ok": True})
224
+
225
+
226
+ @sites_app.command("verify")
227
+ def sites_verify(
228
+ site_id: str = typer.Argument(..., help="Site id (uuid)."),
229
+ domain: str = typer.Option(..., "--domain", "-d", help="Domain to verify."),
230
+ check: bool = typer.Option(
231
+ False, "--check", help="Poll verification result instead of starting it."
232
+ ),
233
+ ) -> None:
234
+ """Verify domain ownership. Without --check: start it and print DNS instructions."""
235
+ if check:
236
+ try:
237
+ out = request(
238
+ "POST", f"/api/sites/{site_id}/verify-domain/check", json_body={"domain": domain}
239
+ )
240
+ except ApiError as e:
241
+ _fail(e)
242
+ emit(out)
243
+ if not is_json_mode() and not out.get("verified"):
244
+ info("[yellow]Not verified yet — add the DNS record, then retry --check.[/yellow]")
245
+ return
246
+
247
+ try:
248
+ out = request(
249
+ "POST", f"/api/sites/{site_id}/verify-domain/init", json_body={"domain": domain}
250
+ )
251
+ except ApiError as e:
252
+ _fail(e)
253
+ emit(out)
254
+ if not is_json_mode():
255
+ rec = (out.get("instructions") or {}).get("dns_record") or {}
256
+ if rec:
257
+ info(
258
+ f"\nAdd this DNS record, then run "
259
+ f"[bold]botu sites verify {site_id} --domain {domain} --check[/bold]:"
260
+ )
261
+ info(f" [cyan]{rec.get('type')} {rec.get('name')} -> {rec.get('value')}[/cyan]")
262
+
263
+
264
+ # ─── keys ────────────────────────────────────────────────────────────
265
+
266
+
267
+ @keys_app.command("create")
268
+ def keys_create(
269
+ site_id: str = typer.Option(..., "--site", "-s", help="Site id (uuid)."),
270
+ label: str = typer.Option("default", "--label", "-l", help="Human-friendly key label."),
271
+ test: bool = typer.Option(False, "--test", help="Issue a pk_test_ key instead of pk_live_."),
272
+ ) -> None:
273
+ """Create a new embed API key. The plaintext key is shown ONCE."""
274
+ try:
275
+ out = request(
276
+ "POST", f"/api/sites/{site_id}/keys", json_body={"label": label, "test": test}
277
+ )
278
+ except ApiError as e:
279
+ _fail(e)
280
+ emit(out)
281
+ if not is_json_mode():
282
+ info("\n[yellow]Save the `apiKey` — the plaintext is shown only once.[/yellow]")
283
+
284
+
285
+ @keys_app.command("list")
286
+ def keys_list(
287
+ site_id: str = typer.Option(..., "--site", "-s", help="Site id (uuid)."),
288
+ ) -> None:
289
+ """List a site's API keys (raw values never shown)."""
290
+ try:
291
+ out = request("GET", f"/api/sites/{site_id}")
292
+ except ApiError as e:
293
+ _fail(e)
294
+ emit(
295
+ out.get("keys", []),
296
+ table_columns=[
297
+ ("ID", "id"),
298
+ ("Label", "label"),
299
+ ("Key", "keyMasked"),
300
+ ("Revoked", "revokedAt"),
301
+ ("Last used", "lastUsedAt"),
302
+ ("Created", "createdAt"),
303
+ ],
304
+ )
305
+
306
+
307
+ @keys_app.command("revoke")
308
+ def keys_revoke(
309
+ key_id: int = typer.Argument(..., help="Key id (integer, from `keys list`)."),
310
+ site_id: str = typer.Option(..., "--site", "-s", help="Site id (uuid)."),
311
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
312
+ ) -> None:
313
+ """Revoke an API key. Calls embedding it will then fail."""
314
+ _confirm(f"Revoke key {key_id} on site {site_id}?", yes)
315
+ try:
316
+ out = request(
317
+ "POST", f"/api/sites/{site_id}/keys/revoke", json_body={"keyId": key_id}
318
+ )
319
+ except ApiError as e:
320
+ _fail(e)
321
+ emit(out or {"ok": True})
322
+
323
+
324
+ # ─── embed ───────────────────────────────────────────────────────────
325
+
326
+
327
+ def _theme_data_attrs(strategy: dict | None) -> list[str]:
328
+ """Port of botu-web themeStrategyToDataAttrs (ThemeStrategyPicker.jsx)."""
329
+ s = strategy or {}
330
+ attrs: list[str] = []
331
+ mode = s.get("mode")
332
+ if mode == "fixed" and s.get("fixedTheme") in ("dark", "light"):
333
+ attrs.append(f'data-theme-fixed="{s["fixedTheme"]}"')
334
+ elif mode == "attr" and s.get("attr"):
335
+ attrs.append(f'data-theme-attr="{s["attr"]}"')
336
+ if s.get("attrDark"):
337
+ attrs.append(f'data-theme-attr-dark="{s["attrDark"]}"')
338
+ if s.get("target") == "body":
339
+ attrs.append('data-theme-target="body"')
340
+ elif mode == "class" and s.get("class"):
341
+ attrs.append(f'data-theme-class="{s["class"]}"')
342
+ if s.get("target") == "body":
343
+ attrs.append('data-theme-target="body"')
344
+ return attrs
345
+
346
+
347
+ def _build_snippet(site: dict, api_key: str | None) -> str:
348
+ """Port of botu-web buildEmbedSnippet (site-detail-view.jsx)."""
349
+ key_value = api_key or "<YOUR_API_KEY>"
350
+ tenant_line = ""
351
+ if site.get("primary_domain") and site.get("domain_verified_at"):
352
+ tenant_line = f'\n data-tenant="{site["primary_domain"]}"'
353
+ theme_attrs = _theme_data_attrs(site.get("theme_strategy"))
354
+ theme_lines = ("\n " + "\n ".join(theme_attrs)) if theme_attrs else ""
355
+ return (
356
+ "<script\n"
357
+ f" src=\"{api_url()}/v1.js\"\n"
358
+ f' data-api-key="{key_value}"{tenant_line}\n'
359
+ f' data-user-id="OPTIONAL_USER_ID"{theme_lines}\n'
360
+ " async></script>"
361
+ )
362
+
363
+
364
+ _SNIPPET_RE = re.compile(
365
+ r"<script\b[^>]*\bsrc=[\"'][^\"']*v1\.js[\"'][^>]*>\s*</script>",
366
+ re.IGNORECASE | re.DOTALL,
367
+ )
368
+
369
+
370
+ @app.command()
371
+ def embed(
372
+ site_id: str = typer.Option(..., "--site", "-s", help="Site id (uuid)."),
373
+ key: str = typer.Option(None, "--key", "-k", help="Embed key to put in the snippet."),
374
+ new_key: bool = typer.Option(
375
+ False, "--new-key", help="Mint a fresh embed key and use it in the snippet."
376
+ ),
377
+ write: str = typer.Option(
378
+ None, "--write", "-w", help="HTML file to inject the snippet into (before </body>)."
379
+ ),
380
+ ) -> None:
381
+ """Print the <script> embed snippet — or inject it into an HTML file.
382
+
383
+ 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).
386
+ """
387
+ try:
388
+ detail = request("GET", f"/api/sites/{site_id}")
389
+ except ApiError as e:
390
+ _fail(e)
391
+ site = detail.get("site") or {}
392
+
393
+ api_key = key
394
+ if not api_key and new_key:
395
+ try:
396
+ out = request(
397
+ "POST", f"/api/sites/{site_id}/keys", json_body={"label": "embed", "test": False}
398
+ )
399
+ except ApiError as e:
400
+ _fail(e)
401
+ api_key = out.get("apiKey")
402
+
403
+ snippet = _build_snippet(site, api_key)
404
+
405
+ if write:
406
+ try:
407
+ with open(write, "r", encoding="utf-8") as f:
408
+ html = f.read()
409
+ except OSError as e:
410
+ error(f"cannot read {write}: {e}", code=1)
411
+ if _SNIPPET_RE.search(html):
412
+ new_html = _SNIPPET_RE.sub(lambda _m: snippet, html, count=1)
413
+ action = "replaced"
414
+ elif re.search(r"</body>", html, re.IGNORECASE):
415
+ new_html = re.sub(
416
+ r"</body>", lambda _m: f"{snippet}\n{_m.group(0)}", html, count=1, flags=re.IGNORECASE
417
+ )
418
+ action = "injected"
419
+ else:
420
+ new_html = f"{html}\n{snippet}\n"
421
+ action = "appended"
422
+ try:
423
+ with open(write, "w", encoding="utf-8") as f:
424
+ f.write(new_html)
425
+ except OSError as e:
426
+ error(f"cannot write {write}: {e}", code=1)
427
+ if is_json_mode():
428
+ emit({"ok": True, "action": action, "file": write, "snippet": snippet})
429
+ else:
430
+ success(f"{action} embed snippet in {write}")
431
+ return
432
+
433
+ if is_json_mode():
434
+ emit({"snippet": snippet, "has_key": bool(api_key)})
435
+ else:
436
+ info(snippet)
437
+ if not api_key:
438
+ info(
439
+ "\n[dim]No key in snippet. Pass --key <pk_...> or --new-key, "
440
+ "or run `botu keys create --site <id>`.[/dim]"
441
+ )
442
+
443
+
444
+ # ─── usage / test ────────────────────────────────────────────────────
445
+
446
+
447
+ @app.command()
448
+ def usage(
449
+ site_id: str = typer.Option(None, "--site", "-s", help="Limit to one site id."),
450
+ ) -> None:
451
+ """Per-site quota and usage."""
452
+ path = "/api/usage" + (f"?site={site_id}" if site_id else "")
453
+ try:
454
+ out = request("GET", path)
455
+ except ApiError as e:
456
+ _fail(e)
457
+ if is_json_mode():
458
+ emit(out)
459
+ else:
460
+ if out.get("note"):
461
+ info(f"[dim]{out['note']}[/dim]")
462
+ emit(
463
+ out.get("sites", []),
464
+ table_columns=[
465
+ ("Site", "site_id"),
466
+ ("Name", "name"),
467
+ ("Quota/mo", "monthly_quota_msgs"),
468
+ ("Consumed", "consumed_msgs"),
469
+ ("Status", "status"),
470
+ ],
471
+ )
472
+
473
+
474
+ @app.command()
475
+ def test(
476
+ site_id: str = typer.Option(..., "--site", "-s", help="Site id (uuid)."),
477
+ key: str = typer.Option(None, "--key", "-k", help="Embed key to test (default: mint one)."),
478
+ ) -> None:
479
+ """Verify an embed key works by running the loader's auth exchange.
480
+
481
+ Without --key a disposable pk_test_ key is minted for the check.
482
+ """
483
+ api_key = key
484
+ minted = False
485
+ if not api_key:
486
+ try:
487
+ out = request(
488
+ "POST",
489
+ f"/api/sites/{site_id}/keys",
490
+ json_body={"label": "cli-test", "test": True},
491
+ )
492
+ except ApiError as e:
493
+ _fail(e)
494
+ api_key = out.get("apiKey")
495
+ minted = True
496
+
497
+ # trailing slash — botu-web Next.js trailingSlash:true
498
+ url = f"{api_url()}/api/auth/exchange/"
499
+ payload = {"apiKey": api_key, "anonId": f"cli_{uuid.uuid4().hex[:16]}"}
500
+ try:
501
+ with httpx.Client(timeout=20.0, follow_redirects=True) as c:
502
+ r = c.post(url, json=payload)
503
+ except httpx.RequestError as e:
504
+ error(f"network error: {e}", code=2)
505
+ if not r.is_success:
506
+ error(
507
+ f"auth exchange failed ({r.status_code}): {r.text}",
508
+ code=1 if r.status_code < 500 else 3,
509
+ )
510
+ data = r.json()
511
+ ok = bool(data.get("token"))
512
+ result = {
513
+ "ok": ok,
514
+ "site_id": site_id,
515
+ "session_id": data.get("sessionId"),
516
+ "minted_test_key": minted,
517
+ }
518
+ if is_json_mode():
519
+ emit(result)
520
+ elif ok:
521
+ success(f"embed key valid — auth exchange OK (session {data.get('sessionId')})")
522
+ if minted:
523
+ info("[dim]A disposable pk_test_ key was minted for this check.[/dim]")
524
+ else:
525
+ error("auth exchange returned no token", code=3)
526
+
527
+
528
+ @app.command()
529
+ def version() -> None:
530
+ """Print CLI version."""
531
+ emit({"version": __version__})
532
+
533
+
534
+ if __name__ == "__main__":
535
+ app()
botu_cli/client.py ADDED
@@ -0,0 +1,96 @@
1
+ """Thin httpx wrapper for the botu-web console API.
2
+
3
+ Every console endpoint (`/api/sites*`, `/api/user/me`, `/api/usage`) is
4
+ authenticated with the saved Logto JWT as a Bearer token. The public
5
+ `/api/auth/exchange` endpoint (used by `botu test`) does NOT need a JWT and
6
+ is called directly, not through `request()`.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ import httpx
13
+
14
+ from . import __version__
15
+ from .config import Credentials, api_url, load_credentials
16
+
17
+
18
+ class ApiError(RuntimeError):
19
+ def __init__(self, status: int, message: str, body: Any = None):
20
+ super().__init__(f"HTTP {status}: {message}")
21
+ self.status = status
22
+ self.message = message
23
+ self.body = body
24
+
25
+
26
+ def _default_headers() -> dict[str, str]:
27
+ return {
28
+ "User-Agent": f"botu-cli/{__version__}",
29
+ "Accept": "application/json",
30
+ }
31
+
32
+
33
+ def require_login() -> Credentials:
34
+ creds = load_credentials()
35
+ if creds is None or not creds.access_token:
36
+ raise ApiError(401, "not logged in — run `botu login` first")
37
+ return creds
38
+
39
+
40
+ def with_trailing_slash(path: str) -> str:
41
+ """botu-web is a Next.js app with `trailingSlash: true` — every route
42
+ needs a `/` before the query string, else Next.js answers 308. We add it
43
+ so calls land directly without a redirect round-trip.
44
+ """
45
+ base, sep, query = path.partition("?")
46
+ if not base.endswith("/"):
47
+ base += "/"
48
+ return f"{base}{sep}{query}"
49
+
50
+
51
+ def _raise(resp: httpx.Response) -> None:
52
+ if resp.is_success:
53
+ return
54
+ try:
55
+ body = resp.json()
56
+ except (ValueError, httpx.DecodingError):
57
+ body = resp.text
58
+ if isinstance(body, dict):
59
+ # botu-web errors are `{error, detail?}`; surface both when present.
60
+ msg = body.get("error") or body.get("detail") or resp.reason_phrase
61
+ if body.get("detail") and body.get("error"):
62
+ msg = f"{body['error']} ({body['detail']})"
63
+ else:
64
+ msg = resp.reason_phrase
65
+ raise ApiError(resp.status_code, str(msg), body)
66
+
67
+
68
+ def request(
69
+ method: str,
70
+ path: str,
71
+ *,
72
+ json_body: Any | None = None,
73
+ timeout: float = 30.0,
74
+ ) -> Any:
75
+ """Call a botu-web console endpoint with the saved Logto JWT."""
76
+ creds = require_login()
77
+ headers = _default_headers()
78
+ headers["Authorization"] = f"Bearer {creds.access_token}"
79
+ url = f"{api_url()}{with_trailing_slash(path)}"
80
+ try:
81
+ # follow_redirects: belt-and-suspenders for the trailingSlash 308.
82
+ with httpx.Client(timeout=timeout, follow_redirects=True) as client:
83
+ resp = client.request(method, url, json=json_body, headers=headers)
84
+ except httpx.RequestError as e:
85
+ raise ApiError(0, f"network error: {e}") from e
86
+ _raise(resp)
87
+ if resp.status_code == 204 or not resp.content:
88
+ return None
89
+ return resp.json()
90
+
91
+
92
+ def exit_code_for(err: ApiError) -> int:
93
+ """Map an ApiError onto a CLI exit code: 1 user / 2 network / 3 server."""
94
+ if err.status == 0:
95
+ return 2
96
+ return 1 if err.status < 500 else 3
botu_cli/config.py ADDED
@@ -0,0 +1,134 @@
1
+ """Local config + shared credential storage for the botu CLI.
2
+
3
+ Token cache lives at ``~/.paradigx/auth.json`` — **shared** across every
4
+ Paradigx product CLI (botu, tokenroute, ...). They all authenticate against
5
+ the same Logto (auth.paradigx.com), so a single login is reused. See
6
+ docs/specs/agent-first-cli.md §6.3.
7
+
8
+ auth.json shape::
9
+
10
+ {
11
+ "version": 1,
12
+ "tokens": {
13
+ "<resource>": {
14
+ "access_token": "...", "refresh_token": "...",
15
+ "expires_at": 1234567890, "issuer": "...", "client_id": "...",
16
+ "resource": "...", "api_url": "https://botu.io"
17
+ }
18
+ }
19
+ }
20
+
21
+ Entries are keyed by Logto resource indicator so multiple products / envs
22
+ coexist. Retrieval picks the entry whose ``api_url`` matches the current
23
+ target — that uniquely identifies "the credential for the deployment I'm
24
+ talking to" (prod vs qa) without an extra discovery round-trip.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import os
30
+ import stat
31
+ from dataclasses import dataclass
32
+ from pathlib import Path
33
+
34
+ DEFAULT_API_URL = "https://botu.io"
35
+
36
+ _CRED_FIELDS = (
37
+ "access_token",
38
+ "refresh_token",
39
+ "expires_at",
40
+ "issuer",
41
+ "client_id",
42
+ "resource",
43
+ "api_url",
44
+ )
45
+
46
+
47
+ def api_url() -> str:
48
+ """Target botu-web deployment. Override with BOTU_API_URL for qa / local."""
49
+ return os.environ.get("BOTU_API_URL", DEFAULT_API_URL).rstrip("/")
50
+
51
+
52
+ def _config_dir() -> Path:
53
+ return Path.home() / ".paradigx"
54
+
55
+
56
+ def _auth_path() -> Path:
57
+ return _config_dir() / "auth.json"
58
+
59
+
60
+ @dataclass
61
+ class Credentials:
62
+ access_token: str
63
+ refresh_token: str | None = None
64
+ expires_at: int | None = None # unix seconds
65
+ issuer: str | None = None
66
+ client_id: str | None = None
67
+ resource: str | None = None
68
+ api_url: str | None = None
69
+
70
+
71
+ def _read_store() -> dict:
72
+ p = _auth_path()
73
+ if not p.exists():
74
+ return {"version": 1, "tokens": {}}
75
+ try:
76
+ data = json.loads(p.read_text(encoding="utf-8"))
77
+ except (json.JSONDecodeError, OSError):
78
+ return {"version": 1, "tokens": {}}
79
+ if not isinstance(data, dict):
80
+ return {"version": 1, "tokens": {}}
81
+ if not isinstance(data.get("tokens"), dict):
82
+ data["tokens"] = {}
83
+ data.setdefault("version", 1)
84
+ return data
85
+
86
+
87
+ def _write_store(store: dict) -> None:
88
+ d = _config_dir()
89
+ d.mkdir(parents=True, exist_ok=True)
90
+ p = _auth_path()
91
+ p.write_text(json.dumps(store, indent=2), encoding="utf-8")
92
+ # Owner-only read/write on POSIX. chmod is a near no-op on Windows but
93
+ # doesn't error, so we call it unconditionally.
94
+ try:
95
+ os.chmod(p, stat.S_IRUSR | stat.S_IWUSR)
96
+ except OSError:
97
+ pass
98
+
99
+
100
+ def save_credentials(creds: Credentials) -> Path:
101
+ store = _read_store()
102
+ key = creds.resource or creds.api_url or "default"
103
+ store["tokens"][key] = {f: getattr(creds, f) for f in _CRED_FIELDS}
104
+ _write_store(store)
105
+ return _auth_path()
106
+
107
+
108
+ def load_credentials() -> Credentials | None:
109
+ """Return the stored credential for the current ``api_url()``."""
110
+ store = _read_store()
111
+ target = api_url()
112
+ matches = [
113
+ v for v in store["tokens"].values() if isinstance(v, dict) and v.get("api_url") == target
114
+ ]
115
+ if not matches:
116
+ return None
117
+ best = max(matches, key=lambda v: v.get("expires_at") or 0)
118
+ return Credentials(**{f: best.get(f) for f in _CRED_FIELDS})
119
+
120
+
121
+ def clear_credentials() -> bool:
122
+ """Drop credential(s) for the current ``api_url()``. True if anything removed."""
123
+ store = _read_store()
124
+ target = api_url()
125
+ kept = {
126
+ k: v
127
+ for k, v in store["tokens"].items()
128
+ if not (isinstance(v, dict) and v.get("api_url") == target)
129
+ }
130
+ if len(kept) == len(store["tokens"]):
131
+ return False
132
+ store["tokens"] = kept
133
+ _write_store(store)
134
+ return True
@@ -0,0 +1,132 @@
1
+ """OIDC device-flow client — talks directly to Logto.
2
+
3
+ Discovery happens via botu-web (`GET /api/auth/discovery`), so the CLI
4
+ hardcodes no Logto URL or client_id. After polling succeeds we hand the
5
+ token response back to the caller, who saves it via `config.save_credentials`.
6
+
7
+ This is the OIDC-standard pattern: the client connects to the IdP, the
8
+ resource server (botu-web) only validates tokens. See agent-first-cli.md §2.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import time
13
+ import webbrowser
14
+ from dataclasses import dataclass
15
+
16
+ import httpx
17
+
18
+ from .config import api_url
19
+
20
+ # Per RFC 8628 §3.5 we honour the server's `interval`; this is a sane floor.
21
+ _MIN_POLL_INTERVAL_SECONDS = 5
22
+
23
+
24
+ @dataclass
25
+ class DiscoveryInfo:
26
+ issuer: str
27
+ client_id: str
28
+ resource: str
29
+ scopes: list[str]
30
+ device_authorization_endpoint: str
31
+ token_endpoint: str
32
+
33
+
34
+ @dataclass
35
+ class DeviceCodeResponse:
36
+ device_code: str
37
+ user_code: str
38
+ verification_uri: str
39
+ verification_uri_complete: str
40
+ expires_in: int
41
+ interval: int
42
+
43
+
44
+ def fetch_discovery() -> DiscoveryInfo:
45
+ """GET botu-web /api/auth/discovery/ (trailing slash — Next.js trailingSlash)."""
46
+ with httpx.Client(timeout=10.0, follow_redirects=True) as c:
47
+ r = c.get(f"{api_url()}/api/auth/discovery/")
48
+ r.raise_for_status()
49
+ data = r.json()
50
+ return DiscoveryInfo(
51
+ issuer=data["issuer"],
52
+ client_id=data["client_id"],
53
+ resource=data["resource"],
54
+ scopes=data["scopes"],
55
+ device_authorization_endpoint=data["device_authorization_endpoint"],
56
+ token_endpoint=data["token_endpoint"],
57
+ )
58
+
59
+
60
+ def request_device_code(disc: DiscoveryInfo) -> DeviceCodeResponse:
61
+ """POST {device_authorization_endpoint} → device_code + user_code."""
62
+ with httpx.Client(timeout=10.0) as c:
63
+ r = c.post(
64
+ disc.device_authorization_endpoint,
65
+ data={
66
+ "client_id": disc.client_id,
67
+ "scope": " ".join(disc.scopes),
68
+ "resource": disc.resource,
69
+ },
70
+ )
71
+ r.raise_for_status()
72
+ body = r.json()
73
+ return DeviceCodeResponse(
74
+ device_code=body["device_code"],
75
+ user_code=body["user_code"],
76
+ verification_uri=body["verification_uri"],
77
+ verification_uri_complete=body.get(
78
+ "verification_uri_complete", body["verification_uri"]
79
+ ),
80
+ expires_in=body["expires_in"],
81
+ interval=max(body.get("interval", 5), _MIN_POLL_INTERVAL_SECONDS),
82
+ )
83
+
84
+
85
+ def open_browser(url: str) -> bool:
86
+ """Best-effort. False on headless environments with no display."""
87
+ try:
88
+ return webbrowser.open(url)
89
+ except webbrowser.Error:
90
+ return False
91
+
92
+
93
+ def poll_for_token(disc: DiscoveryInfo, code: DeviceCodeResponse, *, on_pending=None) -> dict:
94
+ """Poll {token_endpoint} until success / expired / denied.
95
+
96
+ Returns the raw token response dict. Raises RuntimeError with a
97
+ human-readable message on failure.
98
+ """
99
+ deadline = time.time() + code.expires_in
100
+ interval = code.interval
101
+ with httpx.Client(timeout=10.0) as client:
102
+ while time.time() < deadline:
103
+ time.sleep(interval)
104
+ r = client.post(
105
+ disc.token_endpoint,
106
+ data={
107
+ "client_id": disc.client_id,
108
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
109
+ "device_code": code.device_code,
110
+ "resource": disc.resource,
111
+ },
112
+ )
113
+ if r.is_success:
114
+ return r.json()
115
+ err = (
116
+ r.json().get("error")
117
+ if r.headers.get("content-type", "").startswith("application/json")
118
+ else None
119
+ )
120
+ if err == "authorization_pending":
121
+ if on_pending is not None:
122
+ on_pending()
123
+ continue
124
+ if err == "slow_down":
125
+ interval += 5
126
+ continue
127
+ if err == "expired_token":
128
+ raise RuntimeError("device code expired — run `botu login` again")
129
+ if err == "access_denied":
130
+ raise RuntimeError("login denied by user")
131
+ raise RuntimeError(f"token exchange failed ({r.status_code}): {r.text}")
132
+ raise RuntimeError("device code expired before user completed login")
botu_cli/output.py ADDED
@@ -0,0 +1,66 @@
1
+ """Output helpers — toggle between human-friendly rich tables and --json."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import sys
7
+ from typing import Any
8
+
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ _console = Console()
13
+ _err_console = Console(stderr=True)
14
+
15
+
16
+ def is_json_mode() -> bool:
17
+ """`--json` is set globally via env var (set by the Typer callback)."""
18
+ return os.environ.get("_BOTU_OUTPUT_JSON") == "1"
19
+
20
+
21
+ def emit(payload: Any, *, table_columns: list[tuple[str, str]] | None = None) -> None:
22
+ """Print `payload` as JSON (agent mode) or a rich table / kv list (human mode).
23
+
24
+ `table_columns` is a list of `(header, dict_key)` pairs used when payload
25
+ is a list. Single dicts render as a key/value list; everything else falls
26
+ back to a plain print.
27
+ """
28
+ if is_json_mode():
29
+ print(json.dumps(payload, indent=2, default=str))
30
+ return
31
+
32
+ if isinstance(payload, list) and table_columns:
33
+ table = Table(show_header=True, header_style="bold")
34
+ for header, _ in table_columns:
35
+ table.add_column(header)
36
+ for row in payload:
37
+ table.add_row(*[str(row.get(k, "")) for _, k in table_columns])
38
+ _console.print(table)
39
+ return
40
+
41
+ if isinstance(payload, dict):
42
+ for k, v in payload.items():
43
+ _console.print(f"[bold]{k}[/bold]: {v}")
44
+ return
45
+
46
+ _console.print(payload)
47
+
48
+
49
+ def info(msg: str) -> None:
50
+ if not is_json_mode():
51
+ _console.print(msg)
52
+
53
+
54
+ def success(msg: str) -> None:
55
+ if not is_json_mode():
56
+ # ASCII-safe marker — Windows legacy GBK consoles can't encode U+2713.
57
+ _console.print(f"[green]OK[/green] {msg}")
58
+
59
+
60
+ def error(msg: str, *, code: int = 1) -> None:
61
+ """Print an error and exit. code: 1 user / 2 network / 3 server."""
62
+ if is_json_mode():
63
+ print(json.dumps({"error": msg}, indent=2))
64
+ else:
65
+ _err_console.print(f"[red]error[/red]: {msg}")
66
+ sys.exit(code)
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.4
2
+ Name: botu-cli
3
+ Version: 0.1.0
4
+ Summary: Agent-first CLI for botu — embeddable AI agent for any website
5
+ Project-URL: Homepage, https://botu.io
6
+ Project-URL: Repository, https://github.com/jiangjin11/botu-web
7
+ Author: Paradigx Pte Ltd
8
+ License: MIT
9
+ Keywords: agent,ai,botu,cli,embed,widget
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: httpx>=0.28
22
+ Requires-Dist: rich>=13.0
23
+ Requires-Dist: typer<1.0,>=0.15
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest-mock>=3.14; extra == 'dev'
26
+ Requires-Dist: pytest>=8.3; extra == 'dev'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # botu (Python CLI)
30
+
31
+ Agent-first CLI for [botu](https://botu.io) — the embeddable AI agent for any website.
32
+
33
+ Register a site, get an embed key, verify your domain, and inject the
34
+ `<script>` snippet — all from the command line, no web console needed.
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pip install botu-cli
40
+ # or, run once without installing:
41
+ uvx botu --help
42
+ pipx run botu --help
43
+ ```
44
+
45
+ ## Quickstart
46
+
47
+ ```bash
48
+ botu login # OAuth device-flow, opens browser
49
+ botu sites create --name acme --domain acme.com # create a site + first embed key
50
+ botu embed --site <site-id> --new-key --write index.html # inject the <script>
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
+ botu test --site <site-id> # check the embed key works
54
+ ```
55
+
56
+ All commands accept `--json` (or env `BOTU_JSON=1`) for machine-parseable
57
+ output that's friendly to agents and CI.
58
+
59
+ ## Commands
60
+
61
+ | Command | Purpose |
62
+ |---|---|
63
+ | `botu login` / `logout` / `whoami` | OAuth device-flow session |
64
+ | `botu sites create\|list\|get\|delete` | Manage sites |
65
+ | `botu sites verify <id> --domain <d> [--check]` | Domain ownership (DNS TXT) |
66
+ | `botu keys create\|list\|revoke --site <id>` | Manage embed API keys |
67
+ | `botu embed --site <id>` | Print / write the `<script>` embed snippet |
68
+ | `botu usage [--site <id>]` | Per-site quota and usage |
69
+ | `botu test --site <id>` | Verify an embed key via the loader auth exchange |
70
+
71
+ ### About embed keys
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.
77
+
78
+ ## Configuration
79
+
80
+ | Env var | Default | Purpose |
81
+ |---|---|---|
82
+ | `BOTU_API_URL` | `https://botu.io` | Target deployment (set to `https://qa.botu.io` for QA) |
83
+ | `BOTU_JSON` | — | `1` forces JSON output globally |
84
+
85
+ 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.
88
+
89
+ ## Exit codes
90
+
91
+ `0` ok · `1` user error (4xx) · `2` network error · `3` server error (5xx)
92
+
93
+ ---
94
+
95
+ © 2026 Paradigx. All Rights Reserved.
@@ -0,0 +1,10 @@
1
+ botu_cli/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ botu_cli/__main__.py,sha256=EpOdewe1rp_9VbKQGyCD6_JcMarFDIBdveSOS2ffApU,18410
3
+ botu_cli/client.py,sha256=wnZk0JaVHEqeu7RpKEilVlGtVuhEBhEagUeiD3iA9o4,3061
4
+ botu_cli/config.py,sha256=lw2JaKTDSAYyEmeA_Ldl6zNbSK1AZm7tLJ58rlQh6tc,3831
5
+ botu_cli/device_flow.py,sha256=4KVaLI1zwC8OrtVrPPAhddtTDSdNsS2uhUNnvXvaaec,4389
6
+ botu_cli/output.py,sha256=c24bEfJFPFgfQjfEEaH6vmAIP8iXUV39tjkcAhQLBwo,1979
7
+ botu_cli-0.1.0.dist-info/METADATA,sha256=VK2LD_leij5RWt2qJmjulMVj1CMdj926hKqyzYrG0h8,3427
8
+ botu_cli-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
9
+ botu_cli-0.1.0.dist-info/entry_points.txt,sha256=UYc164c40rjb3K9nm4V7TbUSFBnAOq1LqujSRR84SNw,47
10
+ botu_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ botu = botu_cli.__main__:app