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.
Files changed (47) hide show
  1. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/CHANGELOG.md +6 -0
  2. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/PKG-INFO +1 -1
  3. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/__init__.py +1 -1
  4. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/admin.py +3 -3
  5. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/agents.py +173 -0
  6. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/auth.py +152 -0
  7. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/deploy.py +22 -0
  8. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/plan.py +25 -18
  9. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/projects.py +103 -0
  10. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/utils/feature_gate.py +8 -6
  11. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/pyproject.toml +1 -1
  12. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/.gitignore +0 -0
  13. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/CLI_SPEC.md +0 -0
  14. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/README.md +0 -0
  15. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/__main__.py +0 -0
  16. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/api.py +0 -0
  17. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/app.py +0 -0
  18. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/__init__.py +0 -0
  19. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/analytics.py +0 -0
  20. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/approval.py +0 -0
  21. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/audit.py +0 -0
  22. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/chat.py +0 -0
  23. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/compliance.py +0 -0
  24. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/deployments.py +0 -0
  25. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/gateway.py +0 -0
  26. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/groups.py +0 -0
  27. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/init.py +0 -0
  28. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/kb.py +0 -0
  29. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/mcp.py +0 -0
  30. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/memory.py +0 -0
  31. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/models.py +0 -0
  32. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/policies.py +0 -0
  33. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/providers.py +0 -0
  34. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/rbac.py +0 -0
  35. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/routing.py +0 -0
  36. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/scheduler.py +0 -0
  37. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/secrets.py +0 -0
  38. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/commands/sso.py +0 -0
  39. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/config.py +0 -0
  40. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/examples/__init__.py +0 -0
  41. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/examples/enterprise.yaml +0 -0
  42. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/examples/multi-provider-failover.yaml +0 -0
  43. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/examples/quickstart.yaml +0 -0
  44. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/examples/support-agent.yaml +0 -0
  45. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/utils/__init__.py +0 -0
  46. {bonito_cli-0.7.4 → bonito_cli-0.9.0}/bonito_cli/utils/auth.py +0 -0
  47. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bonito-cli
3
- Version: 0.7.4
3
+ Version: 0.9.0
4
4
  Summary: Bonito CLI — Unified multi-cloud AI management from your terminal
5
5
  Author-email: Bonito <hello@getbonito.com>
6
6
  License: MIT
@@ -1,3 +1,3 @@
1
1
  """Bonito CLI — Unified multi-cloud AI management from your terminal."""
2
2
 
3
- __version__ = "0.7.4"
3
+ __version__ = "0.8.0"
@@ -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("Pro", style="dim" if current_tier != "pro" else "bold green")
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
- pro_providers = tiers.get("pro", {}).get("providers", 0)
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(pro_providers) if pro_providers != float('inf') else "Unlimited",
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
- "pro": "Pro",
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, "pro": 1, "enterprise": 2}
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, 2)
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
- "pro": "Pro",
86
+ "starter": "Starter",
87
+ "pro": "Pro",
86
88
  "enterprise": "Enterprise"
87
89
  }
88
90
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "bonito-cli"
7
- version = "0.7.4"
7
+ version = "0.9.0"
8
8
  description = "Bonito CLI — Unified multi-cloud AI management from your terminal"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
File without changes
File without changes
File without changes
File without changes
File without changes