bonito-cli 0.7.4__tar.gz → 0.8.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.8.0}/CHANGELOG.md +6 -0
  2. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/PKG-INFO +1 -1
  3. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/__init__.py +1 -1
  4. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/commands/agents.py +173 -0
  5. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/commands/auth.py +152 -0
  6. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/commands/deploy.py +22 -0
  7. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/pyproject.toml +1 -1
  8. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/.gitignore +0 -0
  9. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/CLI_SPEC.md +0 -0
  10. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/README.md +0 -0
  11. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/__main__.py +0 -0
  12. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/api.py +0 -0
  13. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/app.py +0 -0
  14. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/commands/__init__.py +0 -0
  15. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/commands/admin.py +0 -0
  16. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/commands/analytics.py +0 -0
  17. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/commands/approval.py +0 -0
  18. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/commands/audit.py +0 -0
  19. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/commands/chat.py +0 -0
  20. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/commands/compliance.py +0 -0
  21. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/commands/deployments.py +0 -0
  22. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/commands/gateway.py +0 -0
  23. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/commands/groups.py +0 -0
  24. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/commands/init.py +0 -0
  25. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/commands/kb.py +0 -0
  26. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/commands/mcp.py +0 -0
  27. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/commands/memory.py +0 -0
  28. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/commands/models.py +0 -0
  29. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/commands/plan.py +0 -0
  30. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/commands/policies.py +0 -0
  31. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/commands/projects.py +0 -0
  32. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/commands/providers.py +0 -0
  33. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/commands/rbac.py +0 -0
  34. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/commands/routing.py +0 -0
  35. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/commands/scheduler.py +0 -0
  36. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/commands/secrets.py +0 -0
  37. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/commands/sso.py +0 -0
  38. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/config.py +0 -0
  39. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/examples/__init__.py +0 -0
  40. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/examples/enterprise.yaml +0 -0
  41. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/examples/multi-provider-failover.yaml +0 -0
  42. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/examples/quickstart.yaml +0 -0
  43. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/examples/support-agent.yaml +0 -0
  44. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/utils/__init__.py +0 -0
  45. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/utils/auth.py +0 -0
  46. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/utils/display.py +0 -0
  47. {bonito_cli-0.7.4 → bonito_cli-0.8.0}/bonito_cli/utils/feature_gate.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.8.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"
@@ -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
@@ -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.8.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