humanbound-cli 0.3.1__tar.gz → 0.3.3__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 (53) hide show
  1. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/PKG-INFO +1 -1
  2. humanbound_cli-0.3.3/humanbound_cli/__init__.py +8 -0
  3. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli/client.py +102 -7
  4. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli/commands/api_keys.py +2 -2
  5. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli/commands/auth.py +49 -8
  6. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli/commands/campaigns.py +1 -1
  7. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli/commands/experiments.py +106 -3
  8. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli/commands/findings.py +2 -2
  9. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli/commands/logs.py +1 -1
  10. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli/commands/members.py +2 -2
  11. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli/commands/providers.py +1 -1
  12. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli/commands/test.py +13 -13
  13. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli/main.py +19 -3
  14. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli.egg-info/PKG-INFO +1 -1
  15. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/pyproject.toml +1 -1
  16. humanbound_cli-0.3.1/humanbound_cli/__init__.py +0 -3
  17. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/LICENSE +0 -0
  18. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/README.md +0 -0
  19. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli/commands/__init__.py +0 -0
  20. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli/commands/coverage.py +0 -0
  21. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli/commands/docs.py +0 -0
  22. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli/commands/guardrails.py +0 -0
  23. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli/commands/init.py +0 -0
  24. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli/commands/orgs.py +0 -0
  25. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli/commands/posture.py +0 -0
  26. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli/commands/projects.py +0 -0
  27. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli/commands/scan.py +0 -0
  28. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli/commands/upload_logs.py +0 -0
  29. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli/config.py +0 -0
  30. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli/exceptions.py +0 -0
  31. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli/extractors/__init__.py +0 -0
  32. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli/extractors/openapi.py +0 -0
  33. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli/extractors/repo.py +0 -0
  34. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli/pytest_plugin/__init__.py +0 -0
  35. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli/pytest_plugin/fixtures.py +0 -0
  36. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli/pytest_plugin/report.py +0 -0
  37. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli/report.py +0 -0
  38. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli/serve/__init__.py +0 -0
  39. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli/serve/config_builder.py +0 -0
  40. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli/serve/local_server.py +0 -0
  41. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli/serve/runtime_detector.py +0 -0
  42. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli/serve/tunnel_client.py +0 -0
  43. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli.egg-info/SOURCES.txt +0 -0
  44. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli.egg-info/dependency_links.txt +0 -0
  45. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli.egg-info/entry_points.txt +0 -0
  46. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli.egg-info/requires.txt +0 -0
  47. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/humanbound_cli.egg-info/top_level.txt +0 -0
  48. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/relay/relay.py +0 -0
  49. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/setup.cfg +0 -0
  50. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/tests/__init__.py +0 -0
  51. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/tests/cli_integration_test.py +0 -0
  52. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/tests/conftest.py +0 -0
  53. {humanbound_cli-0.3.1 → humanbound_cli-0.3.3}/tests/test_cli_commands.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: humanbound-cli
3
- Version: 0.3.1
3
+ Version: 0.3.3
4
4
  Summary: Humanbound CLI - command line interface for AI agent security testing.
5
5
  Author-email: Kostas Siabanis <hello@humanbound.ai>, Demetris Gerogiannis <hello@humanbound.ai>
6
6
  License: Apache-2.0
@@ -0,0 +1,8 @@
1
+ """Humanbound CLI - command line interface for AI agent security testing."""
2
+
3
+ from importlib.metadata import version as _v
4
+
5
+ try:
6
+ __version__ = _v("humanbound-cli")
7
+ except Exception:
8
+ __version__ = "dev"
@@ -206,6 +206,89 @@ ERROR_HTML = """<!DOCTYPE html>
206
206
  </html>"""
207
207
 
208
208
 
