botable-dev 0.3.1__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.
- botable/__init__.py +3 -0
- botable/__main__.py +5 -0
- botable/api_client.py +203 -0
- botable/cli.py +781 -0
- botable/config.py +193 -0
- botable/errors.py +53 -0
- botable/generate.py +288 -0
- botable/mcp_install.py +72 -0
- botable/prompts.py +137 -0
- botable/runner.py +333 -0
- botable/scaffold.py +104 -0
- botable/ui.py +48 -0
- botable_dev-0.3.1.dist-info/METADATA +186 -0
- botable_dev-0.3.1.dist-info/RECORD +19 -0
- botable_dev-0.3.1.dist-info/WHEEL +4 -0
- botable_dev-0.3.1.dist-info/entry_points.txt +3 -0
- botable_dev-0.3.1.dist-info/licenses/LICENSE +21 -0
- botable_mcp/__init__.py +3 -0
- botable_mcp/server.py +227 -0
botable/cli.py
ADDED
|
@@ -0,0 +1,781 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Botable CLI — main Typer application.
|
|
3
|
+
|
|
4
|
+
All commands are registered here. Heavy logic lives in dedicated modules.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import platform
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
import socket
|
|
15
|
+
import subprocess
|
|
16
|
+
import time
|
|
17
|
+
import webbrowser
|
|
18
|
+
from contextlib import nullcontext
|
|
19
|
+
from typing import Any, Callable, Coroutine, Dict, Optional, TypeVar
|
|
20
|
+
|
|
21
|
+
import typer
|
|
22
|
+
from rich.console import Console
|
|
23
|
+
|
|
24
|
+
from botable import __version__
|
|
25
|
+
from botable.ui import banner, success, error, info
|
|
26
|
+
|
|
27
|
+
app = typer.Typer(
|
|
28
|
+
name="botable",
|
|
29
|
+
help="⚡ Generate Telegram bots with AI — from idea to running bot in 3 minutes.",
|
|
30
|
+
no_args_is_help=True,
|
|
31
|
+
rich_markup_mode="rich",
|
|
32
|
+
)
|
|
33
|
+
console = Console()
|
|
34
|
+
|
|
35
|
+
T = TypeVar("T")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ── shared helpers ───────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
def _require_auth() -> str:
|
|
41
|
+
"""Return the auth token or exit with a helpful error."""
|
|
42
|
+
from botable.config import get_auth_token
|
|
43
|
+
|
|
44
|
+
token = get_auth_token()
|
|
45
|
+
if not token:
|
|
46
|
+
error("Not logged in. Run [cyan]botable login --api-key YOUR_KEY[/]")
|
|
47
|
+
raise typer.Exit(1)
|
|
48
|
+
return token
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _run_async(
|
|
52
|
+
coro_fn: Callable[[], Coroutine[Any, Any, T]],
|
|
53
|
+
status_msg: str = "Working…",
|
|
54
|
+
error_prefix: str = "Operation failed",
|
|
55
|
+
) -> T:
|
|
56
|
+
"""Run an async coroutine with a Rich spinner and consistent error handling.
|
|
57
|
+
|
|
58
|
+
Catches httpx errors and extracts backend detail messages.
|
|
59
|
+
"""
|
|
60
|
+
import httpx
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
with (console.status(f"[bold cyan]{status_msg}") if status_msg else nullcontext()):
|
|
64
|
+
return asyncio.run(coro_fn())
|
|
65
|
+
except httpx.HTTPStatusError as e:
|
|
66
|
+
from botable.errors import APIError
|
|
67
|
+
api_err = APIError.from_httpx(e)
|
|
68
|
+
error(f"{error_prefix}: {api_err.detail}")
|
|
69
|
+
raise typer.Exit(1)
|
|
70
|
+
except (httpx.ConnectError, httpx.ConnectTimeout):
|
|
71
|
+
error(f"{error_prefix}: Cannot reach Botable API. Check your connection.")
|
|
72
|
+
raise typer.Exit(1)
|
|
73
|
+
except Exception as e:
|
|
74
|
+
error(f"{error_prefix}: {e}")
|
|
75
|
+
raise typer.Exit(1)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ── version ──────────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
@app.command()
|
|
81
|
+
def version():
|
|
82
|
+
"""Show the Botable CLI version."""
|
|
83
|
+
from botable.config import get_api_url
|
|
84
|
+
|
|
85
|
+
console.print(f"[bold cyan]botable[/] v{__version__}")
|
|
86
|
+
console.print(f"API URL: {get_api_url()}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ── init ─────────────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
@app.command()
|
|
92
|
+
def init(
|
|
93
|
+
name: str = typer.Argument("my-bot", help="Name of the bot project to create"),
|
|
94
|
+
):
|
|
95
|
+
"""Scaffold a new bot project with botable.toml and .env."""
|
|
96
|
+
from botable.scaffold import scaffold_project
|
|
97
|
+
|
|
98
|
+
banner()
|
|
99
|
+
try:
|
|
100
|
+
project_dir = scaffold_project(name)
|
|
101
|
+
success(f"Project [bold]{name}[/] created at [cyan]{project_dir}[/]")
|
|
102
|
+
info("Next steps:")
|
|
103
|
+
console.print(" 1. [cyan]cd {name}[/]".format(name=name))
|
|
104
|
+
console.print(' 2. Edit [yellow].env[/] — add your BOT_TOKEN')
|
|
105
|
+
console.print(' 3. [cyan]botable generate "your bot description"[/]')
|
|
106
|
+
except Exception as e:
|
|
107
|
+
error(f"Failed to create project: {e}")
|
|
108
|
+
raise typer.Exit(1)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ── generate ─────────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
@app.command()
|
|
114
|
+
def generate(
|
|
115
|
+
description: str = typer.Argument(..., help="Natural language description of the bot to generate"),
|
|
116
|
+
model: str = typer.Option("gpt-4o-mini", "--model", "-m", help="OpenAI model to use for generation"),
|
|
117
|
+
):
|
|
118
|
+
"""Generate a Telegram bot from a natural language description."""
|
|
119
|
+
from botable.generate import run_generate
|
|
120
|
+
|
|
121
|
+
banner()
|
|
122
|
+
info(f"Generating bot: [italic]{description}[/]")
|
|
123
|
+
console.print()
|
|
124
|
+
|
|
125
|
+
ok = run_generate(description, model=model)
|
|
126
|
+
if not ok:
|
|
127
|
+
raise typer.Exit(1)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ── dev ──────────────────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
@app.command()
|
|
133
|
+
def dev(
|
|
134
|
+
watch: bool = typer.Option(False, "--watch", "-w", help="Enable hot reload on file changes"),
|
|
135
|
+
):
|
|
136
|
+
"""Run the bot locally with live output."""
|
|
137
|
+
from botable.runner import run_dev
|
|
138
|
+
|
|
139
|
+
banner()
|
|
140
|
+
exit_code = run_dev(watch=watch)
|
|
141
|
+
raise typer.Exit(exit_code)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ── deploy ───────────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
@app.command()
|
|
147
|
+
def deploy(json_output: bool = typer.Option(False, "--json", help="Print deployment result as JSON")):
|
|
148
|
+
"""Deploy bot to Botable cloud."""
|
|
149
|
+
import io
|
|
150
|
+
import tarfile
|
|
151
|
+
from pathlib import Path
|
|
152
|
+
|
|
153
|
+
from botable.config import load_project_config
|
|
154
|
+
from botable.api_client import BotableClient
|
|
155
|
+
from botable.ui import warn
|
|
156
|
+
|
|
157
|
+
if not json_output:
|
|
158
|
+
banner()
|
|
159
|
+
|
|
160
|
+
# ── 1. Validate project ──────────────────────────────────────────
|
|
161
|
+
config = load_project_config()
|
|
162
|
+
if config is None:
|
|
163
|
+
error("No [cyan]botable.toml[/] found. Run [cyan]botable init[/] first.")
|
|
164
|
+
raise typer.Exit(1)
|
|
165
|
+
|
|
166
|
+
project_root = Path(config.get("_project_root", "."))
|
|
167
|
+
main_py = project_root / "main.py"
|
|
168
|
+
if not main_py.exists():
|
|
169
|
+
error("[cyan]main.py[/] not found. Generate your bot first: [cyan]botable generate \"...\"[/]")
|
|
170
|
+
raise typer.Exit(1)
|
|
171
|
+
|
|
172
|
+
# ── 2. Check auth ────────────────────────────────────────────────
|
|
173
|
+
token = _require_auth()
|
|
174
|
+
|
|
175
|
+
# ── 3. Create tarball ────────────────────────────────────────────
|
|
176
|
+
project_name = config.get("project", {}).get("name", project_root.name)
|
|
177
|
+
if not json_output:
|
|
178
|
+
info(f"Deploying [bold]{project_name}[/]…\n")
|
|
179
|
+
|
|
180
|
+
FILES_TO_PACK = ["main.py", "requirements.txt", ".env", "botable.toml"]
|
|
181
|
+
buf = io.BytesIO()
|
|
182
|
+
packed = []
|
|
183
|
+
|
|
184
|
+
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
|
185
|
+
for name in FILES_TO_PACK:
|
|
186
|
+
fpath = project_root / name
|
|
187
|
+
if fpath.exists():
|
|
188
|
+
tar.add(str(fpath), arcname=name)
|
|
189
|
+
packed.append(name)
|
|
190
|
+
|
|
191
|
+
tarball = buf.getvalue()
|
|
192
|
+
if not json_output:
|
|
193
|
+
console.print(f" 📦 Packed {len(packed)} files ({len(tarball) // 1024}KB)\n")
|
|
194
|
+
|
|
195
|
+
# ── 4. Upload & deploy ───────────────────────────────────────────
|
|
196
|
+
async def _deploy():
|
|
197
|
+
client = BotableClient(api_key=token)
|
|
198
|
+
try:
|
|
199
|
+
return await client.deploy_project(tarball)
|
|
200
|
+
finally:
|
|
201
|
+
await client.close()
|
|
202
|
+
|
|
203
|
+
result = _run_async(_deploy, "" if json_output else "Uploading & deploying…", "Deploy failed")
|
|
204
|
+
|
|
205
|
+
# ── 5. Show result ───────────────────────────────────────────────
|
|
206
|
+
data = result.get("data", result)
|
|
207
|
+
deploy_status = data.get("deployment_status", data.get("state", "unknown"))
|
|
208
|
+
project_id = (data.get("project_id") or data.get("id") or result.get("project_id")
|
|
209
|
+
or config.get("project", {}).get("id"))
|
|
210
|
+
|
|
211
|
+
if json_output:
|
|
212
|
+
typer.echo(json.dumps({
|
|
213
|
+
"project_id": project_id,
|
|
214
|
+
"state": deploy_status,
|
|
215
|
+
"message": data.get("message", result.get("message", "")),
|
|
216
|
+
}))
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
if deploy_status in ("success", "queued"):
|
|
220
|
+
success(f"Project [bold]{data.get('project_name', project_name)}[/] deployed!")
|
|
221
|
+
console.print()
|
|
222
|
+
|
|
223
|
+
if deploy_status == "queued":
|
|
224
|
+
info("Deployment queued — bot will start shortly.")
|
|
225
|
+
else:
|
|
226
|
+
info("Bot is running. ✨")
|
|
227
|
+
|
|
228
|
+
console.print()
|
|
229
|
+
console.print(" [dim]View status:[/] [cyan]botable bots list[/]")
|
|
230
|
+
console.print(" [dim]Stream logs:[/] [cyan]botable logs -f[/]")
|
|
231
|
+
else:
|
|
232
|
+
warn(f"Deploy returned status: {deploy_status}")
|
|
233
|
+
console.print(f" [dim]{data}[/]")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ── logs ─────────────────────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
@app.command()
|
|
240
|
+
def logs(
|
|
241
|
+
project_id: str = typer.Argument(None, help="Project ID (defaults to most recent bot)"),
|
|
242
|
+
follow: bool = typer.Option(False, "--follow", "-f", help="Stream logs in real-time"),
|
|
243
|
+
json_output: bool = typer.Option(False, "--json", help="Print status snapshot as JSON"),
|
|
244
|
+
):
|
|
245
|
+
"""Show a deployed bot's status snapshot (live log streaming is unavailable)."""
|
|
246
|
+
from botable.api_client import BotableClient
|
|
247
|
+
|
|
248
|
+
token = _require_auth()
|
|
249
|
+
|
|
250
|
+
async def _get_status():
|
|
251
|
+
client = BotableClient(api_key=token)
|
|
252
|
+
try:
|
|
253
|
+
pid = project_id
|
|
254
|
+
if not pid:
|
|
255
|
+
result = await client.list_bots()
|
|
256
|
+
bots = result.get("data", {}).get("bots", [])
|
|
257
|
+
if not bots:
|
|
258
|
+
return None
|
|
259
|
+
pid = bots[0]["id"]
|
|
260
|
+
|
|
261
|
+
status = await client.get_bot_status(pid)
|
|
262
|
+
return {"project_id": pid, **status.get("data", status)}
|
|
263
|
+
finally:
|
|
264
|
+
await client.close()
|
|
265
|
+
|
|
266
|
+
result = _run_async(_get_status, "" if json_output else "Fetching status…", "Failed to get logs")
|
|
267
|
+
|
|
268
|
+
if json_output:
|
|
269
|
+
typer.echo(json.dumps(result))
|
|
270
|
+
return
|
|
271
|
+
|
|
272
|
+
if result is None:
|
|
273
|
+
info("No deployed bots found. Run [cyan]botable deploy[/] first.")
|
|
274
|
+
raise typer.Exit(0)
|
|
275
|
+
|
|
276
|
+
banner()
|
|
277
|
+
pid = result.get("project_id", "?")
|
|
278
|
+
state = result.get("current_state", "unknown")
|
|
279
|
+
running = result.get("is_running", False)
|
|
280
|
+
|
|
281
|
+
status_icon = "🟢" if running else "🔴"
|
|
282
|
+
console.print(f" {status_icon} Project [bold]{pid}[/]")
|
|
283
|
+
console.print(f" State: [cyan]{state}[/] | Running: {'yes' if running else 'no'}")
|
|
284
|
+
console.print()
|
|
285
|
+
|
|
286
|
+
if follow:
|
|
287
|
+
info("Live log streaming will be available in a future release.")
|
|
288
|
+
info("For now, use [cyan]botable logs[/] to check current status.")
|
|
289
|
+
else:
|
|
290
|
+
status_data = result.get("status", {})
|
|
291
|
+
if status_data:
|
|
292
|
+
for k, v in status_data.items():
|
|
293
|
+
if k not in ("current_state", "is_bot_running", "project_id"):
|
|
294
|
+
console.print(f" [dim]{k}:[/] {v}")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# ── login ────────────────────────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
@app.command()
|
|
300
|
+
def login(
|
|
301
|
+
api_key: str = typer.Option("", "--api-key", "-k", help="API key for authentication"),
|
|
302
|
+
api_url: str = typer.Option("", "--api-url", help="Persist the Botable API base URL"),
|
|
303
|
+
no_browser: bool = typer.Option(False, "--no-browser", help="Do not open the verification page"),
|
|
304
|
+
):
|
|
305
|
+
"""Authenticate with Botable cloud."""
|
|
306
|
+
import httpx
|
|
307
|
+
from botable.config import save_global_config, load_global_config, get_api_url
|
|
308
|
+
from urllib.parse import urlsplit
|
|
309
|
+
|
|
310
|
+
def valid_url(value: str) -> bool:
|
|
311
|
+
parsed = urlsplit(value)
|
|
312
|
+
return (parsed.scheme in ("http", "https") and bool(parsed.hostname)
|
|
313
|
+
and not parsed.username and not parsed.password and not parsed.query
|
|
314
|
+
and not parsed.fragment and parsed.path in ("", "/"))
|
|
315
|
+
|
|
316
|
+
if api_url and not valid_url(api_url):
|
|
317
|
+
error("Invalid API URL. Use an http(s) origin such as https://api-staging.botable.dev")
|
|
318
|
+
raise typer.Exit(1)
|
|
319
|
+
|
|
320
|
+
if api_key or api_url:
|
|
321
|
+
config = load_global_config()
|
|
322
|
+
if api_key:
|
|
323
|
+
config["auth_token"] = api_key
|
|
324
|
+
if api_url:
|
|
325
|
+
config["api_url"] = api_url.rstrip("/")
|
|
326
|
+
save_global_config(config)
|
|
327
|
+
success("Settings saved to [cyan]~/.config/botable/config.toml[/]")
|
|
328
|
+
info(f"API URL: {get_api_url()}")
|
|
329
|
+
return
|
|
330
|
+
|
|
331
|
+
base_url = api_url.rstrip("/") if api_url else get_api_url().rstrip("/")
|
|
332
|
+
hostname = re.sub(r"[^A-Za-z0-9.-]", "-", socket.gethostname())[:65].strip(".-") or "unknown"
|
|
333
|
+
try:
|
|
334
|
+
with httpx.Client(base_url=base_url, timeout=10) as client:
|
|
335
|
+
response = client.post("/api/v1/cli/auth/device", json={"client_name": f"botable CLI on {hostname}"})
|
|
336
|
+
response.raise_for_status()
|
|
337
|
+
device = response.json()
|
|
338
|
+
typer.echo(f"Code: {device['user_code']}")
|
|
339
|
+
typer.echo(f"Open: {device['verification_uri_complete']}")
|
|
340
|
+
typer.echo(f"Another browser: {device['verification_uri']} and enter code {device['user_code']}")
|
|
341
|
+
has_display = os.name == "nt" or (platform.system() == "Darwin" and not os.getenv("SSH_CONNECTION") and not os.getenv("CI")) or bool(os.getenv("DISPLAY") or os.getenv("WAYLAND_DISPLAY"))
|
|
342
|
+
if not no_browser and has_display:
|
|
343
|
+
webbrowser.open(device["verification_uri_complete"])
|
|
344
|
+
|
|
345
|
+
interval = max(float(device["interval"]), 1)
|
|
346
|
+
deadline = time.monotonic() + float(device["expires_in"])
|
|
347
|
+
while time.monotonic() < deadline:
|
|
348
|
+
time.sleep(min(interval, max(0, deadline - time.monotonic())))
|
|
349
|
+
response = client.post("/api/v1/cli/auth/token", json={"device_code": device["device_code"]})
|
|
350
|
+
if response.status_code == 200:
|
|
351
|
+
result = response.json()
|
|
352
|
+
returned_url = result.get("api_url", "").rstrip("/").removesuffix("/api/v1")
|
|
353
|
+
if returned_url and not valid_url(returned_url):
|
|
354
|
+
raise ValueError("Invalid API URL returned by server")
|
|
355
|
+
config = load_global_config()
|
|
356
|
+
config["auth_token"] = result["api_key"]
|
|
357
|
+
if returned_url or api_url:
|
|
358
|
+
config["api_url"] = (returned_url or api_url).rstrip("/")
|
|
359
|
+
save_global_config(config)
|
|
360
|
+
typer.echo(f"Logged in as {result['user']['email']} → {get_api_url()}")
|
|
361
|
+
return
|
|
362
|
+
code, message = _device_error(response)
|
|
363
|
+
if response.status_code == 428 and code == "authorization_pending":
|
|
364
|
+
continue
|
|
365
|
+
if response.status_code == 429:
|
|
366
|
+
interval += 5
|
|
367
|
+
continue
|
|
368
|
+
if response.status_code == 403 and code == "access_denied":
|
|
369
|
+
error("Login denied.")
|
|
370
|
+
elif response.status_code == 410 and code == "expired_token":
|
|
371
|
+
error("Login code expired.")
|
|
372
|
+
else:
|
|
373
|
+
error(f"Login failed (HTTP {response.status_code}): {message}")
|
|
374
|
+
raise typer.Exit(1)
|
|
375
|
+
error("Login code expired.")
|
|
376
|
+
raise typer.Exit(1)
|
|
377
|
+
except KeyboardInterrupt:
|
|
378
|
+
info("Login cancelled.")
|
|
379
|
+
raise typer.Exit(130)
|
|
380
|
+
except httpx.HTTPStatusError as exc:
|
|
381
|
+
_, detail = _device_error(exc.response)
|
|
382
|
+
error(f"Login failed: {detail}")
|
|
383
|
+
raise typer.Exit(1)
|
|
384
|
+
except (httpx.HTTPError, KeyError, ValueError) as exc:
|
|
385
|
+
error(f"Login failed: {type(exc).__name__}")
|
|
386
|
+
raise typer.Exit(1)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _device_error(response) -> tuple[str, str]:
|
|
390
|
+
try:
|
|
391
|
+
payload = response.json()
|
|
392
|
+
except ValueError:
|
|
393
|
+
payload = {}
|
|
394
|
+
if not isinstance(payload, dict):
|
|
395
|
+
payload = {}
|
|
396
|
+
status = payload.get("status")
|
|
397
|
+
code = status if status in ("authorization_pending", "slow_down", "access_denied", "expired_token") else ""
|
|
398
|
+
message = payload.get("message") or payload.get("detail") or response.reason_phrase or "Request failed"
|
|
399
|
+
return code, message if isinstance(message, str) else "Request failed"
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
@app.command()
|
|
403
|
+
def logout():
|
|
404
|
+
"""Remove stored authentication."""
|
|
405
|
+
from botable.config import save_global_config, load_global_config
|
|
406
|
+
|
|
407
|
+
config = load_global_config()
|
|
408
|
+
if "auth_token" in config:
|
|
409
|
+
del config["auth_token"]
|
|
410
|
+
save_global_config(config)
|
|
411
|
+
success("Logged out — API key removed.")
|
|
412
|
+
else:
|
|
413
|
+
info("Not logged in.")
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
mcp_app = typer.Typer(help="Configure coding agent MCP clients")
|
|
417
|
+
app.add_typer(mcp_app, name="mcp")
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
@mcp_app.command("install")
|
|
421
|
+
def mcp_install(
|
|
422
|
+
target: str = typer.Option("all", "--target", help="claude, codex, or all"),
|
|
423
|
+
scope: str = typer.Option("user", "--scope", help="Claude scope: user or project"),
|
|
424
|
+
):
|
|
425
|
+
"""Register the Botable MCP server with Claude Code and/or Codex."""
|
|
426
|
+
from botable.mcp_install import _mcp_command, install_claude, install_codex
|
|
427
|
+
|
|
428
|
+
if target not in ("claude", "codex", "all") or scope not in ("user", "project"):
|
|
429
|
+
error("Use --target claude|codex|all and --scope user|project.")
|
|
430
|
+
raise typer.Exit(2)
|
|
431
|
+
if _mcp_command() == "botable-mcp" and not shutil.which("botable-mcp"):
|
|
432
|
+
typer.echo('Warning: botable-mcp not found; install "botable-dev[mcp]" first.')
|
|
433
|
+
failed = False
|
|
434
|
+
missing = False
|
|
435
|
+
configured = 0
|
|
436
|
+
for name, install, verify in (
|
|
437
|
+
("claude", lambda: install_claude(scope), "claude mcp list"),
|
|
438
|
+
("codex", install_codex, "codex mcp list"),
|
|
439
|
+
):
|
|
440
|
+
if target not in (name, "all"):
|
|
441
|
+
continue
|
|
442
|
+
try:
|
|
443
|
+
typer.echo(install())
|
|
444
|
+
typer.echo(f"Verify: {verify}")
|
|
445
|
+
configured += 1
|
|
446
|
+
except FileNotFoundError as exc:
|
|
447
|
+
error(f"{name} MCP unavailable: {exc}")
|
|
448
|
+
missing = True
|
|
449
|
+
except (OSError, ValueError, subprocess.CalledProcessError) as exc:
|
|
450
|
+
error(f"{name} MCP install failed: {exc}")
|
|
451
|
+
failed = True
|
|
452
|
+
if failed or (missing and (target != "all" or not configured)):
|
|
453
|
+
raise typer.Exit(1)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
# ── bots ─────────────────────────────────────────────────────────────────────
|
|
457
|
+
|
|
458
|
+
bots_app = typer.Typer(help="Manage deployed bots")
|
|
459
|
+
app.add_typer(bots_app, name="bots")
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
@bots_app.command("list")
|
|
463
|
+
def bots_list(json_output: bool = typer.Option(False, "--json", help="Print bots as JSON")):
|
|
464
|
+
"""List all deployed bots."""
|
|
465
|
+
from rich.table import Table
|
|
466
|
+
from botable.api_client import BotableClient
|
|
467
|
+
|
|
468
|
+
token = _require_auth()
|
|
469
|
+
|
|
470
|
+
async def _list():
|
|
471
|
+
client = BotableClient(api_key=token)
|
|
472
|
+
try:
|
|
473
|
+
return await client.list_bots()
|
|
474
|
+
finally:
|
|
475
|
+
await client.close()
|
|
476
|
+
|
|
477
|
+
result = _run_async(_list, "" if json_output else "Fetching bots…", "Failed to list bots")
|
|
478
|
+
|
|
479
|
+
if json_output:
|
|
480
|
+
typer.echo(json.dumps(result.get("data", result).get("bots", [])))
|
|
481
|
+
return
|
|
482
|
+
|
|
483
|
+
banner()
|
|
484
|
+
|
|
485
|
+
data = result.get("data", result)
|
|
486
|
+
bots = data.get("bots", [])
|
|
487
|
+
|
|
488
|
+
if not bots:
|
|
489
|
+
info("No bots found. Create one with [cyan]botable deploy[/]")
|
|
490
|
+
return
|
|
491
|
+
|
|
492
|
+
table = Table(title=f"🤖 Your Bots ({len(bots)})", show_lines=True)
|
|
493
|
+
table.add_column("Name", style="bold")
|
|
494
|
+
table.add_column("ID", style="dim", max_width=20)
|
|
495
|
+
table.add_column("State", style="cyan")
|
|
496
|
+
table.add_column("Running", justify="center")
|
|
497
|
+
table.add_column("Deployed", justify="center")
|
|
498
|
+
|
|
499
|
+
for bot in bots:
|
|
500
|
+
running = "🟢" if bot.get("is_running") else "🔴"
|
|
501
|
+
deployed = "✅" if bot.get("is_deployed") else "—"
|
|
502
|
+
table.add_row(
|
|
503
|
+
bot.get("name", "untitled"),
|
|
504
|
+
str(bot.get("id", "?"))[:20],
|
|
505
|
+
bot.get("fsm_state", "?"),
|
|
506
|
+
running,
|
|
507
|
+
deployed,
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
console.print(table)
|
|
511
|
+
console.print()
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
@bots_app.command("stop")
|
|
515
|
+
def bots_stop(
|
|
516
|
+
project_id: str = typer.Argument(..., help="Project ID of the bot to stop"),
|
|
517
|
+
):
|
|
518
|
+
"""Stop a running bot."""
|
|
519
|
+
from botable.api_client import BotableClient
|
|
520
|
+
|
|
521
|
+
token = _require_auth()
|
|
522
|
+
|
|
523
|
+
async def _stop():
|
|
524
|
+
client = BotableClient(api_key=token)
|
|
525
|
+
try:
|
|
526
|
+
return await client.stop_bot(project_id)
|
|
527
|
+
finally:
|
|
528
|
+
await client.close()
|
|
529
|
+
|
|
530
|
+
_run_async(_stop, "Stopping bot…", "Failed to stop bot")
|
|
531
|
+
success(f"Bot [bold]{project_id}[/] stopped.")
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
@bots_app.command("start")
|
|
535
|
+
def bots_start(
|
|
536
|
+
project_id: str = typer.Argument(..., help="Project ID of the bot to start"),
|
|
537
|
+
):
|
|
538
|
+
"""Start a stopped bot."""
|
|
539
|
+
from botable.api_client import BotableClient
|
|
540
|
+
|
|
541
|
+
token = _require_auth()
|
|
542
|
+
|
|
543
|
+
async def _start():
|
|
544
|
+
client = BotableClient(api_key=token)
|
|
545
|
+
try:
|
|
546
|
+
return await client.start_bot(project_id)
|
|
547
|
+
finally:
|
|
548
|
+
await client.close()
|
|
549
|
+
|
|
550
|
+
_run_async(_start, "Starting bot…", "Failed to start bot")
|
|
551
|
+
success(f"Bot [bold]{project_id}[/] started.")
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
@bots_app.command("delete")
|
|
555
|
+
def bots_delete(
|
|
556
|
+
project_id: str = typer.Argument(..., help="Project ID of the bot to delete"),
|
|
557
|
+
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
|
|
558
|
+
):
|
|
559
|
+
"""Delete a bot permanently."""
|
|
560
|
+
from botable.api_client import BotableClient
|
|
561
|
+
|
|
562
|
+
if not force:
|
|
563
|
+
confirm = typer.confirm(f"⚠️ Are you sure you want to delete {project_id}?")
|
|
564
|
+
if not confirm:
|
|
565
|
+
info("Cancelled.")
|
|
566
|
+
raise typer.Exit(0)
|
|
567
|
+
|
|
568
|
+
token = _require_auth()
|
|
569
|
+
|
|
570
|
+
async def _delete():
|
|
571
|
+
client = BotableClient(api_key=token)
|
|
572
|
+
try:
|
|
573
|
+
return await client.delete_bot(project_id)
|
|
574
|
+
finally:
|
|
575
|
+
await client.close()
|
|
576
|
+
|
|
577
|
+
_run_async(_delete, "Deleting bot…", "Failed to delete bot")
|
|
578
|
+
success(f"Bot [bold]{project_id}[/] deleted.")
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
@bots_app.command("edit")
|
|
582
|
+
def bots_edit(
|
|
583
|
+
project_id: str = typer.Argument(..., help="Project ID of the bot to edit"),
|
|
584
|
+
instruction: str = typer.Argument(..., help="Edit instruction in natural language"),
|
|
585
|
+
):
|
|
586
|
+
"""Edit a bot with natural language instructions."""
|
|
587
|
+
from botable.api_client import BotableClient
|
|
588
|
+
|
|
589
|
+
token = _require_auth()
|
|
590
|
+
banner()
|
|
591
|
+
info(f"Editing bot [bold]{project_id}[/]: [italic]{instruction}[/]")
|
|
592
|
+
|
|
593
|
+
async def _edit():
|
|
594
|
+
client = BotableClient(api_key=token)
|
|
595
|
+
try:
|
|
596
|
+
return await client.edit_bot(project_id, instruction)
|
|
597
|
+
finally:
|
|
598
|
+
await client.close()
|
|
599
|
+
|
|
600
|
+
result = _run_async(_edit, "Applying edit…", "Failed to edit bot")
|
|
601
|
+
|
|
602
|
+
data = result.get("data", result)
|
|
603
|
+
state = data.get("state", "unknown")
|
|
604
|
+
success(f"Edit applied. Bot state: [cyan]{state}[/]")
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
# ── api-keys ─────────────────────────────────────────────────────────────
|
|
608
|
+
|
|
609
|
+
keys_app = typer.Typer(help="Manage API keys")
|
|
610
|
+
app.add_typer(keys_app, name="api-keys")
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
@keys_app.command("list")
|
|
614
|
+
def keys_list():
|
|
615
|
+
"""List all API keys."""
|
|
616
|
+
from rich.table import Table
|
|
617
|
+
from botable.api_client import BotableClient
|
|
618
|
+
|
|
619
|
+
token = _require_auth()
|
|
620
|
+
|
|
621
|
+
async def _list():
|
|
622
|
+
client = BotableClient(api_key=token)
|
|
623
|
+
try:
|
|
624
|
+
return await client.list_api_keys()
|
|
625
|
+
finally:
|
|
626
|
+
await client.close()
|
|
627
|
+
|
|
628
|
+
result = _run_async(_list, "Fetching API keys…", "Failed to list keys")
|
|
629
|
+
|
|
630
|
+
data = result.get("data", result)
|
|
631
|
+
keys = data.get("keys", [])
|
|
632
|
+
|
|
633
|
+
if not keys:
|
|
634
|
+
info("No API keys found. Create one with [cyan]botable api-keys create \"name\"[/]")
|
|
635
|
+
return
|
|
636
|
+
|
|
637
|
+
table = Table(title=f"🔑 API Keys ({len(keys)})")
|
|
638
|
+
table.add_column("Name", style="bold")
|
|
639
|
+
table.add_column("ID", style="dim", max_width=20)
|
|
640
|
+
table.add_column("Created", style="cyan")
|
|
641
|
+
table.add_column("Last Used")
|
|
642
|
+
|
|
643
|
+
for key in keys:
|
|
644
|
+
table.add_row(
|
|
645
|
+
key.get("name", "unnamed"),
|
|
646
|
+
str(key.get("id", "?"))[:20],
|
|
647
|
+
key.get("created_at", "?")[:10],
|
|
648
|
+
(key.get("last_used_at") or "never")[:10],
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
console.print(table)
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
@keys_app.command("create")
|
|
655
|
+
def keys_create(
|
|
656
|
+
name: str = typer.Argument(..., help="Name for the API key"),
|
|
657
|
+
):
|
|
658
|
+
"""Create a new API key."""
|
|
659
|
+
from botable.api_client import BotableClient
|
|
660
|
+
|
|
661
|
+
token = _require_auth()
|
|
662
|
+
|
|
663
|
+
async def _create():
|
|
664
|
+
client = BotableClient(api_key=token)
|
|
665
|
+
try:
|
|
666
|
+
return await client.create_api_key(name)
|
|
667
|
+
finally:
|
|
668
|
+
await client.close()
|
|
669
|
+
|
|
670
|
+
result = _run_async(_create, "Creating API key…", "Failed to create key")
|
|
671
|
+
|
|
672
|
+
data = result.get("data", result)
|
|
673
|
+
key_value = data.get("key", data.get("api_key", "?"))
|
|
674
|
+
|
|
675
|
+
success(f"API key [bold]{name}[/] created!")
|
|
676
|
+
console.print()
|
|
677
|
+
console.print(f" 🔑 [bold yellow]{key_value}[/]")
|
|
678
|
+
console.print()
|
|
679
|
+
console.print(" [dim]⚠️ Save this key — it won't be shown again![/]")
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
@keys_app.command("revoke")
|
|
683
|
+
def keys_revoke(
|
|
684
|
+
key_id: str = typer.Argument(..., help="ID of the API key to revoke"),
|
|
685
|
+
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
|
|
686
|
+
):
|
|
687
|
+
"""Revoke an API key."""
|
|
688
|
+
from botable.api_client import BotableClient
|
|
689
|
+
|
|
690
|
+
if not force:
|
|
691
|
+
confirm = typer.confirm(f"⚠️ Revoke API key {key_id}?")
|
|
692
|
+
if not confirm:
|
|
693
|
+
info("Cancelled.")
|
|
694
|
+
raise typer.Exit(0)
|
|
695
|
+
|
|
696
|
+
token = _require_auth()
|
|
697
|
+
|
|
698
|
+
async def _revoke():
|
|
699
|
+
client = BotableClient(api_key=token)
|
|
700
|
+
try:
|
|
701
|
+
return await client.revoke_api_key(key_id)
|
|
702
|
+
finally:
|
|
703
|
+
await client.close()
|
|
704
|
+
|
|
705
|
+
_run_async(_revoke, "Revoking API key…", "Failed to revoke key")
|
|
706
|
+
success(f"API key [bold]{key_id}[/] revoked.")
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
# ── credits ──────────────────────────────────────────────────────────────
|
|
710
|
+
|
|
711
|
+
@app.command()
|
|
712
|
+
def credits():
|
|
713
|
+
"""Show current credit balance."""
|
|
714
|
+
from botable.api_client import BotableClient
|
|
715
|
+
|
|
716
|
+
token = _require_auth()
|
|
717
|
+
|
|
718
|
+
async def _get():
|
|
719
|
+
client = BotableClient(api_key=token)
|
|
720
|
+
try:
|
|
721
|
+
return await client.get_credits()
|
|
722
|
+
finally:
|
|
723
|
+
await client.close()
|
|
724
|
+
|
|
725
|
+
result = _run_async(_get, "Fetching credits…", "Failed to get credits")
|
|
726
|
+
|
|
727
|
+
data = result.get("data", result)
|
|
728
|
+
balance = data.get("balance", 0)
|
|
729
|
+
used = data.get("used", 0)
|
|
730
|
+
|
|
731
|
+
banner()
|
|
732
|
+
console.print(f" 💰 Credits: [bold green]{balance}[/]")
|
|
733
|
+
if used:
|
|
734
|
+
console.print(f" 📊 Used: [dim]{used}[/]")
|
|
735
|
+
console.print()
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
# ── templates ────────────────────────────────────────────────────────────
|
|
739
|
+
|
|
740
|
+
@app.command("templates")
|
|
741
|
+
def templates_list():
|
|
742
|
+
"""List available bot templates."""
|
|
743
|
+
from rich.table import Table
|
|
744
|
+
from botable.api_client import BotableClient
|
|
745
|
+
|
|
746
|
+
token = _require_auth()
|
|
747
|
+
|
|
748
|
+
async def _list():
|
|
749
|
+
client = BotableClient(api_key=token)
|
|
750
|
+
try:
|
|
751
|
+
return await client.list_templates()
|
|
752
|
+
finally:
|
|
753
|
+
await client.close()
|
|
754
|
+
|
|
755
|
+
result = _run_async(_list, "Fetching templates…", "Failed to list templates")
|
|
756
|
+
|
|
757
|
+
data = result.get("data", result)
|
|
758
|
+
templates = data.get("templates", [])
|
|
759
|
+
|
|
760
|
+
if not templates:
|
|
761
|
+
info("No templates available.")
|
|
762
|
+
return
|
|
763
|
+
|
|
764
|
+
table = Table(title=f"📦 Templates ({len(templates)})")
|
|
765
|
+
table.add_column("Name", style="bold")
|
|
766
|
+
table.add_column("Category", style="cyan")
|
|
767
|
+
table.add_column("Description")
|
|
768
|
+
|
|
769
|
+
for tmpl in templates:
|
|
770
|
+
table.add_row(
|
|
771
|
+
tmpl.get("name", "?"),
|
|
772
|
+
tmpl.get("template_type", "general"),
|
|
773
|
+
(tmpl.get("short_description", "")[:50] + "…") if len(tmpl.get("short_description", "")) > 50 else tmpl.get("short_description", ""),
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
console.print(table)
|
|
777
|
+
console.print()
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
if __name__ == "__main__":
|
|
781
|
+
app()
|