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