209
+ LOGOUT_HTML = """<!DOCTYPE html>
210
+ <html>
211
+ <head>
212
+ <title>Humanbound CLI - Logged Out</title>
213
+ <style>
214
+ * { margin: 0; padding: 0; box-sizing: border-box; }
215
+ body {
216
+ font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
217
+ background: linear-gradient(135deg, #0d1117 0%, #161b22 100%);
218
+ color: #e6edf3;
219
+ min-height: 100vh;
220
+ display: flex;
221
+ align-items: center;
222
+ justify-content: center;
223
+ }
224
+ .container {
225
+ text-align: center;
226
+ padding: 3rem;
227
+ background: rgba(22, 27, 34, 0.8);
228
+ border: 1px solid #30363d;
229
+ border-radius: 12px;
230
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
231
+ max-width: 480px;
232
+ }
233
+ .icon {
234
+ width: 64px;
235
+ height: 64px;
236
+ margin-bottom: 1.5rem;
237
+ background: #58a6ff;
238
+ border-radius: 50%;
239
+ display: flex;
240
+ align-items: center;
241
+ justify-content: center;
242
+ margin-left: auto;
243
+ margin-right: auto;
244
+ }
245
+ .icon svg { width: 32px; height: 32px; fill: white; }
246
+ h1 {
247
+ font-size: 1.5rem;
248
+ font-weight: 600;
249
+ margin-bottom: 0.75rem;
250
+ color: #58a6ff;
251
+ }
252
+ p {
253
+ color: #8b949e;
254
+ font-size: 0.9rem;
255
+ line-height: 1.6;
256
+ }
257
+ .command {
258
+ background: #0d1117;
259
+ border: 1px solid #30363d;
260
+ border-radius: 6px;
261
+ padding: 1rem;
262
+ margin-top: 1.5rem;
263
+ font-size: 0.85rem;
264
+ }
265
+ .command code {
266
+ color: #7ee787;
267
+ }
268
+ .prompt { color: #6e7681; }
269
+ .close-hint {
270
+ margin-top: 1.5rem;
271
+ font-size: 0.8rem;
272
+ color: #6e7681;
273
+ }
274
+ </style>
275
+ </head>
276
+ <body>
277
+ <div class="container">
278
+ <div class="icon">
279
+ <svg viewBox="0 0 24 24"><path d="M13 3h-2v10h2V3zm4.83 2.17l-1.42 1.42C17.99 7.86 19 9.81 19 12c0 3.87-3.13 7-7 7s-7-3.13-7-7c0-2.19 1.01-4.14 2.58-5.42L6.17 5.17C4.23 6.82 3 9.26 3 12c0 4.97 4.03 9 9 9s9-4.03 9-9c0-2.74-1.23-5.18-3.17-6.83z"/></svg>
280
+ </div>
281
+ <h1>Session Revoked</h1>
282
+ <p>Your local credentials and browser session have been cleared.</p>
283
+ <div class="command">
284
+ <span class="prompt">$</span> <code>hb login</code>
285
+ </div>
286
+ <p class="close-hint">You can close this tab</p>
287
+ </div>
288
+ </body>
289
+ </html>"""
290
+
291
+
209
292
  class HumanboundClient:
210
293
  """API client for Humanbound platform with OAuth authentication."""
211
294
 
@@ -388,16 +471,24 @@ class HumanboundClient:
388
471
 
389
472
  def _exchange_for_api_token(self) -> None:
390
473
  """Exchange Auth0 token for API session token."""
391
- response = requests.get(
392
- f"{self.base_url}/auth",
393
- headers={"Authorization": f"Bearer {self._auth0_token}"},
394
- timeout=DEFAULT_TIMEOUT,
395
- )
474
+ try:
475
+ response = requests.get(
476
+ f"{self.base_url}/auth",
477
+ headers={"Authorization": f"Bearer {self._auth0_token}"},
478
+ timeout=DEFAULT_TIMEOUT,
479
+ )
480
+ except requests.ConnectionError:
481
+ raise AuthenticationError(f"Could not connect to {self.base_url}. Is the server running?")
482
+ except requests.Timeout:
483
+ raise AuthenticationError(f"Connection to {self.base_url} timed out.")
396
484
 
397
485
  if response.status_code != 200:
398
- raise AuthenticationError(f"API authentication failed: {response.text}")
486
+ raise AuthenticationError(f"API authentication failed ({response.status_code}): {response.text}")
399
487
 
400
- data = response.json()
488
+ try:
489
+ data = response.json()
490
+ except Exception:
491
+ raise AuthenticationError(f"API returned invalid response from {self.base_url}/auth")
401
492
  self._api_token = data.get("access_token") or data.get("token")
402
493
  if not self._api_token:
403
494
  raise AuthenticationError("No access token in API response")
