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/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()