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 +1 -0
- botu_cli/__main__.py +535 -0
- botu_cli/client.py +96 -0
- botu_cli/config.py +134 -0
- botu_cli/device_flow.py +132 -0
- botu_cli/output.py +66 -0
- botu_cli-0.1.0.dist-info/METADATA +95 -0
- botu_cli-0.1.0.dist-info/RECORD +10 -0
- botu_cli-0.1.0.dist-info/WHEEL +4 -0
- botu_cli-0.1.0.dist-info/entry_points.txt +2 -0
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
|
botu_cli/device_flow.py
ADDED
|
@@ -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,,
|