@@ -454,6 +545,10 @@ class HumanboundClient:
454
545
  self._username = credentials.get("username")
455
546
  self._email = credentials.get("email")
456
547
  self._default_organisation_id = credentials.get("default_organisation_id")
548
+ # Restore saved base_url unless explicitly overridden via --base-url or env var
549
+ saved_url = credentials.get("base_url")
550
+ if saved_url and self.base_url == get_base_url().rstrip("/"):
551
+ self.base_url = saved_url.rstrip("/")
457
552
 
458
553
  def _load_credentials_file(self) -> dict:
459
554
  """Load credentials file."""
@@ -54,7 +54,7 @@ def list_keys(as_json: bool):
54
54
  return
55
55
 
56
56
  table = Table(title="API Keys")
57
- table.add_column("ID", style="dim", width=10)
57
+ table.add_column("ID", style="dim")
58
58
  table.add_column("Name", style="bold")
59
59
  table.add_column("Scopes")
60
60
  table.add_column("Active", justify="center")
@@ -67,7 +67,7 @@ def list_keys(as_json: bool):
67
67
  prefix = (key_val[:12] + "...") if len(str(key_val)) > 12 else str(key_val)
68
68
 
69
69
  table.add_row(
70
- str(key.get("id", ""))[:10],
70
+ str(key.get("id", "")),
71
71
  key.get("name", ""),
72
72
  key.get("scopes", ""),
73
73
  active,
@@ -5,6 +5,7 @@ from rich.console import Console
5
5
  from rich.panel import Panel
6
6
 
7
7
  from ..client import HumanboundClient
8
+ from ..config import DEFAULT_BASE_URL
8
9
  from ..exceptions import AuthenticationError, APIError
9
10
 
10
11
  console = Console()
@@ -50,9 +51,14 @@ def login(base_url: str, port: int, force: bool):
50
51
  except Exception as e:
51
52
  org_display = f"{client.default_organisation_id} (could not resolve name: {e})"
52
53
 
54
+ base_url_line = ""
55
+ if client.base_url.rstrip("/") != DEFAULT_BASE_URL.rstrip("/"):
56
+ base_url_line = f"Base URL: {client.base_url}\n"
57
+
53
58
  console.print(Panel(
54
59
  f"[green]Login successful![/green]\n\n"
55
60
  f"User: {client.username or 'unknown'}\n"
61
+ f"{base_url_line}"
56
62
  f"Organisation: {org_display}",
57
63
  title="Humanbound",
58
64
  ))
@@ -65,20 +71,51 @@ def login(base_url: str, port: int, force: bool):
65
71
 
66
72
  @auth_group.command("logout")
67
73
  @click.option("--revoke", is_flag=True, help="Also clear browser SSO session (opens browser)")
68
- def logout(revoke: bool):
74
+ @click.option("--port", default=8085, help="Local callback port (default: 8085)")
75
+ def logout(revoke: bool, port: int):
69
76
  """Clear stored credentials."""
70
- import webbrowser
71
- from ..config import get_auth0_domain, get_auth0_client_id
72
-
73
77
  client = HumanboundClient()
74
78
  client.logout() # This already prints the success message
75
79
 
76
80
  if revoke:
81
+ import webbrowser
82
+ import urllib.parse
83
+ import http.server
84
+ import socketserver
85
+ from ..config import get_auth0_domain, get_auth0_client_id
86
+ from ..client import LOGOUT_HTML
87
+
77
88
  auth0_domain = get_auth0_domain()
78
89
  client_id = get_auth0_client_id()
79
- logout_url = f"https://{auth0_domain}/v2/logout?client_id={client_id}"
80
- console.print("Opening browser to clear Auth0 session...")
81
- webbrowser.open(logout_url)
90
+ return_to = f"http://localhost:{port}"
91
+
92
+ logout_url = (
93
+ f"https://{auth0_domain}/v2/logout?"
94
+ + urllib.parse.urlencode({"client_id": client_id, "returnTo": return_to})
95
+ )
96
+
97
+ class LogoutHandler(http.server.BaseHTTPRequestHandler):
98
+ def do_GET(self):
99
+ self.send_response(200)
100
+ self.send_header("Content-type", "text/html")
101
+ self.end_headers()
102
+ self.wfile.write(LOGOUT_HTML.encode())
103
+
104
+ def log_message(self, format, *args):
105
+ pass
106
+
107
+ socketserver.TCPServer.allow_reuse_address = True
108
+ server = socketserver.TCPServer(("", port), LogoutHandler)
109
+ server.timeout = 30
110
+
111
+ try:
112
+ console.print("Opening browser to clear Auth0 session...")
113
+ webbrowser.open(logout_url)
114
+ server.handle_request()
115
+ finally:
116
+ server.server_close()
117
+
118
+ console.print("[green]Browser session revoked. Base URL reset to default.[/green]")
82
119
  else:
83
120
  console.print(
84
121
  "[dim]Note: Your browser may still have an active Auth0 session. "
@@ -104,11 +141,15 @@ def whoami():
104
141
  except Exception as e:
105
142
  org_display = f"{client.organisation_id} [dim](could not resolve name: {e})[/dim]"
106
143
 
144
+ base_url_line = ""
145
+ if client.base_url.rstrip("/") != DEFAULT_BASE_URL.rstrip("/"):
146
+ base_url_line = f"Base URL: {client.base_url}\n"
147
+
107
148
  console.print(Panel(
108
149
  f"[green]Authenticated[/green]\n\n"
109
150
  f"User: {client.username or '[dim]unknown[/dim]'}\n"
110
151
  f"Email: {client.email or '[dim]unknown[/dim]'}\n"
111
- f"Base URL: {client.base_url}\n"
152
+ f"{base_url_line}"
112
153
  f"Organisation: {org_display}\n"
113
154
  f"Project: {client.project_id or '[dim]not set[/dim]'}",
114
155
  title="Humanbound Status",
@@ -156,7 +156,7 @@ def break_campaign(force: bool):
156
156
  return
157
157
 
158
158
  if not force:
159
- if not Confirm.ask(f"Break campaign [bold]{campaign_id[:10]}...[/bold]? Running experiments will be stopped"):
159
+ if not Confirm.ask(f"Break campaign [bold]{campaign_id}[/bold]? Running experiments will be stopped"):
160
160
  console.print("[dim]Cancelled.[/dim]")
161
161
  return
162
162
 
@@ -142,13 +142,20 @@ def show_experiment(experiment_id: str):
142
142
 
143
143
 
144
144
  @experiments_group.command("status")
145
- @click.argument("experiment_id")
145
+ @click.argument("experiment_id", required=False)
146
146
  @click.option("--watch", "-w", is_flag=True, help="Watch status until completion")
147
147
  @click.option("--interval", default=10, help="Polling interval in seconds (with --watch)")
148
- def experiment_status(experiment_id: str, watch: bool, interval: int):
148
+ @click.option("--all", "show_all", is_flag=True, help="Show status of all project experiments (polls every 60s)")
149
+ def experiment_status(experiment_id: str, watch: bool, interval: int, show_all: bool):
149
150
  """Check experiment status.
150
151
 
151
- EXPERIMENT_ID: Experiment UUID.
152
+ EXPERIMENT_ID: Experiment UUID (optional with --all).
153
+
154
+ \b
155
+ Examples:
156
+ hb experiments status <id> # One-shot status
157
+ hb experiments status <id> --watch # Poll single experiment
158
+ hb experiments status --all # Dashboard of all experiments (polls 60s)
152
159
  """
153
160
  client = HumanboundClient()
154
161
 
@@ -156,6 +163,14 @@ def experiment_status(experiment_id: str, watch: bool, interval: int):
156
163
  console.print("[yellow]No project selected.[/yellow]")
157
164
  raise SystemExit(1)
158
165
 
166
+ if show_all:
167
+ _poll_all_experiments(client)
168
+ return
169
+
170
+ if not experiment_id:
171
+ console.print("[red]Provide an experiment ID or use --all.[/red]")
172
+ raise SystemExit(1)
173
+
159
174
  try:
160
175
  if not watch:
161
176
  status = client.get_experiment_status(experiment_id)
@@ -201,6 +216,94 @@ def experiment_status(experiment_id: str, watch: bool, interval: int):
201
216
  console.print("\n[yellow]Stopped watching.[/yellow]")
202
217
 
203
218
 
219
+ ACTIVE_STATUSES = ["Running", "Generating", "Generated", "Pending"]
220
+
221
+
222
+ def _poll_all_experiments(client: HumanboundClient):
223
+ """Poll all project experiments every 60s until none are active."""
224
+ try:
225
+ cycle = 0
226
+ while True:
227
+ # Fetch all experiments (up to 200)
228
+ all_exps = []
229
+ page = 1
230
+ while True:
231
+ response = client.list_experiments(page=page, size=100)
232
+ all_exps.extend(response.get("data", []))
233
+ if not response.get("has_next_page"):
234
+ break
235
+ page += 1
236
+
237
+ if not all_exps:
238
+ console.print("[yellow]No experiments found.[/yellow]")
239
+ return
240
+
241
+ active = [e for e in all_exps if e.get("status") in ACTIVE_STATUSES]
242
+ finished = [e for e in all_exps if e.get("status") == "Finished"]
243
+ failed = [e for e in all_exps if e.get("status") == "Failed"]
244
+
245
+ # Clear screen on subsequent cycles
246
+ if cycle > 0:
247
+ console.clear()
248
+
249
+ timestamp = time.strftime("%H:%M:%S")
250
+ console.print(f"[bold]Project Experiments[/bold] [dim]{timestamp}[/dim]\n")
251
+ console.print(
252
+ f" Total: {len(all_exps)} "
253
+ f"[green]Finished: {len(finished)}[/green] "
254
+ f"[yellow]Active: {len(active)}[/yellow] "
255
+ f"[red]Failed: {len(failed)}[/red]\n"
256
+ )
257
+
258
+ table = Table()
259
+ table.add_column("Name", style="bold", max_width=35)
260
+ table.add_column("Status", width=12)
261
+ table.add_column("Category", width=20)
262
+ table.add_column("ID", style="dim")
263
+
264
+ for exp in all_exps:
265
+ status = exp.get("status", "Unknown")
266
+ status_style = {
267
+ "Finished": "[green]Finished[/green]",
268
+ "Running": "[yellow]Running[/yellow]",
269
+ "Failed": "[red]Failed[/red]",
270
+ "Generating": "[cyan]Generating[/cyan]",
271
+ "Generated": "[blue]Generated[/blue]",
272
+ "Pending": "[dim]Pending[/dim]",
273
+ "Terminated": "[red]Terminated[/red]",
274
+ }.get(status, status)
275
+
276
+ cat = exp.get("test_category", "")
277
+ if "/" in cat:
278
+ cat = cat.split("/")[-1]
279
+
280
+ table.add_row(
281
+ exp.get("name", ""),
282
+ status_style,
283
+ cat,
284
+ exp.get("id", ""),
285
+ )
286
+
287
+ console.print(table)
288
+
289
+ if not active:
290
+ console.print(f"\n[green]All experiments completed.[/green]")
291
+ return
292
+
293
+ console.print(f"\n[dim]{len(active)} active — polling every 60s (Ctrl+C to stop)[/dim]")
294
+ time.sleep(60)
295
+ cycle += 1
296
+
297
+ except NotAuthenticatedError:
298
+ console.print("[red]Not authenticated.[/red] Run 'hb login' first.")
299
+ raise SystemExit(1)
300
+ except APIError as e:
301
+ console.print(f"[red]Error:[/red] {e}")
302
+ raise SystemExit(1)
303
+ except KeyboardInterrupt:
304
+ console.print("\n[yellow]Stopped polling.[/yellow]")
305
+
306
+
204
307
  def _print_status(status: dict):
205
308
  """Print experiment status."""
206
309
  current_status = status.get("status", "Unknown")
@@ -76,7 +76,7 @@ def findings_group(ctx, status, severity, page, size, as_json):
76
76
  return
77
77
 
78
78
  table = Table(title="Findings")
79
- table.add_column("ID", style="dim", width=10)
79
+ table.add_column("ID", style="dim")
80
80
  table.add_column("Title", max_width=40)
81
81
  table.add_column("Severity", width=10)
82
82
  table.add_column("Status", width=10)
@@ -89,7 +89,7 @@ def findings_group(ctx, status, severity, page, size, as_json):
89
89
  stat = str(finding.get("status", "")).lower()
90
90
 
91
91
  table.add_row(
92
- str(finding.get("id", ""))[:10],
92
+ str(finding.get("id", "")),
93
93
  finding.get("title", finding.get("description", ""))[:40],
94
94
  SEVERITY_STYLES.get(sev, sev),
95
95
  STATUS_STYLES.get(stat, stat),
@@ -127,7 +127,7 @@ def _show_table(client: HumanboundClient, experiment_id: str, verdict: str, page
127
127
  return
128
128
 
129
129
  table = Table(title=f"Experiment Logs (page {page})")
130
- table.add_column("ID", style="dim", width=10)
130
+ table.add_column("ID", style="dim")
131
131
  table.add_column("Verdict", width=6)
132
132
  table.add_column("Severity", width=8)
133
133
  table.add_column("Category", width=15)
@@ -55,7 +55,7 @@ def list_members(as_json: bool):
55
55
  return
56
56
 
57
57
  table = Table(title="Organisation Members")
58
- table.add_column("ID", style="dim", width=10)
58
+ table.add_column("ID", style="dim")
59
59
  table.add_column("Email", style="bold")
60
60
  table.add_column("Username")
61
61
  table.add_column("Role")
@@ -70,7 +70,7 @@ def list_members(as_json: bool):
70
70
  }.get(str(role).lower(), str(role))
71
71
 
72
72
  table.add_row(
73
- str(member.get("id", ""))[:10],
73
+ str(member.get("id", "")),
74
74
  member.get("email", ""),
75
75
  member.get("username", ""),
76
76
  role_style,
@@ -47,7 +47,7 @@ def list_providers():
47
47
  return
48
48
 
49
49
  table = Table(title="Model Providers")
50
- table.add_column("ID", style="dim", width=12)
50
+ table.add_column("ID", style="dim")
51
51
  table.add_column("Name", style="bold")
52
52
  table.add_column("Model", width=25)
53
53
  table.add_column("Default", justify="center")
@@ -13,13 +13,8 @@ from ..exceptions import NotAuthenticatedError, APIError
13
13
 
14
14
  console = Console()
15
15
 
16
- # Available test categories
17
- TEST_CATEGORIES = [
18
- "humanbound/adversarial/owasp_single_turn",
19
- "humanbound/adversarial/owasp_multi_turn",
20
- "humanbound/adversarial/owasp_agentic_multi_turn",
21
- "humanbound/behavioral/behavioral",
22
- ]
16
+ # Default test category
17
+ DEFAULT_TEST_CATEGORY = "humanbound/adversarial/owasp_multi_turn"
23
18
 
24
19
  # Testing levels (must match backend TestingLevel enum)
25
20
  # unit (~20 min), system (~45 min), acceptance (~90 min)
@@ -62,7 +57,7 @@ def _load_integration(value: str) -> dict:
62
57
  raise SystemExit(1)
63
58
 
64
59
  try:
65
- return json.loads(value)
60
+ return json.loads(value.strip())
66
61
  except json.JSONDecodeError:
67
62
  console.print(f"[red]--endpoint must be a JSON string or path to a JSON file.[/red]")
68
63
  console.print("[dim]Example: --endpoint ./bot-config.json[/dim]")
@@ -73,9 +68,8 @@ def _load_integration(value: str) -> dict:
73
68
  @click.command("test")
74
69
  @click.option(
75
70
  "--test-category", "-t",
76
- type=click.Choice(TEST_CATEGORIES, case_sensitive=False),
77
- default="humanbound/adversarial/owasp_multi_turn",
78
- help="Test category to run"
71
+ default=DEFAULT_TEST_CATEGORY,
72
+ help="Test category to run (e.g. humanbound/adversarial/owasp_multi_turn, humanbound/behavioral/qa)"
79
73
  )
80
74
  @click.option(
81
75
  "--testing-level", "-l",
@@ -87,6 +81,11 @@ def _load_integration(value: str) -> dict:
87
81
  "--name", "-n",
88
82
  help="Experiment name (auto-generated if not provided)"
89
83
  )
84
+ @click.option(
85
+ "--description", "-d",
86
+ default="",
87
+ help="Experiment description"
88
+ )
90
89
  @click.option(
91
90
  "--lang",
92
91
  default="english",
@@ -117,8 +116,8 @@ def _load_integration(value: str) -> dict:
117
116
  type=click.Choice(["critical", "high", "medium", "low", "any"]),
118
117
  help="Exit with error if findings of this severity or higher are found"
119
118
  )
120
- def test_command(test_category: str, testing_level: str, name: str, lang: str,
121
- provider_id: str, endpoint: str,
119
+ def test_command(test_category: str, testing_level: str, name: str, description: str,
120
+ lang: str, provider_id: str, endpoint: str,
122
121
  no_auto_start: bool,
123
122
  wait: bool, fail_on: str):
124
123
  """Run security tests on the current project.
@@ -185,6 +184,7 @@ def test_command(test_category: str, testing_level: str, name: str, lang: str,
185
184
  # Create experiment
186
185
  experiment_data = {
187
186
  "name": name,
187
+ "description": description,
188
188
  "test_category": test_category,
189
189
  "testing_level": testing_level,
190
190
  "lang": lang,
@@ -3,6 +3,7 @@
3
3
  import click
4
4
  from rich.console import Console
5
5
 
6
+ from . import __version__
6
7
  from .client import HumanboundClient
7
8
  from .config import get_base_url
8
9
 
@@ -35,6 +36,7 @@ def get_client() -> HumanboundClient:
35
36
 
36
37
 
37
38
  @click.group()
39
+ @click.version_option(version=__version__, prog_name="hb")
38
40
  @click.option(
39
41
  "--base-url",
40
42
  envvar="HUMANBOUND_BASE_URL",
@@ -98,10 +100,11 @@ def login_alias(ctx):
98
100
 
99
101
  @cli.command("logout")
100
102
  @click.option("--revoke", is_flag=True, help="Also clear browser SSO session (opens browser)")
103
+ @click.option("--port", default=8085, help="Local callback port (default: 8085)")
101
104
  @click.pass_context
102
- def logout_alias(ctx, revoke):
105
+ def logout_alias(ctx, revoke, port):
103
106
  """Clear stored credentials (alias for 'auth logout')."""
104
- ctx.invoke(auth.logout, revoke=revoke)
107
+ ctx.invoke(auth.logout, revoke=revoke, port=port)
105
108
 
106
109
 
107
110
  @cli.command("whoami")
@@ -148,12 +151,24 @@ def switch_org(org_id: str):
148
151
  @cli.command("status")
149
152
  @click.argument("experiment_id", required=False)
150
153
  @click.option("--watch", "-w", is_flag=True, help="Watch status until completion")
154
+ @click.option("--all", "show_all", is_flag=True, help="Show all project experiments (polls every 60s)")
151
155
  @click.pass_context
152
- def status_alias(ctx, experiment_id: str, watch: bool):
156
+ def status_alias(ctx, experiment_id: str, watch: bool, show_all: bool):
153
157
  """Check experiment status (alias for 'experiments status').
154
158
 
155
159
  If no experiment_id is provided, shows the most recent experiment.
160
+ Use --all to see a dashboard of all project experiments.
156
161
  """
162
+ if show_all:
163
+ ctx.invoke(
164
+ experiments.experiment_status,
165
+ experiment_id=None,
166
+ watch=False,
167
+ interval=10,
168
+ show_all=True,
169
+ )
170
+ return
171
+
157
172
  client = HumanboundClient()
158
173
 
159
174
  if not experiment_id:
@@ -179,6 +194,7 @@ def status_alias(ctx, experiment_id: str, watch: bool):
179
194
  experiment_id=experiment_id,
180
195
  watch=watch,
181
196
  interval=10,
197
+ show_all=False,
182
198
  )
183
199
 
184
200
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: humanbound-cli
3
- Version: 0.3.1
3
+ Version: 0.3.3
4
4
  Summary: Humanbound CLI - command line interface for AI agent security testing.
5
5
  Author-email: Kostas Siabanis <hello@humanbound.ai>, Demetris Gerogiannis <hello@humanbound.ai>
6
6
  License: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "humanbound-cli"
7
- version = "0.3.1"
7
+ version = "0.3.3"
8
8
  authors = [
9
9
  { name="Kostas Siabanis", email="hello@humanbound.ai" },
10
10
  { name="Demetris Gerogiannis", email="hello@humanbound.ai" },
@@ -1,3 +0,0 @@
1
- """Humanbound CLI - command line interface for AI agent security testing."""
2
-
3
- __version__ = "0.2.0"
File without changes
File without changes
File without changes