botu-cli 0.1.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {botu_cli-0.1.0 → botu_cli-0.3.0}/PKG-INFO +28 -12
- {botu_cli-0.1.0 → botu_cli-0.3.0}/README.md +26 -10
- {botu_cli-0.1.0 → botu_cli-0.3.0}/pyproject.toml +2 -2
- botu_cli-0.3.0/src/botu_cli/__init__.py +1 -0
- {botu_cli-0.1.0 → botu_cli-0.3.0}/src/botu_cli/__main__.py +209 -85
- botu_cli-0.3.0/src/botu_cli/product.py +56 -0
- botu_cli-0.3.0/tests/test_cli.py +292 -0
- botu_cli-0.1.0/src/botu_cli/__init__.py +0 -1
- botu_cli-0.1.0/src/botu_cli/client.py +0 -96
- botu_cli-0.1.0/src/botu_cli/config.py +0 -134
- botu_cli-0.1.0/src/botu_cli/device_flow.py +0 -132
- botu_cli-0.1.0/src/botu_cli/output.py +0 -66
- botu_cli-0.1.0/tests/test_cli.py +0 -464
- {botu_cli-0.1.0 → botu_cli-0.3.0}/.gitignore +0 -0
- {botu_cli-0.1.0 → botu_cli-0.3.0}/tests/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: botu-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Agent-first CLI for botu — embeddable AI agent for any website
|
|
5
5
|
Project-URL: Homepage, https://botu.io
|
|
6
6
|
Project-URL: Repository, https://github.com/jiangjin11/botu-web
|
|
@@ -19,7 +19,7 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
19
19
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
20
|
Requires-Python: >=3.10
|
|
21
21
|
Requires-Dist: httpx>=0.28
|
|
22
|
-
Requires-Dist:
|
|
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
|
-
|
|
71
|
+
## Non-interactive use (CI / headless agents)
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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 (
|
|
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`) —
|
|
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
|
-
|
|
43
|
+
## Non-interactive use (CI / headless agents)
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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 (
|
|
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`) —
|
|
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.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Agent-first CLI for botu — embeddable AI agent for any website"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
@@ -21,9 +21,9 @@ classifiers = [
|
|
|
21
21
|
]
|
|
22
22
|
|
|
23
23
|
dependencies = [
|
|
24
|
+
"paradigx-cli-core>=0.1,<0.2",
|
|
24
25
|
"typer>=0.15,<1.0",
|
|
25
26
|
"httpx>=0.28",
|
|
26
|
-
"rich>=13.0",
|
|
27
27
|
]
|
|
28
28
|
|
|
29
29
|
[project.optional-dependencies]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
|
@@ -1,28 +1,23 @@
|
|
|
1
1
|
"""botu CLI entry point — agent-first provisioning for the botu embed agent.
|
|
2
2
|
|
|
3
|
-
botu login
|
|
4
|
-
botu
|
|
5
|
-
botu
|
|
6
|
-
botu
|
|
7
|
-
botu
|
|
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>
|
|
9
|
+
botu test --site <id>
|
|
15
10
|
|
|
16
|
-
All commands accept `--json` (or env BOTU_JSON=1)
|
|
17
|
-
|
|
18
|
-
|
|
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 .
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
172
|
+
out = api("GET", "/api/sites")
|
|
186
173
|
except ApiError as e:
|
|
187
174
|
_fail(e)
|
|
188
175
|
sites = out.get("sites", []) if isinstance(out, dict) else out
|
|
@@ -203,7 +190,93 @@ def sites_list() -> None:
|
|
|
203
190
|
def sites_get(site_id: str = typer.Argument(..., help="Site id (uuid).")) -> None:
|
|
204
191
|
"""Show one site with all its (masked) keys."""
|
|
205
192
|
try:
|
|
206
|
-
out =
|
|
193
|
+
out = api("GET", f"/api/sites/{site_id}")
|
|
194
|
+
except ApiError as e:
|
|
195
|
+
_fail(e)
|
|
196
|
+
emit(out)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@sites_app.command("update")
|
|
200
|
+
def sites_update(
|
|
201
|
+
site_id: str = typer.Argument(..., help="Site id (uuid)."),
|
|
202
|
+
name: str = typer.Option(None, "--name", "-n", help="New name."),
|
|
203
|
+
description: str = typer.Option(None, "--description", help="New description."),
|
|
204
|
+
add_origin: list[str] = typer.Option(
|
|
205
|
+
None,
|
|
206
|
+
"--add-origin",
|
|
207
|
+
help="Add an allowed origin (repeatable). Merges with existing origins.",
|
|
208
|
+
),
|
|
209
|
+
remove_origin: list[str] = typer.Option(
|
|
210
|
+
None,
|
|
211
|
+
"--remove-origin",
|
|
212
|
+
help="Remove an allowed origin (repeatable). Merges with existing origins.",
|
|
213
|
+
),
|
|
214
|
+
set_origins: list[str] = typer.Option(
|
|
215
|
+
None,
|
|
216
|
+
"--set-origin",
|
|
217
|
+
help=(
|
|
218
|
+
"Replace allowed origins (repeatable). Mutually exclusive with "
|
|
219
|
+
"--add-origin / --remove-origin."
|
|
220
|
+
),
|
|
221
|
+
),
|
|
222
|
+
theme_mode: str = typer.Option(
|
|
223
|
+
None,
|
|
224
|
+
"--theme-mode",
|
|
225
|
+
help="Theme detection mode: auto | light | dark.",
|
|
226
|
+
),
|
|
227
|
+
theme_target: str = typer.Option(
|
|
228
|
+
None,
|
|
229
|
+
"--theme-target",
|
|
230
|
+
help="Theme target element selector: html | body | <custom-css-selector>.",
|
|
231
|
+
),
|
|
232
|
+
) -> None:
|
|
233
|
+
"""Update an existing site (name / description / allowed origins / theme).
|
|
234
|
+
|
|
235
|
+
Origins:
|
|
236
|
+
• --add-origin / --remove-origin merge against the current allowlist
|
|
237
|
+
(1 GET + 1 PATCH); use these for incremental changes.
|
|
238
|
+
• --set-origin replaces the list outright. Mixing modes is an error.
|
|
239
|
+
"""
|
|
240
|
+
if set_origins and (add_origin or remove_origin):
|
|
241
|
+
_fail(
|
|
242
|
+
ApiError(
|
|
243
|
+
400,
|
|
244
|
+
"--set-origin cannot combine with --add-origin / --remove-origin",
|
|
245
|
+
)
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
patch: dict = {}
|
|
249
|
+
if name is not None:
|
|
250
|
+
patch["name"] = name
|
|
251
|
+
if description is not None:
|
|
252
|
+
patch["description"] = description
|
|
253
|
+
if theme_mode is not None or theme_target is not None:
|
|
254
|
+
patch["themeStrategy"] = {
|
|
255
|
+
**({"mode": theme_mode} if theme_mode is not None else {}),
|
|
256
|
+
**({"target": theme_target} if theme_target is not None else {}),
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
needs_origin_merge = bool(add_origin or remove_origin)
|
|
260
|
+
if set_origins:
|
|
261
|
+
patch["allowedOrigins"] = [o.strip() for o in set_origins if o and o.strip()]
|
|
262
|
+
elif needs_origin_merge:
|
|
263
|
+
try:
|
|
264
|
+
current = api("GET", f"/api/sites/{site_id}")
|
|
265
|
+
except ApiError as e:
|
|
266
|
+
_fail(e)
|
|
267
|
+
existing = list((current.get("site") or {}).get("allowed_origins") or [])
|
|
268
|
+
merged = [o for o in existing if o not in (remove_origin or [])]
|
|
269
|
+
for o in add_origin or []:
|
|
270
|
+
o_clean = o.strip()
|
|
271
|
+
if o_clean and o_clean not in merged:
|
|
272
|
+
merged.append(o_clean)
|
|
273
|
+
patch["allowedOrigins"] = merged
|
|
274
|
+
|
|
275
|
+
if not patch:
|
|
276
|
+
_fail(ApiError(400, "nothing to update — supply at least one field"))
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
out = api("PATCH", f"/api/sites/{site_id}", patch)
|
|
207
280
|
except ApiError as e:
|
|
208
281
|
_fail(e)
|
|
209
282
|
emit(out)
|
|
@@ -217,7 +290,7 @@ def sites_delete(
|
|
|
217
290
|
"""Delete a site (cascades to its keys, users and usage)."""
|
|
218
291
|
_confirm(f"Delete site {site_id} and all its keys?", yes)
|
|
219
292
|
try:
|
|
220
|
-
out =
|
|
293
|
+
out = api("DELETE", f"/api/sites/{site_id}")
|
|
221
294
|
except ApiError as e:
|
|
222
295
|
_fail(e)
|
|
223
296
|
emit(out or {"ok": True})
|
|
@@ -234,9 +307,7 @@ def sites_verify(
|
|
|
234
307
|
"""Verify domain ownership. Without --check: start it and print DNS instructions."""
|
|
235
308
|
if check:
|
|
236
309
|
try:
|
|
237
|
-
out =
|
|
238
|
-
"POST", f"/api/sites/{site_id}/verify-domain/check", json_body={"domain": domain}
|
|
239
|
-
)
|
|
310
|
+
out = api("POST", f"/api/sites/{site_id}/verify-domain/check", {"domain": domain})
|
|
240
311
|
except ApiError as e:
|
|
241
312
|
_fail(e)
|
|
242
313
|
emit(out)
|
|
@@ -245,9 +316,7 @@ def sites_verify(
|
|
|
245
316
|
return
|
|
246
317
|
|
|
247
318
|
try:
|
|
248
|
-
out =
|
|
249
|
-
"POST", f"/api/sites/{site_id}/verify-domain/init", json_body={"domain": domain}
|
|
250
|
-
)
|
|
319
|
+
out = api("POST", f"/api/sites/{site_id}/verify-domain/init", {"domain": domain})
|
|
251
320
|
except ApiError as e:
|
|
252
321
|
_fail(e)
|
|
253
322
|
emit(out)
|
|
@@ -272,9 +341,7 @@ def keys_create(
|
|
|
272
341
|
) -> None:
|
|
273
342
|
"""Create a new embed API key. The plaintext key is shown ONCE."""
|
|
274
343
|
try:
|
|
275
|
-
out =
|
|
276
|
-
"POST", f"/api/sites/{site_id}/keys", json_body={"label": label, "test": test}
|
|
277
|
-
)
|
|
344
|
+
out = api("POST", f"/api/sites/{site_id}/keys", {"label": label, "test": test})
|
|
278
345
|
except ApiError as e:
|
|
279
346
|
_fail(e)
|
|
280
347
|
emit(out)
|
|
@@ -288,7 +355,7 @@ def keys_list(
|
|
|
288
355
|
) -> None:
|
|
289
356
|
"""List a site's API keys (raw values never shown)."""
|
|
290
357
|
try:
|
|
291
|
-
out =
|
|
358
|
+
out = api("GET", f"/api/sites/{site_id}")
|
|
292
359
|
except ApiError as e:
|
|
293
360
|
_fail(e)
|
|
294
361
|
emit(
|
|
@@ -313,9 +380,69 @@ def keys_revoke(
|
|
|
313
380
|
"""Revoke an API key. Calls embedding it will then fail."""
|
|
314
381
|
_confirm(f"Revoke key {key_id} on site {site_id}?", yes)
|
|
315
382
|
try:
|
|
316
|
-
out =
|
|
317
|
-
|
|
318
|
-
)
|
|
383
|
+
out = api("POST", f"/api/sites/{site_id}/keys/revoke", {"keyId": key_id})
|
|
384
|
+
except ApiError as e:
|
|
385
|
+
_fail(e)
|
|
386
|
+
emit(out or {"ok": True})
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
# ─── tokens (account-level PATs) ─────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@tokens_app.command("create")
|
|
393
|
+
def tokens_create(
|
|
394
|
+
name: str = typer.Option(..., "--name", "-n", help="Token label."),
|
|
395
|
+
expires_days: int = typer.Option(
|
|
396
|
+
None, "--expires-days", help="Expire after N days (default: never)."
|
|
397
|
+
),
|
|
398
|
+
) -> None:
|
|
399
|
+
"""Create an account access token (PAT). The plaintext is shown ONCE.
|
|
400
|
+
|
|
401
|
+
Use it non-interactively via the BOTU_TOKEN env var — for CI / agents.
|
|
402
|
+
"""
|
|
403
|
+
body: dict = {"name": name}
|
|
404
|
+
if expires_days is not None:
|
|
405
|
+
body["expiresInDays"] = expires_days
|
|
406
|
+
try:
|
|
407
|
+
out = api("POST", "/api/tokens", body)
|
|
408
|
+
except ApiError as e:
|
|
409
|
+
_fail(e)
|
|
410
|
+
emit(out)
|
|
411
|
+
if not is_json_mode():
|
|
412
|
+
info("\n[yellow]Save the `token` — the plaintext is shown only once.[/yellow]")
|
|
413
|
+
info("[dim]Use it as: export BOTU_TOKEN=<token>[/dim]")
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
@tokens_app.command("list")
|
|
417
|
+
def tokens_list() -> None:
|
|
418
|
+
"""List your account access tokens (plaintext never shown)."""
|
|
419
|
+
try:
|
|
420
|
+
out = api("GET", "/api/tokens")
|
|
421
|
+
except ApiError as e:
|
|
422
|
+
_fail(e)
|
|
423
|
+
emit(
|
|
424
|
+
out.get("tokens", []),
|
|
425
|
+
table_columns=[
|
|
426
|
+
("ID", "id"),
|
|
427
|
+
("Name", "name"),
|
|
428
|
+
("Prefix", "token_prefix"),
|
|
429
|
+
("Last used", "last_used_at"),
|
|
430
|
+
("Revoked", "revoked_at"),
|
|
431
|
+
("Expires", "expires_at"),
|
|
432
|
+
("Created", "created_at"),
|
|
433
|
+
],
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
@tokens_app.command("revoke")
|
|
438
|
+
def tokens_revoke(
|
|
439
|
+
token_id: str = typer.Argument(..., help="Token id (uuid, from `tokens list`)."),
|
|
440
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
|
|
441
|
+
) -> None:
|
|
442
|
+
"""Revoke an account access token."""
|
|
443
|
+
_confirm(f"Revoke token {token_id}?", yes)
|
|
444
|
+
try:
|
|
445
|
+
out = api("POST", f"/api/tokens/{token_id}/revoke")
|
|
319
446
|
except ApiError as e:
|
|
320
447
|
_fail(e)
|
|
321
448
|
emit(out or {"ok": True})
|
|
@@ -381,11 +508,10 @@ def embed(
|
|
|
381
508
|
"""Print the <script> embed snippet — or inject it into an HTML file.
|
|
382
509
|
|
|
383
510
|
Key resolution: --key wins; else --new-key mints one; else the snippet
|
|
384
|
-
uses a `<YOUR_API_KEY>` placeholder
|
|
385
|
-
never retrievable — mint a new one or pass --key).
|
|
511
|
+
uses a `<YOUR_API_KEY>` placeholder.
|
|
386
512
|
"""
|
|
387
513
|
try:
|
|
388
|
-
detail =
|
|
514
|
+
detail = api("GET", f"/api/sites/{site_id}")
|
|
389
515
|
except ApiError as e:
|
|
390
516
|
_fail(e)
|
|
391
517
|
site = detail.get("site") or {}
|
|
@@ -393,9 +519,7 @@ def embed(
|
|
|
393
519
|
api_key = key
|
|
394
520
|
if not api_key and new_key:
|
|
395
521
|
try:
|
|
396
|
-
out =
|
|
397
|
-
"POST", f"/api/sites/{site_id}/keys", json_body={"label": "embed", "test": False}
|
|
398
|
-
)
|
|
522
|
+
out = api("POST", f"/api/sites/{site_id}/keys", {"label": "embed", "test": False})
|
|
399
523
|
except ApiError as e:
|
|
400
524
|
_fail(e)
|
|
401
525
|
api_key = out.get("apiKey")
|
|
@@ -413,7 +537,11 @@ def embed(
|
|
|
413
537
|
action = "replaced"
|
|
414
538
|
elif re.search(r"</body>", html, re.IGNORECASE):
|
|
415
539
|
new_html = re.sub(
|
|
416
|
-
r"</body>",
|
|
540
|
+
r"</body>",
|
|
541
|
+
lambda _m: f"{snippet}\n{_m.group(0)}",
|
|
542
|
+
html,
|
|
543
|
+
count=1,
|
|
544
|
+
flags=re.IGNORECASE,
|
|
417
545
|
)
|
|
418
546
|
action = "injected"
|
|
419
547
|
else:
|
|
@@ -451,7 +579,7 @@ def usage(
|
|
|
451
579
|
"""Per-site quota and usage."""
|
|
452
580
|
path = "/api/usage" + (f"?site={site_id}" if site_id else "")
|
|
453
581
|
try:
|
|
454
|
-
out =
|
|
582
|
+
out = api("GET", path)
|
|
455
583
|
except ApiError as e:
|
|
456
584
|
_fail(e)
|
|
457
585
|
if is_json_mode():
|
|
@@ -484,11 +612,7 @@ def test(
|
|
|
484
612
|
minted = False
|
|
485
613
|
if not api_key:
|
|
486
614
|
try:
|
|
487
|
-
out =
|
|
488
|
-
"POST",
|
|
489
|
-
f"/api/sites/{site_id}/keys",
|
|
490
|
-
json_body={"label": "cli-test", "test": True},
|
|
491
|
-
)
|
|
615
|
+
out = api("POST", f"/api/sites/{site_id}/keys", {"label": "cli-test", "test": True})
|
|
492
616
|
except ApiError as e:
|
|
493
617
|
_fail(e)
|
|
494
618
|
api_key = out.get("apiKey")
|