bonito-cli 0.7.4__tar.gz → 0.9.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.
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/CHANGELOG.md +6 -0
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/PKG-INFO +1 -1
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/__init__.py +1 -1
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/admin.py +3 -3
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/agents.py +173 -0
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/auth.py +152 -0
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/deploy.py +22 -0
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/plan.py +25 -18
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/projects.py +103 -0
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/utils/feature_gate.py +8 -6
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/pyproject.toml +1 -1
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/.gitignore +0 -0
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/CLI_SPEC.md +0 -0
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/README.md +0 -0
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/__main__.py +0 -0
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/api.py +0 -0
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/app.py +0 -0
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/__init__.py +0 -0
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/analytics.py +0 -0
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/approval.py +0 -0
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/audit.py +0 -0
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/chat.py +0 -0
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/compliance.py +0 -0
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/deployments.py +0 -0
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/gateway.py +0 -0
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/groups.py +0 -0
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/init.py +0 -0
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/kb.py +0 -0
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/mcp.py +0 -0
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/memory.py +0 -0
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/models.py +0 -0
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/policies.py +0 -0
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/providers.py +0 -0
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/rbac.py +0 -0
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/routing.py +0 -0
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/scheduler.py +0 -0
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/secrets.py +0 -0
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/sso.py +0 -0
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/config.py +0 -0
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/examples/__init__.py +0 -0
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/examples/enterprise.yaml +0 -0
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/examples/multi-provider-failover.yaml +0 -0
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/examples/quickstart.yaml +0 -0
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/examples/support-agent.yaml +0 -0
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/utils/__init__.py +0 -0
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/utils/auth.py +0 -0
- {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/utils/display.py +0 -0
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the Bonito CLI will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.8.0] - 2026-05-27
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **Personal access tokens**: `bonito auth token create`, `bonito auth token list`, `bonito auth token revoke` — manage PATs (bp-...) for programmatic API access
|
|
9
|
+
- **Token login**: `bonito auth token login --token bp-...` — authenticate using a PAT instead of email/password
|
|
10
|
+
|
|
5
11
|
## [0.6.0] - 2026-05-07
|
|
6
12
|
|
|
7
13
|
### Added
|
|
@@ -45,7 +45,7 @@ def verify_user(
|
|
|
45
45
|
def set_tier(
|
|
46
46
|
email: Optional[str] = typer.Option(None, "--email", "-e", help="User email (to look up org)"),
|
|
47
47
|
org_id: Optional[str] = typer.Option(None, "--org", help="Organization ID"),
|
|
48
|
-
tier: str = typer.Option(..., "--tier", "-t", help="Tier: free, pro, enterprise"),
|
|
48
|
+
tier: str = typer.Option(..., "--tier", "-t", help="Tier: free, starter, pro, enterprise"),
|
|
49
49
|
reason: str = typer.Option("Set via CLI", "--reason", help="Reason for tier change"),
|
|
50
50
|
json_output: bool = typer.Option(False, "--json", help="JSON output"),
|
|
51
51
|
):
|
|
@@ -58,8 +58,8 @@ def set_tier(
|
|
|
58
58
|
"""
|
|
59
59
|
ensure_authenticated()
|
|
60
60
|
|
|
61
|
-
if tier not in ("free", "pro", "enterprise"):
|
|
62
|
-
print_error("Tier must be: free, pro, or enterprise")
|
|
61
|
+
if tier not in ("free", "starter", "pro", "enterprise"):
|
|
62
|
+
print_error("Tier must be: free, starter, pro, or enterprise")
|
|
63
63
|
raise typer.Exit(1)
|
|
64
64
|
|
|
65
65
|
if not org_id and not email:
|
|
@@ -554,5 +554,178 @@ def list_triggers(
|
|
|
554
554
|
console.print(table)
|
|
555
555
|
|
|
556
556
|
|
|
557
|
+
# ─── Scaling / HPA Commands ───
|
|
558
|
+
|
|
559
|
+
scaling_app = typer.Typer(help="Agent autoscaling (HPA) management")
|
|
560
|
+
app.add_typer(scaling_app, name="scaling")
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
@scaling_app.command("status")
|
|
564
|
+
def scaling_status(
|
|
565
|
+
agent_id: str = typer.Argument(..., help="Agent ID"),
|
|
566
|
+
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
|
|
567
|
+
):
|
|
568
|
+
"""Show current autoscaling status for an agent."""
|
|
569
|
+
ensure_authenticated()
|
|
570
|
+
try:
|
|
571
|
+
data = api.get(f"/agents/{agent_id}/scaling")
|
|
572
|
+
except APIError as e:
|
|
573
|
+
print_error(f"Failed to get scaling status: {e}")
|
|
574
|
+
raise typer.Exit(1)
|
|
575
|
+
|
|
576
|
+
if json_output:
|
|
577
|
+
console.print_json(json.dumps(data))
|
|
578
|
+
return
|
|
579
|
+
|
|
580
|
+
table = Table(title="Agent Scaling Status", show_lines=True)
|
|
581
|
+
table.add_column("Property", style="cyan")
|
|
582
|
+
table.add_column("Value", style="white")
|
|
583
|
+
|
|
584
|
+
table.add_row("Autoscale Enabled", "✅" if data["autoscale_enabled"] else "❌")
|
|
585
|
+
table.add_row("Base RPM", str(data["base_rpm"]))
|
|
586
|
+
table.add_row("Effective RPM", str(data["effective_rpm"]))
|
|
587
|
+
table.add_row("Scaling Active", "🔥 Yes" if data["scaling_active"] else "No")
|
|
588
|
+
table.add_row("Current Usage", f"{data['current_rpm_usage']} RPM")
|
|
589
|
+
table.add_row("Utilization", f"{data['utilization']:.0%}")
|
|
590
|
+
table.add_row("Replica Count", str(data["replica_count"]))
|
|
591
|
+
if data.get("last_scaled_at"):
|
|
592
|
+
table.add_row("Last Scaled", data["last_scaled_at"])
|
|
593
|
+
|
|
594
|
+
cfg = data.get("config", {})
|
|
595
|
+
table.add_row("Capacity Threshold", f"{cfg.get('capacity_threshold', 0.6):.0%}")
|
|
596
|
+
table.add_row("Scale-Down Threshold", f"{cfg.get('scale_down_threshold', 0.3):.0%}")
|
|
597
|
+
table.add_row("Max Replicas", str(cfg.get("max_replicas", 5)))
|
|
598
|
+
table.add_row("Mode", cfg.get("mode", "virtual"))
|
|
599
|
+
|
|
600
|
+
console.print(table)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
@scaling_app.command("configure")
|
|
604
|
+
def scaling_configure(
|
|
605
|
+
agent_id: str = typer.Argument(..., help="Agent ID"),
|
|
606
|
+
threshold: float = typer.Option(0.6, "--threshold", "-t", help="Capacity threshold (0.1-0.95)"),
|
|
607
|
+
scale_down: float = typer.Option(0.3, "--scale-down", help="Scale-down threshold"),
|
|
608
|
+
max_replicas: int = typer.Option(5, "--max-replicas", "-m", help="Max replicas (1-10)"),
|
|
609
|
+
cooldown: int = typer.Option(300, "--cooldown", help="Scale-down cooldown in seconds"),
|
|
610
|
+
mode: str = typer.Option("virtual", "--mode", help="Scaling mode: virtual or replica"),
|
|
611
|
+
enable: bool = typer.Option(True, "--enable/--disable", help="Enable or disable autoscaling"),
|
|
612
|
+
):
|
|
613
|
+
"""Configure autoscaling for an agent. Enterprise+ only."""
|
|
614
|
+
ensure_authenticated()
|
|
615
|
+
try:
|
|
616
|
+
data = api.post(f"/agents/{agent_id}/scaling/configure", {
|
|
617
|
+
"enabled": enable,
|
|
618
|
+
"capacity_threshold": threshold,
|
|
619
|
+
"scale_down_threshold": scale_down,
|
|
620
|
+
"max_replicas": max_replicas,
|
|
621
|
+
"scale_down_cooldown_seconds": cooldown,
|
|
622
|
+
"mode": mode,
|
|
623
|
+
})
|
|
624
|
+
print_success(f"Autoscaling {'enabled' if enable else 'disabled'} for agent {agent_id}")
|
|
625
|
+
if enable:
|
|
626
|
+
cfg = data.get("autoscale_config", {})
|
|
627
|
+
print_info(f" Threshold: {cfg.get('capacity_threshold', 0.6):.0%} | Max replicas: {cfg.get('max_replicas', 5)} | Mode: {cfg.get('mode', 'virtual')}")
|
|
628
|
+
except APIError as e:
|
|
629
|
+
print_error(f"Failed to configure scaling: {e}")
|
|
630
|
+
raise typer.Exit(1)
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
@scaling_app.command("events")
|
|
634
|
+
def scaling_events(
|
|
635
|
+
agent_id: str = typer.Argument(..., help="Agent ID"),
|
|
636
|
+
limit: int = typer.Option(20, "--limit", "-n", help="Number of events to show"),
|
|
637
|
+
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
|
|
638
|
+
):
|
|
639
|
+
"""List recent scaling events for an agent."""
|
|
640
|
+
ensure_authenticated()
|
|
641
|
+
try:
|
|
642
|
+
data = api.get(f"/agents/{agent_id}/scaling/events", params={"limit": limit})
|
|
643
|
+
except APIError as e:
|
|
644
|
+
print_error(f"Failed to get scaling events: {e}")
|
|
645
|
+
raise typer.Exit(1)
|
|
646
|
+
|
|
647
|
+
if json_output:
|
|
648
|
+
console.print_json(json.dumps(data))
|
|
649
|
+
return
|
|
650
|
+
|
|
651
|
+
events = data.get("events", [])
|
|
652
|
+
if not events:
|
|
653
|
+
print_info("No scaling events found.")
|
|
654
|
+
return
|
|
655
|
+
|
|
656
|
+
table = Table(title=f"Scaling Events ({data.get('total', 0)} total)")
|
|
657
|
+
table.add_column("Time", style="dim")
|
|
658
|
+
table.add_column("Type", style="cyan")
|
|
659
|
+
table.add_column("Previous RPM", justify="right")
|
|
660
|
+
table.add_column("New RPM", justify="right")
|
|
661
|
+
table.add_column("Utilization", justify="right")
|
|
662
|
+
|
|
663
|
+
for e in events:
|
|
664
|
+
event_type = e["event_type"]
|
|
665
|
+
style = "green" if "up" in event_type else "yellow"
|
|
666
|
+
table.add_row(
|
|
667
|
+
format_timestamp(e.get("created_at")),
|
|
668
|
+
f"[{style}]{event_type}[/{style}]",
|
|
669
|
+
str(e["previous_capacity"]),
|
|
670
|
+
str(e["new_capacity"]),
|
|
671
|
+
f"{e['trigger_utilization']:.0%}",
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
console.print(table)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
@scaling_app.command("queue")
|
|
678
|
+
def scaling_queue(
|
|
679
|
+
agent_id: str = typer.Argument(..., help="Agent ID"),
|
|
680
|
+
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
|
|
681
|
+
):
|
|
682
|
+
"""Show current queue depth and status for an agent."""
|
|
683
|
+
ensure_authenticated()
|
|
684
|
+
try:
|
|
685
|
+
data = api.get(f"/agents/{agent_id}/queue")
|
|
686
|
+
except APIError as e:
|
|
687
|
+
print_error(f"Failed to get queue status: {e}")
|
|
688
|
+
raise typer.Exit(1)
|
|
689
|
+
|
|
690
|
+
if json_output:
|
|
691
|
+
console.print_json(json.dumps(data))
|
|
692
|
+
return
|
|
693
|
+
|
|
694
|
+
table = Table(title="Agent Queue Status", show_lines=True)
|
|
695
|
+
table.add_column("Property", style="cyan")
|
|
696
|
+
table.add_column("Value", style="white")
|
|
697
|
+
|
|
698
|
+
table.add_row("Queue Depth", str(data.get("depth", 0)))
|
|
699
|
+
table.add_row("Status", data.get("status", "unknown"))
|
|
700
|
+
if data.get("oldest_item_age_seconds") is not None:
|
|
701
|
+
age = data["oldest_item_age_seconds"]
|
|
702
|
+
table.add_row("Oldest Item Age", f"{age}s")
|
|
703
|
+
if data.get("estimated_wait_seconds") is not None:
|
|
704
|
+
table.add_row("Estimated Wait", f"{data['estimated_wait_seconds']}s")
|
|
705
|
+
if data.get("processing") is not None:
|
|
706
|
+
table.add_row("Currently Processing", str(data["processing"]))
|
|
707
|
+
|
|
708
|
+
console.print(table)
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
@scaling_app.command("manual")
|
|
712
|
+
def scaling_manual(
|
|
713
|
+
agent_id: str = typer.Argument(..., help="Agent ID"),
|
|
714
|
+
direction: str = typer.Argument(..., help="Scale direction: up or down"),
|
|
715
|
+
):
|
|
716
|
+
"""Manually scale an agent up or down. Enterprise+ only."""
|
|
717
|
+
ensure_authenticated()
|
|
718
|
+
if direction not in ("up", "down"):
|
|
719
|
+
print_error("Direction must be 'up' or 'down'")
|
|
720
|
+
raise typer.Exit(1)
|
|
721
|
+
|
|
722
|
+
try:
|
|
723
|
+
data = api.post(f"/agents/{agent_id}/scaling/manual", {"direction": direction})
|
|
724
|
+
print_success(f"Scaled {direction}: {data['previous_rpm']} → {data['new_rpm']} RPM")
|
|
725
|
+
except APIError as e:
|
|
726
|
+
print_error(f"Failed to scale: {e}")
|
|
727
|
+
raise typer.Exit(1)
|
|
728
|
+
|
|
729
|
+
|
|
557
730
|
if __name__ == "__main__":
|
|
558
731
|
app()
|
|
@@ -12,6 +12,7 @@ from rich.prompt import Prompt
|
|
|
12
12
|
from rich.table import Table
|
|
13
13
|
|
|
14
14
|
from ..api import api, APIError
|
|
15
|
+
from ..utils.auth import ensure_authenticated
|
|
15
16
|
from ..config import (
|
|
16
17
|
clear_credentials,
|
|
17
18
|
is_authenticated,
|
|
@@ -275,3 +276,154 @@ def status():
|
|
|
275
276
|
console.print(f"[red]✗ API error: {exc}[/red]")
|
|
276
277
|
if exc.status_code == 401:
|
|
277
278
|
console.print(" Token may be expired. Run: [cyan]bonito auth login[/cyan]")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# ── token management ─────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
token_app = typer.Typer(help="🔑 Personal access token management")
|
|
284
|
+
app.add_typer(token_app, name="token")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@token_app.command("create")
|
|
288
|
+
def token_create(
|
|
289
|
+
name: str = typer.Option(..., "--name", "-n", help="Token name"),
|
|
290
|
+
scopes: Optional[str] = typer.Option(None, "--scopes", "-s", help="Comma-separated scopes (omit for full access)"),
|
|
291
|
+
expires_in: int = typer.Option(90, "--expires-in", help="Expiry in days (1-365)"),
|
|
292
|
+
):
|
|
293
|
+
"""Create a personal access token (bp-...)."""
|
|
294
|
+
ensure_authenticated()
|
|
295
|
+
|
|
296
|
+
body = {"name": name, "expires_in_days": expires_in}
|
|
297
|
+
if scopes:
|
|
298
|
+
body["scopes"] = [s.strip() for s in scopes.split(",")]
|
|
299
|
+
|
|
300
|
+
try:
|
|
301
|
+
with console.status("[cyan]Creating token…[/cyan]"):
|
|
302
|
+
result = api.post("/tokens", json=body)
|
|
303
|
+
|
|
304
|
+
raw_token = result.get("token", "")
|
|
305
|
+
console.print(
|
|
306
|
+
Panel(
|
|
307
|
+
f"[green]✓ Token created[/green]\n\n"
|
|
308
|
+
f" [bold yellow]{raw_token}[/bold yellow]\n\n"
|
|
309
|
+
f" [dim]Copy this now — it won't be shown again.[/dim]\n"
|
|
310
|
+
f" Name: {result.get('name')}\n"
|
|
311
|
+
f" Prefix: {result.get('token_prefix')}\n"
|
|
312
|
+
f" Expires: {result.get('expires_at', '')[:10]}",
|
|
313
|
+
title="🔑 Personal Access Token",
|
|
314
|
+
border_style="green",
|
|
315
|
+
)
|
|
316
|
+
)
|
|
317
|
+
except APIError as exc:
|
|
318
|
+
console.print(f"[red]✗ {exc}[/red]")
|
|
319
|
+
raise typer.Exit(1)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@token_app.command("list")
|
|
323
|
+
def token_list():
|
|
324
|
+
"""List your personal access tokens."""
|
|
325
|
+
ensure_authenticated()
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
with console.status("[cyan]Fetching tokens…[/cyan]"):
|
|
329
|
+
tokens = api.get("/tokens")
|
|
330
|
+
|
|
331
|
+
if not tokens:
|
|
332
|
+
console.print("[yellow]No personal access tokens found.[/yellow]")
|
|
333
|
+
console.print(" Create one: [cyan]bonito auth token create --name my-token[/cyan]")
|
|
334
|
+
return
|
|
335
|
+
|
|
336
|
+
table = Table(title="Personal Access Tokens", border_style="dim")
|
|
337
|
+
table.add_column("Name", style="cyan")
|
|
338
|
+
table.add_column("Prefix")
|
|
339
|
+
table.add_column("Expires")
|
|
340
|
+
table.add_column("Last Used")
|
|
341
|
+
table.add_column("Status")
|
|
342
|
+
table.add_column("ID", style="dim")
|
|
343
|
+
|
|
344
|
+
for t in tokens:
|
|
345
|
+
status_str = "[red]Revoked[/red]" if t.get("revoked_at") else "[green]Active[/green]"
|
|
346
|
+
expires = t.get("expires_at", "")[:10]
|
|
347
|
+
last_used = t.get("last_used_at", "")
|
|
348
|
+
last_used = last_used[:10] if last_used else "Never"
|
|
349
|
+
table.add_row(
|
|
350
|
+
t.get("name", "—"),
|
|
351
|
+
t.get("token_prefix", "—"),
|
|
352
|
+
expires,
|
|
353
|
+
last_used,
|
|
354
|
+
status_str,
|
|
355
|
+
str(t.get("id", ""))[:8] + "…",
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
console.print(table)
|
|
359
|
+
|
|
360
|
+
except APIError as exc:
|
|
361
|
+
console.print(f"[red]✗ {exc}[/red]")
|
|
362
|
+
raise typer.Exit(1)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
@token_app.command("revoke")
|
|
366
|
+
def token_revoke(
|
|
367
|
+
token_id: str = typer.Argument(..., help="Token ID to revoke"),
|
|
368
|
+
):
|
|
369
|
+
"""Revoke a personal access token."""
|
|
370
|
+
ensure_authenticated()
|
|
371
|
+
|
|
372
|
+
try:
|
|
373
|
+
with console.status("[cyan]Revoking token…[/cyan]"):
|
|
374
|
+
api.delete(f"/tokens/{token_id}")
|
|
375
|
+
console.print("[green]✓ Token revoked[/green]")
|
|
376
|
+
except APIError as exc:
|
|
377
|
+
console.print(f"[red]✗ {exc}[/red]")
|
|
378
|
+
raise typer.Exit(1)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
@token_app.command("login")
|
|
382
|
+
def token_login(
|
|
383
|
+
token: str = typer.Option(..., "--token", "-t", help="Personal access token (bp-...)"),
|
|
384
|
+
):
|
|
385
|
+
"""Authenticate using a personal access token instead of email/password."""
|
|
386
|
+
if not token.startswith("bp-"):
|
|
387
|
+
console.print("[red]✗ Token must start with 'bp-' (personal access token)[/red]")
|
|
388
|
+
raise typer.Exit(1)
|
|
389
|
+
|
|
390
|
+
# Verify the token works
|
|
391
|
+
try:
|
|
392
|
+
with console.status("[cyan]Verifying token…[/cyan]"):
|
|
393
|
+
resp = httpx.get(
|
|
394
|
+
f"{api.base_url}/api/auth/me",
|
|
395
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
396
|
+
timeout=15,
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
if resp.status_code != 200:
|
|
400
|
+
console.print("[red]✗ Token verification failed. Check the token and try again.[/red]")
|
|
401
|
+
raise typer.Exit(1)
|
|
402
|
+
|
|
403
|
+
me = resp.json()
|
|
404
|
+
|
|
405
|
+
# Store the PAT as the access_token in credentials
|
|
406
|
+
creds = {
|
|
407
|
+
"access_token": token,
|
|
408
|
+
"email": me.get("email", ""),
|
|
409
|
+
"user": me,
|
|
410
|
+
}
|
|
411
|
+
save_credentials(creds)
|
|
412
|
+
|
|
413
|
+
# Reset HTTP client
|
|
414
|
+
if api._client and not api._client.is_closed:
|
|
415
|
+
api._client.close()
|
|
416
|
+
api._client = None
|
|
417
|
+
|
|
418
|
+
console.print(
|
|
419
|
+
Panel(
|
|
420
|
+
f"[green]✓ Authenticated via PAT as [bold]{me.get('email', '')}[/bold][/green]\n"
|
|
421
|
+
f" Organization: {me.get('org', {}).get('name', '—')}",
|
|
422
|
+
title="🔑 Token Login",
|
|
423
|
+
border_style="green",
|
|
424
|
+
)
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
except httpx.RequestError as exc:
|
|
428
|
+
console.print(f"[red]✗ Connection failed: {exc}[/red]")
|
|
429
|
+
raise typer.Exit(1)
|
|
@@ -505,6 +505,28 @@ def _deploy_agents(
|
|
|
505
505
|
elif verbose:
|
|
506
506
|
console.print(f" [dim yellow]! KB '{kb_name}' not found - skipping[/dim yellow]")
|
|
507
507
|
|
|
508
|
+
# Rate limit
|
|
509
|
+
if cfg.get("rate_limit_rpm"):
|
|
510
|
+
payload["rate_limit_rpm"] = cfg["rate_limit_rpm"]
|
|
511
|
+
|
|
512
|
+
# HPA / Autoscaling
|
|
513
|
+
scaling = cfg.get("scaling", {})
|
|
514
|
+
if scaling:
|
|
515
|
+
payload["autoscale_enabled"] = scaling.get("enabled", False)
|
|
516
|
+
autoscale_config = {}
|
|
517
|
+
if "capacity_threshold" in scaling:
|
|
518
|
+
autoscale_config["capacity_threshold"] = scaling["capacity_threshold"]
|
|
519
|
+
if "scale_down_threshold" in scaling:
|
|
520
|
+
autoscale_config["scale_down_threshold"] = scaling["scale_down_threshold"]
|
|
521
|
+
if "max_replicas" in scaling:
|
|
522
|
+
autoscale_config["max_replicas"] = scaling["max_replicas"]
|
|
523
|
+
if "scale_down_cooldown_seconds" in scaling:
|
|
524
|
+
autoscale_config["scale_down_cooldown_seconds"] = scaling["scale_down_cooldown_seconds"]
|
|
525
|
+
if "mode" in scaling:
|
|
526
|
+
autoscale_config["mode"] = scaling["mode"]
|
|
527
|
+
if autoscale_config:
|
|
528
|
+
payload["autoscale_config"] = autoscale_config
|
|
529
|
+
|
|
508
530
|
# Note: agent secrets are managed via /api/secrets, not in the agent payload
|
|
509
531
|
|
|
510
532
|
# Check if agent already exists — update instead of creating a duplicate
|
|
@@ -47,6 +47,7 @@ def show_plan(
|
|
|
47
47
|
|
|
48
48
|
tier_display = {
|
|
49
49
|
"free": "Free",
|
|
50
|
+
"starter": "Starter",
|
|
50
51
|
"pro": "Pro",
|
|
51
52
|
"enterprise": "Enterprise"
|
|
52
53
|
}
|
|
@@ -135,9 +136,10 @@ def show_features(
|
|
|
135
136
|
table = Table(show_header=True, header_style="bold")
|
|
136
137
|
table.add_column("Feature")
|
|
137
138
|
table.add_column("Free", style="dim" if current_tier != "free" else "bold green")
|
|
138
|
-
table.add_column("
|
|
139
|
+
table.add_column("Starter", style="dim" if current_tier != "starter" else "bold green")
|
|
140
|
+
table.add_column("Pro", style="dim" if current_tier != "pro" else "bold green")
|
|
139
141
|
table.add_column("Enterprise", style="dim" if current_tier != "enterprise" else "bold green")
|
|
140
|
-
|
|
142
|
+
|
|
141
143
|
# Feature mapping for display
|
|
142
144
|
feature_names = {
|
|
143
145
|
"models": "AI Models",
|
|
@@ -157,17 +159,18 @@ def show_features(
|
|
|
157
159
|
"custom_integrations": "Custom Integrations",
|
|
158
160
|
"dedicated_support": "Dedicated Support"
|
|
159
161
|
}
|
|
160
|
-
|
|
162
|
+
|
|
161
163
|
# Get features from one tier (they should all have the same keys)
|
|
162
164
|
features = tiers.get("free", {}).get("features", {})
|
|
163
|
-
|
|
165
|
+
|
|
164
166
|
for feature_key, display_name in feature_names.items():
|
|
165
167
|
if feature_key in features:
|
|
166
168
|
free_val = "✅" if tiers.get("free", {}).get("features", {}).get(feature_key, False) else "❌"
|
|
169
|
+
starter_val = "✅" if tiers.get("starter", {}).get("features", {}).get(feature_key, False) else "❌"
|
|
167
170
|
pro_val = "✅" if tiers.get("pro", {}).get("features", {}).get(feature_key, False) else "❌"
|
|
168
171
|
ent_val = "✅" if tiers.get("enterprise", {}).get("features", {}).get(feature_key, False) else "❌"
|
|
169
|
-
|
|
170
|
-
table.add_row(display_name, free_val, pro_val, ent_val)
|
|
172
|
+
|
|
173
|
+
table.add_row(display_name, free_val, starter_val, pro_val, ent_val)
|
|
171
174
|
|
|
172
175
|
console.print(table)
|
|
173
176
|
|
|
@@ -177,44 +180,48 @@ def show_features(
|
|
|
177
180
|
limits_table = Table(show_header=True, header_style="bold")
|
|
178
181
|
limits_table.add_column("Resource")
|
|
179
182
|
limits_table.add_column("Free", style="dim" if current_tier != "free" else "bold green")
|
|
183
|
+
limits_table.add_column("Starter", style="dim" if current_tier != "starter" else "bold green")
|
|
180
184
|
limits_table.add_column("Pro", style="dim" if current_tier != "pro" else "bold green")
|
|
181
185
|
limits_table.add_column("Enterprise", style="dim" if current_tier != "enterprise" else "bold green")
|
|
182
|
-
|
|
183
|
-
for tier_name in ["free", "pro", "enterprise"]:
|
|
184
|
-
tier_data = tiers.get(tier_name, {})
|
|
185
|
-
|
|
186
|
+
|
|
186
187
|
# Providers
|
|
187
188
|
free_providers = tiers.get("free", {}).get("providers", 0)
|
|
188
|
-
|
|
189
|
+
starter_providers = tiers.get("starter", {}).get("providers", 0)
|
|
190
|
+
pro_providers = tiers.get("pro", {}).get("providers", 0)
|
|
189
191
|
ent_providers = tiers.get("enterprise", {}).get("providers", 0)
|
|
190
|
-
|
|
192
|
+
|
|
191
193
|
limits_table.add_row(
|
|
192
194
|
"Providers",
|
|
193
195
|
str(free_providers) if free_providers != float('inf') else "Unlimited",
|
|
194
|
-
str(
|
|
196
|
+
str(starter_providers) if starter_providers != float('inf') else "Unlimited",
|
|
197
|
+
str(pro_providers) if pro_providers != float('inf') else "Unlimited",
|
|
195
198
|
str(ent_providers) if ent_providers != float('inf') else "Unlimited"
|
|
196
199
|
)
|
|
197
|
-
|
|
200
|
+
|
|
198
201
|
# Gateway calls
|
|
199
202
|
free_calls = tiers.get("free", {}).get("gateway_calls", 0)
|
|
203
|
+
starter_calls = tiers.get("starter", {}).get("gateway_calls", 0)
|
|
200
204
|
pro_calls = tiers.get("pro", {}).get("gateway_calls", 0)
|
|
201
205
|
ent_calls = tiers.get("enterprise", {}).get("gateway_calls", 0)
|
|
202
|
-
|
|
206
|
+
|
|
203
207
|
limits_table.add_row(
|
|
204
208
|
"Gateway Calls/Month",
|
|
205
209
|
f"{free_calls:,}" if free_calls != float('inf') else "Unlimited",
|
|
210
|
+
f"{starter_calls:,}" if starter_calls != float('inf') else "Unlimited",
|
|
206
211
|
f"{pro_calls:,}" if pro_calls != float('inf') else "Unlimited",
|
|
207
212
|
f"{ent_calls:,}" if ent_calls != float('inf') else "Unlimited"
|
|
208
213
|
)
|
|
209
|
-
|
|
214
|
+
|
|
210
215
|
# Members
|
|
211
216
|
free_members = tiers.get("free", {}).get("members", 0)
|
|
217
|
+
starter_members = tiers.get("starter", {}).get("members", 0)
|
|
212
218
|
pro_members = tiers.get("pro", {}).get("members", 0)
|
|
213
219
|
ent_members = tiers.get("enterprise", {}).get("members", 0)
|
|
214
|
-
|
|
220
|
+
|
|
215
221
|
limits_table.add_row(
|
|
216
|
-
"Team Members",
|
|
222
|
+
"Team Members",
|
|
217
223
|
str(free_members) if free_members != float('inf') else "Unlimited",
|
|
224
|
+
str(starter_members) if starter_members != float('inf') else "Unlimited",
|
|
218
225
|
str(pro_members) if pro_members != float('inf') else "Unlimited",
|
|
219
226
|
str(ent_members) if ent_members != float('inf') else "Unlimited"
|
|
220
227
|
)
|
|
@@ -347,5 +347,108 @@ def project_graph(
|
|
|
347
347
|
console.print(f"\n[dim]💡 Use --json to get full graph data for React Flow or other visualization tools.[/dim]")
|
|
348
348
|
|
|
349
349
|
|
|
350
|
+
# ── project token management ─────────────────────────────────────
|
|
351
|
+
# Mirrors auth.token_app shape, but for bj- project-scoped tokens.
|
|
352
|
+
|
|
353
|
+
token_app = typer.Typer(help="🔑 Project-scoped access token management (bj-)")
|
|
354
|
+
app.add_typer(token_app, name="token")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@token_app.command("create")
|
|
358
|
+
def project_token_create(
|
|
359
|
+
project_id: str = typer.Argument(..., help="Project ID to scope the token to"),
|
|
360
|
+
name: str = typer.Option(..., "--name", "-n", help="Token name"),
|
|
361
|
+
expires_in: int = typer.Option(90, "--expires-in", help="Expiry in days (1-365)"),
|
|
362
|
+
):
|
|
363
|
+
"""Create a project-scoped access token (bj-...)."""
|
|
364
|
+
ensure_authenticated()
|
|
365
|
+
|
|
366
|
+
body = {"name": name, "expires_in_days": expires_in}
|
|
367
|
+
|
|
368
|
+
try:
|
|
369
|
+
with console.status("[cyan]Creating project token…[/cyan]"):
|
|
370
|
+
result = api.post(f"/projects/{project_id}/tokens", json=body)
|
|
371
|
+
|
|
372
|
+
raw_token = result.get("token", "")
|
|
373
|
+
console.print(
|
|
374
|
+
Panel(
|
|
375
|
+
f"[green]✓ Project token created[/green]\n\n"
|
|
376
|
+
f" [bold yellow]{raw_token}[/bold yellow]\n\n"
|
|
377
|
+
f" [dim]Copy this now — it won't be shown again.[/dim]\n"
|
|
378
|
+
f" Name: {result.get('name')}\n"
|
|
379
|
+
f" Project: {result.get('project_id')}\n"
|
|
380
|
+
f" Prefix: {result.get('token_prefix')}\n"
|
|
381
|
+
f" Expires: {result.get('expires_at', '')[:10]}",
|
|
382
|
+
title="🔑 Project Access Token",
|
|
383
|
+
border_style="green",
|
|
384
|
+
)
|
|
385
|
+
)
|
|
386
|
+
except APIError as exc:
|
|
387
|
+
console.print(f"[red]✗ {exc}[/red]")
|
|
388
|
+
raise typer.Exit(1)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
@token_app.command("list")
|
|
392
|
+
def project_token_list(
|
|
393
|
+
project_id: str = typer.Argument(..., help="Project ID to list tokens for"),
|
|
394
|
+
):
|
|
395
|
+
"""List project-scoped access tokens for a project."""
|
|
396
|
+
ensure_authenticated()
|
|
397
|
+
|
|
398
|
+
try:
|
|
399
|
+
with console.status("[cyan]Fetching project tokens…[/cyan]"):
|
|
400
|
+
tokens = api.get(f"/projects/{project_id}/tokens")
|
|
401
|
+
|
|
402
|
+
if not tokens:
|
|
403
|
+
console.print(f"[yellow]No project tokens found for project {project_id}.[/yellow]")
|
|
404
|
+
console.print(f" Create one: [cyan]bonito projects token create {project_id} --name my-token[/cyan]")
|
|
405
|
+
return
|
|
406
|
+
|
|
407
|
+
table = Table(title=f"Project Tokens — {project_id[:8]}…", border_style="dim")
|
|
408
|
+
table.add_column("Name", style="cyan")
|
|
409
|
+
table.add_column("Prefix")
|
|
410
|
+
table.add_column("Expires")
|
|
411
|
+
table.add_column("Last Used")
|
|
412
|
+
table.add_column("Status")
|
|
413
|
+
table.add_column("ID", style="dim")
|
|
414
|
+
|
|
415
|
+
for t in tokens:
|
|
416
|
+
status_str = "[red]Revoked[/red]" if t.get("revoked_at") else "[green]Active[/green]"
|
|
417
|
+
expires = t.get("expires_at", "")[:10]
|
|
418
|
+
last_used = t.get("last_used_at", "")
|
|
419
|
+
last_used = last_used[:10] if last_used else "Never"
|
|
420
|
+
table.add_row(
|
|
421
|
+
t.get("name", "—"),
|
|
422
|
+
t.get("token_prefix", "—"),
|
|
423
|
+
expires,
|
|
424
|
+
last_used,
|
|
425
|
+
status_str,
|
|
426
|
+
str(t.get("id", ""))[:8] + "…",
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
console.print(table)
|
|
430
|
+
|
|
431
|
+
except APIError as exc:
|
|
432
|
+
console.print(f"[red]✗ {exc}[/red]")
|
|
433
|
+
raise typer.Exit(1)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
@token_app.command("revoke")
|
|
437
|
+
def project_token_revoke(
|
|
438
|
+
project_id: str = typer.Argument(..., help="Project ID the token belongs to"),
|
|
439
|
+
token_id: str = typer.Argument(..., help="Token ID to revoke"),
|
|
440
|
+
):
|
|
441
|
+
"""Revoke a project-scoped access token."""
|
|
442
|
+
ensure_authenticated()
|
|
443
|
+
|
|
444
|
+
try:
|
|
445
|
+
with console.status("[cyan]Revoking project token…[/cyan]"):
|
|
446
|
+
api.delete(f"/projects/{project_id}/tokens/{token_id}")
|
|
447
|
+
console.print("[green]✓ Project token revoked[/green]")
|
|
448
|
+
except APIError as exc:
|
|
449
|
+
console.print(f"[red]✗ {exc}[/red]")
|
|
450
|
+
raise typer.Exit(1)
|
|
451
|
+
|
|
452
|
+
|
|
350
453
|
if __name__ == "__main__":
|
|
351
454
|
app()
|
|
@@ -31,10 +31,11 @@ def require_feature(feature: str):
|
|
|
31
31
|
|
|
32
32
|
tier_display = {
|
|
33
33
|
"free": "Free",
|
|
34
|
-
"
|
|
34
|
+
"starter": "Starter",
|
|
35
|
+
"pro": "Pro",
|
|
35
36
|
"enterprise": "Enterprise"
|
|
36
37
|
}
|
|
37
|
-
|
|
38
|
+
|
|
38
39
|
current_name = tier_display.get(current_tier, current_tier.title())
|
|
39
40
|
required_name = tier_display.get(required_tier, required_tier.title())
|
|
40
41
|
|
|
@@ -75,14 +76,15 @@ def require_tier(min_tier: str):
|
|
|
75
76
|
response = api.get("/api/subscriptions/current")
|
|
76
77
|
current_tier = response.get("tier", "free")
|
|
77
78
|
|
|
78
|
-
tier_hierarchy = {"free": 0, "
|
|
79
|
+
tier_hierarchy = {"free": 0, "starter": 1, "pro": 2, "enterprise": 3, "scale": 4}
|
|
79
80
|
current_level = tier_hierarchy.get(current_tier, 0)
|
|
80
|
-
required_level = tier_hierarchy.get(min_tier,
|
|
81
|
-
|
|
81
|
+
required_level = tier_hierarchy.get(min_tier, 3)
|
|
82
|
+
|
|
82
83
|
if current_level < required_level:
|
|
83
84
|
tier_display = {
|
|
84
85
|
"free": "Free",
|
|
85
|
-
"
|
|
86
|
+
"starter": "Starter",
|
|
87
|
+
"pro": "Pro",
|
|
86
88
|
"enterprise": "Enterprise"
|
|
87
89
|
}
|
|
88
90
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|