bt-cli 0.4.13__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. bt_cli/__init__.py +3 -0
  2. bt_cli/cli.py +830 -0
  3. bt_cli/commands/__init__.py +1 -0
  4. bt_cli/commands/configure.py +415 -0
  5. bt_cli/commands/learn.py +229 -0
  6. bt_cli/commands/quick.py +784 -0
  7. bt_cli/core/__init__.py +1 -0
  8. bt_cli/core/auth.py +213 -0
  9. bt_cli/core/client.py +313 -0
  10. bt_cli/core/config.py +393 -0
  11. bt_cli/core/config_file.py +420 -0
  12. bt_cli/core/csv_utils.py +91 -0
  13. bt_cli/core/errors.py +247 -0
  14. bt_cli/core/output.py +205 -0
  15. bt_cli/core/prompts.py +87 -0
  16. bt_cli/core/rest_debug.py +221 -0
  17. bt_cli/data/CLAUDE.md +94 -0
  18. bt_cli/data/__init__.py +0 -0
  19. bt_cli/data/skills/bt/SKILL.md +108 -0
  20. bt_cli/data/skills/entitle/SKILL.md +170 -0
  21. bt_cli/data/skills/epmw/SKILL.md +144 -0
  22. bt_cli/data/skills/pra/SKILL.md +150 -0
  23. bt_cli/data/skills/pws/SKILL.md +198 -0
  24. bt_cli/entitle/__init__.py +1 -0
  25. bt_cli/entitle/client/__init__.py +5 -0
  26. bt_cli/entitle/client/base.py +443 -0
  27. bt_cli/entitle/commands/__init__.py +24 -0
  28. bt_cli/entitle/commands/accounts.py +53 -0
  29. bt_cli/entitle/commands/applications.py +39 -0
  30. bt_cli/entitle/commands/auth.py +68 -0
  31. bt_cli/entitle/commands/bundles.py +218 -0
  32. bt_cli/entitle/commands/integrations.py +60 -0
  33. bt_cli/entitle/commands/permissions.py +70 -0
  34. bt_cli/entitle/commands/policies.py +97 -0
  35. bt_cli/entitle/commands/resources.py +131 -0
  36. bt_cli/entitle/commands/roles.py +74 -0
  37. bt_cli/entitle/commands/users.py +123 -0
  38. bt_cli/entitle/commands/workflows.py +187 -0
  39. bt_cli/entitle/models/__init__.py +31 -0
  40. bt_cli/entitle/models/bundle.py +28 -0
  41. bt_cli/entitle/models/common.py +37 -0
  42. bt_cli/entitle/models/integration.py +30 -0
  43. bt_cli/entitle/models/permission.py +27 -0
  44. bt_cli/entitle/models/policy.py +25 -0
  45. bt_cli/entitle/models/resource.py +29 -0
  46. bt_cli/entitle/models/role.py +28 -0
  47. bt_cli/entitle/models/user.py +24 -0
  48. bt_cli/entitle/models/workflow.py +55 -0
  49. bt_cli/epmw/__init__.py +1 -0
  50. bt_cli/epmw/client/__init__.py +5 -0
  51. bt_cli/epmw/client/base.py +848 -0
  52. bt_cli/epmw/commands/__init__.py +33 -0
  53. bt_cli/epmw/commands/audits.py +250 -0
  54. bt_cli/epmw/commands/auth.py +55 -0
  55. bt_cli/epmw/commands/computers.py +140 -0
  56. bt_cli/epmw/commands/events.py +233 -0
  57. bt_cli/epmw/commands/groups.py +215 -0
  58. bt_cli/epmw/commands/policies.py +673 -0
  59. bt_cli/epmw/commands/quick.py +348 -0
  60. bt_cli/epmw/commands/requests.py +224 -0
  61. bt_cli/epmw/commands/roles.py +78 -0
  62. bt_cli/epmw/commands/tasks.py +38 -0
  63. bt_cli/epmw/commands/users.py +219 -0
  64. bt_cli/epmw/models/__init__.py +1 -0
  65. bt_cli/pra/__init__.py +1 -0
  66. bt_cli/pra/client/__init__.py +5 -0
  67. bt_cli/pra/client/base.py +618 -0
  68. bt_cli/pra/commands/__init__.py +30 -0
  69. bt_cli/pra/commands/auth.py +55 -0
  70. bt_cli/pra/commands/import_export.py +442 -0
  71. bt_cli/pra/commands/jump_clients.py +139 -0
  72. bt_cli/pra/commands/jump_groups.py +146 -0
  73. bt_cli/pra/commands/jump_items.py +638 -0
  74. bt_cli/pra/commands/jumpoints.py +95 -0
  75. bt_cli/pra/commands/policies.py +197 -0
  76. bt_cli/pra/commands/quick.py +470 -0
  77. bt_cli/pra/commands/teams.py +81 -0
  78. bt_cli/pra/commands/users.py +87 -0
  79. bt_cli/pra/commands/vault.py +564 -0
  80. bt_cli/pra/models/__init__.py +27 -0
  81. bt_cli/pra/models/common.py +12 -0
  82. bt_cli/pra/models/jump_client.py +25 -0
  83. bt_cli/pra/models/jump_group.py +15 -0
  84. bt_cli/pra/models/jump_item.py +72 -0
  85. bt_cli/pra/models/jumpoint.py +19 -0
  86. bt_cli/pra/models/team.py +14 -0
  87. bt_cli/pra/models/user.py +17 -0
  88. bt_cli/pra/models/vault.py +45 -0
  89. bt_cli/pws/__init__.py +1 -0
  90. bt_cli/pws/client/__init__.py +5 -0
  91. bt_cli/pws/client/base.py +356 -0
  92. bt_cli/pws/client/beyondinsight.py +869 -0
  93. bt_cli/pws/client/passwordsafe.py +1786 -0
  94. bt_cli/pws/commands/__init__.py +33 -0
  95. bt_cli/pws/commands/accounts.py +372 -0
  96. bt_cli/pws/commands/assets.py +311 -0
  97. bt_cli/pws/commands/auth.py +166 -0
  98. bt_cli/pws/commands/clouds.py +221 -0
  99. bt_cli/pws/commands/config.py +344 -0
  100. bt_cli/pws/commands/credentials.py +347 -0
  101. bt_cli/pws/commands/databases.py +306 -0
  102. bt_cli/pws/commands/directories.py +199 -0
  103. bt_cli/pws/commands/functional.py +298 -0
  104. bt_cli/pws/commands/import_export.py +452 -0
  105. bt_cli/pws/commands/platforms.py +118 -0
  106. bt_cli/pws/commands/quick.py +1646 -0
  107. bt_cli/pws/commands/search.py +256 -0
  108. bt_cli/pws/commands/secrets.py +1343 -0
  109. bt_cli/pws/commands/systems.py +389 -0
  110. bt_cli/pws/commands/users.py +415 -0
  111. bt_cli/pws/commands/workgroups.py +166 -0
  112. bt_cli/pws/config.py +18 -0
  113. bt_cli/pws/models/__init__.py +19 -0
  114. bt_cli/pws/models/account.py +186 -0
  115. bt_cli/pws/models/asset.py +102 -0
  116. bt_cli/pws/models/common.py +132 -0
  117. bt_cli/pws/models/system.py +121 -0
  118. bt_cli-0.4.13.dist-info/METADATA +417 -0
  119. bt_cli-0.4.13.dist-info/RECORD +121 -0
  120. bt_cli-0.4.13.dist-info/WHEEL +4 -0
  121. bt_cli-0.4.13.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,1646 @@
1
+ """Quick commands for Password Safe - combine multiple API calls into single operations."""
2
+
3
+ from typing import Optional
4
+ import json
5
+
6
+ import httpx
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.panel import Panel
10
+ from rich.table import Table
11
+
12
+ from ...core.output import print_api_error, print_error, print_success, print_warning
13
+ from ...core.prompts import prompt_if_missing, prompt_from_list, prompt_choice
14
+ from ..client.base import get_client
15
+
16
+ app = typer.Typer(no_args_is_help=True, help="Quick commands - common multi-step operations in one command")
17
+ console = Console()
18
+
19
+
20
+ @app.command("checkout")
21
+ def quick_checkout(
22
+ system: Optional[str] = typer.Option(None, "--system", "-s", help="System name (partial match supported)"),
23
+ account: Optional[str] = typer.Option(None, "--account", "-a", help="Account name"),
24
+ duration: int = typer.Option(60, "--duration", "-d", help="Duration in minutes"),
25
+ reason: Optional[str] = typer.Option(None, "--reason", "-r", help="Reason for checkout"),
26
+ raw: bool = typer.Option(False, "--raw", help="Output only the password (for scripts)"),
27
+ output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
28
+ ) -> None:
29
+ """Checkout credentials and show password in one step.
30
+
31
+ Combines: find system -> find account -> checkout -> show password
32
+
33
+ If system or account not provided, prompts interactively.
34
+
35
+ Examples:
36
+ bt pws quick checkout # Interactive mode
37
+ bt pws quick checkout -s "axion-finapp-01" -a "root"
38
+ bt pws quick checkout -s axion -a root --duration 30
39
+ PASSWORD=$(bt pws quick checkout -s server -a admin --raw)
40
+ """
41
+ try:
42
+ with get_client() as client:
43
+ client.authenticate()
44
+
45
+ # Interactive prompt for system if not provided
46
+ if not system:
47
+ system = prompt_if_missing(system, "System name (or partial match)")
48
+
49
+ # Step 1: Find the system
50
+ console.print(f"[dim]Finding system '{system}'...[/dim]") if not raw else None
51
+ systems = client.list_managed_systems(search=system)
52
+
53
+ if not systems:
54
+ print_error(f"No system found matching '{system}'")
55
+ raise typer.Exit(1)
56
+
57
+ # Try exact match first, then partial
58
+ matched_system = None
59
+ for s in systems:
60
+ if s.get("SystemName", "").lower() == system.lower():
61
+ matched_system = s
62
+ break
63
+
64
+ if not matched_system:
65
+ # Use first partial match
66
+ matched_system = systems[0]
67
+ if len(systems) > 1 and not raw:
68
+ console.print(f"[yellow]Multiple matches found, using: {matched_system.get('SystemName')}[/yellow]")
69
+
70
+ system_id = matched_system.get("ManagedSystemID")
71
+ system_name = matched_system.get("SystemName")
72
+
73
+ # Step 2: Find the account
74
+ accounts = client.list_managed_accounts(system_id=system_id)
75
+
76
+ # Interactive prompt for account if not provided
77
+ if not account:
78
+ if not accounts:
79
+ print_error(f"No accounts found on system '{system_name}'")
80
+ raise typer.Exit(1)
81
+ account = prompt_from_list(
82
+ accounts, "Account name", "AccountName", "AccountName",
83
+ f"Accounts on {system_name}", str
84
+ )
85
+
86
+ console.print(f"[dim]Finding account '{account}' on {system_name}...[/dim]") if not raw else None
87
+
88
+ matched_account = None
89
+ for acc in accounts:
90
+ if acc.get("AccountName", "").lower() == account.lower():
91
+ matched_account = acc
92
+ break
93
+
94
+ if not matched_account:
95
+ print_error(f"Account '{account}' not found on system '{system_name}'")
96
+ # Show available accounts
97
+ if accounts and not raw:
98
+ console.print("[dim]Available accounts:[/dim]")
99
+ for acc in accounts[:10]:
100
+ console.print(f" - {acc.get('AccountName')}")
101
+ raise typer.Exit(1)
102
+
103
+ account_id = matched_account.get("AccountId", matched_account.get("ManagedAccountID"))
104
+ account_name = matched_account.get("AccountName")
105
+
106
+ # Step 3: Checkout
107
+ console.print(f"[dim]Checking out {account_name}@{system_name}...[/dim]") if not raw else None
108
+ request = client.create_request(
109
+ account_id=account_id,
110
+ system_id=system_id,
111
+ duration_minutes=duration,
112
+ reason=reason,
113
+ access_type="View",
114
+ )
115
+ request_id = request.get("RequestID")
116
+
117
+ # Step 4: Get the credential
118
+ credential = client.get_credential(request_id)
119
+ password = credential.get("Password", "")
120
+
121
+ # Output
122
+ if raw:
123
+ print(password, end="")
124
+ elif output == "json":
125
+ result = {
126
+ "request_id": request_id,
127
+ "system": system_name,
128
+ "system_id": system_id,
129
+ "account": account_name,
130
+ "account_id": account_id,
131
+ "password": password,
132
+ "duration_minutes": duration,
133
+ }
134
+ console.print_json(json.dumps(result))
135
+ else:
136
+ console.print(Panel(
137
+ f"[green]Credential checked out successfully![/green]\n\n"
138
+ f"System: [cyan]{system_name}[/cyan] (ID: {system_id})\n"
139
+ f"Account: [cyan]{account_name}[/cyan] (ID: {account_id})\n"
140
+ f"Request ID: [bold yellow]{request_id}[/bold yellow]\n"
141
+ f"Duration: {duration} minutes\n\n"
142
+ f"Password: [bold green]{password}[/bold green]\n\n"
143
+ f"[dim]Checkin: bt pws credentials checkin {request_id}[/dim]",
144
+ title="Quick Checkout",
145
+ ))
146
+
147
+ except httpx.HTTPStatusError as e:
148
+ print_api_error(e, "quick checkout")
149
+ raise typer.Exit(1)
150
+ except httpx.RequestError as e:
151
+ print_api_error(e, "quick checkout")
152
+ raise typer.Exit(1)
153
+ except typer.Exit:
154
+ raise
155
+ except Exception as e:
156
+ print_api_error(e, "quick checkout")
157
+ raise typer.Exit(1)
158
+
159
+
160
+ @app.command("checkin")
161
+ def quick_checkin(
162
+ request_id: int = typer.Argument(..., help="Request ID to check in"),
163
+ rotate: bool = typer.Option(False, "--rotate", "-r", help="Rotate password after checkin"),
164
+ ) -> None:
165
+ """Check in a credential with optional password rotation.
166
+
167
+ Examples:
168
+ bt pws quick checkin 17
169
+ bt pws quick checkin 17 --rotate
170
+ """
171
+ try:
172
+ with get_client() as client:
173
+ client.authenticate()
174
+
175
+ if rotate:
176
+ console.print("[dim]Scheduling password rotation...[/dim]")
177
+ client.rotate_on_checkin(request_id)
178
+
179
+ console.print("[dim]Checking in credential...[/dim]")
180
+ client.checkin_request(request_id)
181
+
182
+ print_success(f"Credential {request_id} checked in successfully!")
183
+ if rotate:
184
+ print_warning("Password rotation scheduled - new password will be generated.")
185
+
186
+ except httpx.HTTPStatusError as e:
187
+ print_api_error(e, "quick checkin")
188
+ raise typer.Exit(1)
189
+ except httpx.RequestError as e:
190
+ print_api_error(e, "quick checkin")
191
+ raise typer.Exit(1)
192
+ except Exception as e:
193
+ print_api_error(e, "quick checkin")
194
+ raise typer.Exit(1)
195
+
196
+
197
+ @app.command("search")
198
+ def quick_search(
199
+ query: str = typer.Argument(..., help="Search term (searches systems and accounts)"),
200
+ limit: int = typer.Option(20, "--limit", "-l", help="Maximum results per category"),
201
+ output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
202
+ ) -> None:
203
+ """Search across systems and accounts in one command.
204
+
205
+ Examples:
206
+ bt pws quick search axion
207
+ bt pws quick search root
208
+ bt pws quick search database -o json
209
+ """
210
+ try:
211
+ with get_client() as client:
212
+ client.authenticate()
213
+
214
+ # Search systems (filter client-side since API doesn't support search well)
215
+ console.print(f"[dim]Searching systems for '{query}'...[/dim]")
216
+ all_systems = client.list_managed_systems()
217
+ query_lower = query.lower()
218
+ systems = [
219
+ s for s in all_systems
220
+ if query_lower in s.get("SystemName", "").lower()
221
+ ][:limit]
222
+
223
+ # Search accounts
224
+ console.print(f"[dim]Searching accounts for '{query}'...[/dim]")
225
+ accounts = client.list_managed_accounts(account_name=query, limit=limit)
226
+
227
+ if output == "json":
228
+ result = {
229
+ "query": query,
230
+ "systems": systems,
231
+ "accounts": accounts,
232
+ }
233
+ console.print_json(json.dumps(result, default=str))
234
+ else:
235
+ # Show systems
236
+ if systems:
237
+ table = Table(title=f"Systems matching '{query}'")
238
+ table.add_column("ID", style="cyan")
239
+ table.add_column("Name", style="green")
240
+ table.add_column("Platform", style="yellow")
241
+ table.add_column("Workgroup", style="magenta")
242
+
243
+ for s in systems[:limit]:
244
+ table.add_row(
245
+ str(s.get("ManagedSystemID", "")),
246
+ s.get("SystemName", ""),
247
+ str(s.get("PlatformID", "")),
248
+ str(s.get("WorkgroupID", "")),
249
+ )
250
+ console.print(table)
251
+ else:
252
+ console.print(f"[yellow]No systems found matching '{query}'[/yellow]")
253
+
254
+ console.print()
255
+
256
+ # Show accounts
257
+ if accounts:
258
+ table = Table(title=f"Accounts matching '{query}'")
259
+ table.add_column("Acct ID", style="cyan")
260
+ table.add_column("Account", style="green")
261
+ table.add_column("Sys ID", style="blue")
262
+ table.add_column("System", style="magenta")
263
+
264
+ for a in accounts[:limit]:
265
+ table.add_row(
266
+ str(a.get("AccountId", a.get("ManagedAccountID", ""))),
267
+ a.get("AccountName", ""),
268
+ str(a.get("SystemId", a.get("ManagedSystemID", ""))),
269
+ a.get("SystemName", ""),
270
+ )
271
+ console.print(table)
272
+ else:
273
+ console.print(f"[yellow]No accounts found matching '{query}'[/yellow]")
274
+
275
+ except httpx.HTTPStatusError as e:
276
+ print_api_error(e, "quick search")
277
+ raise typer.Exit(1)
278
+ except httpx.RequestError as e:
279
+ print_api_error(e, "quick search")
280
+ raise typer.Exit(1)
281
+ except Exception as e:
282
+ print_api_error(e, "quick search")
283
+ raise typer.Exit(1)
284
+
285
+
286
+ @app.command("password")
287
+ def quick_password(
288
+ system: Optional[str] = typer.Option(None, "--system", "-s", help="System name"),
289
+ account: Optional[str] = typer.Option(None, "--account", "-a", help="Account name"),
290
+ duration: int = typer.Option(5, "--duration", "-d", help="Duration in minutes (default: 5)"),
291
+ auto_checkin: bool = typer.Option(True, "--auto-checkin/--no-auto-checkin", help="Auto checkin after showing password"),
292
+ ) -> None:
293
+ """Get a password quickly - checkout, show, and optionally auto-checkin.
294
+
295
+ Ideal for quick lookups where you just need to see/copy the password.
296
+ If system or account not provided, prompts interactively.
297
+
298
+ Examples:
299
+ bt pws quick password # Interactive mode
300
+ bt pws quick password -s server -a root
301
+ bt pws quick password -s db-server -a admin --no-auto-checkin
302
+ """
303
+ try:
304
+ with get_client() as client:
305
+ client.authenticate()
306
+
307
+ # Interactive prompt for system if not provided
308
+ if not system:
309
+ system = prompt_if_missing(system, "System name (or partial match)")
310
+
311
+ # Find system
312
+ systems = client.list_managed_systems(search=system)
313
+ if not systems:
314
+ print_error(f"No system found matching '{system}'")
315
+ raise typer.Exit(1)
316
+
317
+ matched_system = None
318
+ for s in systems:
319
+ if s.get("SystemName", "").lower() == system.lower():
320
+ matched_system = s
321
+ break
322
+ if not matched_system:
323
+ matched_system = systems[0]
324
+
325
+ system_id = matched_system.get("ManagedSystemID")
326
+ system_name = matched_system.get("SystemName")
327
+
328
+ # Find account
329
+ accounts = client.list_managed_accounts(system_id=system_id)
330
+
331
+ # Interactive prompt for account if not provided
332
+ if not account:
333
+ if not accounts:
334
+ print_error(f"No accounts found on system '{system_name}'")
335
+ raise typer.Exit(1)
336
+ account = prompt_from_list(
337
+ accounts, "Account name", "AccountName", "AccountName",
338
+ f"Accounts on {system_name}", str
339
+ )
340
+
341
+ matched_account = None
342
+ for acc in accounts:
343
+ if acc.get("AccountName", "").lower() == account.lower():
344
+ matched_account = acc
345
+ break
346
+
347
+ if not matched_account:
348
+ print_error(f"Account '{account}' not found on '{system_name}'")
349
+ raise typer.Exit(1)
350
+
351
+ account_id = matched_account.get("AccountId", matched_account.get("ManagedAccountID"))
352
+ account_name = matched_account.get("AccountName")
353
+
354
+ # Checkout
355
+ console.print(f"[dim]Checking out {account_name}@{system_name}...[/dim]")
356
+ request = client.create_request(
357
+ account_id=account_id,
358
+ system_id=system_id,
359
+ duration_minutes=duration,
360
+ access_type="View",
361
+ )
362
+ request_id = request.get("RequestID")
363
+
364
+ # Get password
365
+ credential = client.get_credential(request_id)
366
+ password = credential.get("Password", "")
367
+
368
+ # Show password
369
+ console.print(f"\n[bold green]{password}[/bold green]\n")
370
+ console.print(f"[dim]{account_name}@{system_name} (Request: {request_id})[/dim]")
371
+
372
+ # Auto checkin
373
+ if auto_checkin:
374
+ console.print("[dim]Checking in...[/dim]")
375
+ client.checkin_request(request_id)
376
+ console.print("[green]Auto checked in.[/green]")
377
+ else:
378
+ console.print(f"\n[yellow]Remember to checkin: bt pws credentials checkin {request_id}[/yellow]")
379
+
380
+ except httpx.HTTPStatusError as e:
381
+ print_api_error(e, "quick password")
382
+ raise typer.Exit(1)
383
+ except httpx.RequestError as e:
384
+ print_api_error(e, "quick password")
385
+ raise typer.Exit(1)
386
+ except typer.Exit:
387
+ raise
388
+ except Exception as e:
389
+ print_api_error(e, "quick password")
390
+ raise typer.Exit(1)
391
+
392
+
393
+ @app.command("rotate")
394
+ def quick_rotate(
395
+ system: str = typer.Option(..., "--system", "-s", help="System name (partial match supported)"),
396
+ account: str = typer.Option(..., "--account", "-a", help="Account name"),
397
+ ) -> None:
398
+ """Find an account and trigger password rotation.
399
+
400
+ Combines: find system -> find account -> trigger rotation
401
+
402
+ Examples:
403
+ bt pws quick rotate -s "axion-finapp-01" -a "root"
404
+ bt pws quick rotate -s axion -a svc-backup
405
+ """
406
+ try:
407
+ with get_client() as client:
408
+ client.authenticate()
409
+
410
+ # Step 1: Find the system
411
+ console.print(f"[dim]Finding system '{system}'...[/dim]")
412
+ systems = client.list_managed_systems(search=system)
413
+
414
+ if not systems:
415
+ print_error(f"No system found matching '{system}'")
416
+ raise typer.Exit(1)
417
+
418
+ # Try exact match first, then partial
419
+ matched_system = None
420
+ for s in systems:
421
+ if s.get("SystemName", "").lower() == system.lower():
422
+ matched_system = s
423
+ break
424
+
425
+ if not matched_system:
426
+ matched_system = systems[0]
427
+ if len(systems) > 1:
428
+ console.print(f"[yellow]Multiple matches found, using: {matched_system.get('SystemName')}[/yellow]")
429
+
430
+ system_id = matched_system.get("ManagedSystemID")
431
+ system_name = matched_system.get("SystemName")
432
+
433
+ # Step 2: Find the account
434
+ console.print(f"[dim]Finding account '{account}' on {system_name}...[/dim]")
435
+ accounts = client.list_managed_accounts(system_id=system_id)
436
+
437
+ matched_account = None
438
+ for acc in accounts:
439
+ if acc.get("AccountName", "").lower() == account.lower():
440
+ matched_account = acc
441
+ break
442
+
443
+ if not matched_account:
444
+ print_error(f"Account '{account}' not found on system '{system_name}'")
445
+ if accounts:
446
+ console.print("[dim]Available accounts:[/dim]")
447
+ for acc in accounts[:10]:
448
+ console.print(f" - {acc.get('AccountName')}")
449
+ raise typer.Exit(1)
450
+
451
+ account_id = matched_account.get("AccountId", matched_account.get("ManagedAccountID"))
452
+ account_name = matched_account.get("AccountName")
453
+
454
+ # Step 3: Trigger rotation
455
+ console.print(f"[dim]Triggering password rotation for {account_name}@{system_name}...[/dim]")
456
+ client.change_managed_account_password(account_id)
457
+
458
+ print_success(f"Password rotation initiated for {account_name}@{system_name}")
459
+ console.print("[dim]Note: The new password will be generated using the configured password rule.[/dim]")
460
+
461
+ except httpx.HTTPStatusError as e:
462
+ print_api_error(e, "quick rotate")
463
+ raise typer.Exit(1)
464
+ except httpx.RequestError as e:
465
+ print_api_error(e, "quick rotate")
466
+ raise typer.Exit(1)
467
+ except typer.Exit:
468
+ raise
469
+ except Exception as e:
470
+ print_api_error(e, "quick rotate")
471
+ raise typer.Exit(1)
472
+
473
+
474
+ @app.command("find-secret")
475
+ def quick_find_secret(
476
+ query: str = typer.Argument(..., help="Search term (searches folders and secrets)"),
477
+ limit: int = typer.Option(20, "--limit", "-l", help="Maximum results per category"),
478
+ output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
479
+ ) -> None:
480
+ """Search Secrets Safe folders and secrets.
481
+
482
+ Examples:
483
+ bt pws quick find-secret database
484
+ bt pws quick find-secret admin
485
+ bt pws quick find-secret api-key -o json
486
+ """
487
+ try:
488
+ with get_client() as client:
489
+ client.authenticate()
490
+ query_lower = query.lower()
491
+
492
+ # Search folders
493
+ console.print(f"[dim]Searching folders for '{query}'...[/dim]")
494
+ all_folders = client.list_folders()
495
+ folders = [
496
+ f for f in all_folders
497
+ if query_lower in f.get("Name", "").lower()
498
+ or query_lower in (f.get("Description") or "").lower()
499
+ ][:limit]
500
+
501
+ # Search secrets
502
+ console.print(f"[dim]Searching secrets for '{query}'...[/dim]")
503
+ all_secrets = client.list_secrets()
504
+ secrets = [
505
+ s for s in all_secrets
506
+ if query_lower in s.get("Title", "").lower()
507
+ or query_lower in (s.get("Username") or "").lower()
508
+ or query_lower in (s.get("Description") or "").lower()
509
+ ][:limit]
510
+
511
+ if output == "json":
512
+ result = {
513
+ "query": query,
514
+ "folders": folders,
515
+ "secrets": secrets,
516
+ }
517
+ console.print_json(json.dumps(result, default=str))
518
+ else:
519
+ # Show folders
520
+ if folders:
521
+ table = Table(title=f"Folders matching '{query}'")
522
+ table.add_column("ID", style="cyan")
523
+ table.add_column("Name", style="green")
524
+ table.add_column("Path", style="yellow")
525
+ table.add_column("Description", style="dim")
526
+
527
+ for f in folders:
528
+ table.add_row(
529
+ str(f.get("Id", "")),
530
+ f.get("Name", ""),
531
+ f.get("FolderPath", "") or "-",
532
+ (f.get("Description") or "-")[:40],
533
+ )
534
+ console.print(table)
535
+ else:
536
+ console.print(f"[yellow]No folders found matching '{query}'[/yellow]")
537
+
538
+ console.print()
539
+
540
+ # Show secrets
541
+ if secrets:
542
+ table = Table(title=f"Secrets matching '{query}'")
543
+ table.add_column("ID", style="cyan")
544
+ table.add_column("Title", style="green")
545
+ table.add_column("Username", style="yellow")
546
+ table.add_column("Folder", style="magenta")
547
+
548
+ for s in secrets:
549
+ table.add_row(
550
+ str(s.get("Id", "")),
551
+ s.get("Title", ""),
552
+ s.get("Username", "") or "-",
553
+ s.get("FolderName", "") or "-",
554
+ )
555
+ console.print(table)
556
+ console.print(f"\n[dim]To get secret value: bt pws secrets secrets get <id>[/dim]")
557
+ else:
558
+ console.print(f"[yellow]No secrets found matching '{query}'[/yellow]")
559
+
560
+ except httpx.HTTPStatusError as e:
561
+ print_api_error(e, "quick find-secret")
562
+ raise typer.Exit(1)
563
+ except httpx.RequestError as e:
564
+ print_api_error(e, "quick find-secret")
565
+ raise typer.Exit(1)
566
+ except Exception as e:
567
+ print_api_error(e, "quick find-secret")
568
+ raise typer.Exit(1)
569
+
570
+
571
+ @app.command("offboard")
572
+ def quick_offboard(
573
+ system: str = typer.Option(..., "--system", "-s", help="System name (partial match supported)"),
574
+ force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
575
+ keep_asset: bool = typer.Option(False, "--keep-asset", help="Don't delete the asset"),
576
+ output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
577
+ ) -> None:
578
+ """Offboard a system: delete all accounts, system, and asset.
579
+
580
+ Performs cascading delete in order:
581
+ 1. Delete all managed accounts on the system
582
+ 2. Delete the managed system
583
+ 3. Delete the asset (unless --keep-asset)
584
+
585
+ Examples:
586
+ bt pws quick offboard -s "my-server"
587
+ bt pws quick offboard -s "web-01" --force
588
+ bt pws quick offboard -s "db-server" --keep-asset
589
+ """
590
+ try:
591
+ with get_client() as client:
592
+ client.authenticate()
593
+
594
+ # Step 1: Find the system
595
+ console.print(f"[dim]Finding system '{system}'...[/dim]")
596
+ systems = client.list_managed_systems(search=system)
597
+
598
+ if not systems:
599
+ print_error(f"No system found matching '{system}'")
600
+ raise typer.Exit(1)
601
+
602
+ # Try exact match first, then partial
603
+ matched_system = None
604
+ for s in systems:
605
+ if s.get("SystemName", "").lower() == system.lower():
606
+ matched_system = s
607
+ break
608
+
609
+ if not matched_system:
610
+ matched_system = systems[0]
611
+ if len(systems) > 1:
612
+ console.print(f"[yellow]Multiple matches found, using: {matched_system.get('SystemName')}[/yellow]")
613
+
614
+ system_id = matched_system.get("ManagedSystemID")
615
+ system_name = matched_system.get("SystemName")
616
+ asset_id = matched_system.get("AssetID")
617
+
618
+ # Get full system details if asset_id not in list response
619
+ if not asset_id:
620
+ full_system = client.get_managed_system(system_id)
621
+ asset_id = full_system.get("AssetID")
622
+
623
+ # Step 2: List accounts on this system
624
+ console.print(f"[dim]Finding accounts on {system_name}...[/dim]")
625
+ accounts = client.list_managed_accounts(system_id=system_id)
626
+
627
+ # Show what will be deleted
628
+ console.print(f"\n[bold]Will delete:[/bold]")
629
+ console.print(f" System: [cyan]{system_name}[/cyan] (ID: {system_id})")
630
+ if accounts:
631
+ console.print(f" Accounts ({len(accounts)}):")
632
+ for acc in accounts:
633
+ acc_id = acc.get("AccountId", acc.get("ManagedAccountID", ""))
634
+ console.print(f" - {acc.get('AccountName')} (ID: {acc_id})")
635
+ else:
636
+ console.print(" Accounts: None")
637
+ if asset_id and not keep_asset:
638
+ console.print(f" Asset ID: [yellow]{asset_id}[/yellow]")
639
+ elif keep_asset:
640
+ console.print(f" Asset ID: {asset_id} [dim](keeping)[/dim]")
641
+
642
+ # Confirm
643
+ if not force:
644
+ confirm = typer.confirm("\nProceed with deletion?")
645
+ if not confirm:
646
+ console.print("[yellow]Cancelled.[/yellow]")
647
+ raise typer.Exit(0)
648
+
649
+ deleted = {"accounts": [], "system": None, "asset": None}
650
+
651
+ # Step 3: Delete accounts
652
+ for acc in accounts:
653
+ acc_id = acc.get("AccountId", acc.get("ManagedAccountID"))
654
+ acc_name = acc.get("AccountName")
655
+ console.print(f"[dim]Deleting account {acc_name} (ID: {acc_id})...[/dim]")
656
+ client.delete_managed_account(acc_id)
657
+ deleted["accounts"].append({"id": acc_id, "name": acc_name})
658
+ console.print(f" [green]Deleted account: {acc_name}[/green]")
659
+
660
+ # Step 4: Delete system
661
+ console.print(f"[dim]Deleting system {system_name} (ID: {system_id})...[/dim]")
662
+ client.delete_managed_system(system_id)
663
+ deleted["system"] = {"id": system_id, "name": system_name}
664
+ console.print(f" [green]Deleted system: {system_name}[/green]")
665
+
666
+ # Step 5: Delete asset (unless --keep-asset)
667
+ if asset_id and not keep_asset:
668
+ console.print(f"[dim]Deleting asset (ID: {asset_id})...[/dim]")
669
+ client.delete_asset(asset_id)
670
+ deleted["asset"] = {"id": asset_id}
671
+ console.print(f" [green]Deleted asset ID: {asset_id}[/green]")
672
+
673
+ # Output
674
+ if output == "json":
675
+ console.print_json(json.dumps(deleted))
676
+ else:
677
+ console.print(f"\n[bold green]System '{system_name}' offboarded successfully![/bold green]")
678
+ console.print(f" Accounts deleted: {len(deleted['accounts'])}")
679
+ console.print(f" System deleted: {system_name}")
680
+ if deleted["asset"]:
681
+ console.print(f" Asset deleted: {deleted['asset']['id']}")
682
+
683
+ except httpx.HTTPStatusError as e:
684
+ print_api_error(e, "quick offboard")
685
+ raise typer.Exit(1)
686
+ except httpx.RequestError as e:
687
+ print_api_error(e, "quick offboard")
688
+ raise typer.Exit(1)
689
+ except typer.Exit:
690
+ raise
691
+ except Exception as e:
692
+ print_api_error(e, "quick offboard")
693
+ raise typer.Exit(1)
694
+
695
+
696
+ @app.command("onboard")
697
+ def quick_onboard(
698
+ name: Optional[str] = typer.Option(None, "--name", "-n", help="System name"),
699
+ ip: Optional[str] = typer.Option(None, "--ip", "-i", help="IP address"),
700
+ dns: Optional[str] = typer.Option(None, "--dns", "-d", help="DNS name (e.g., ip-10-0-1-50.compute.internal)"),
701
+ workgroup: Optional[int] = typer.Option(None, "--workgroup", "-w", help="Workgroup ID"),
702
+ platform: Optional[int] = typer.Option(None, "--platform", "-p", help="Platform ID (1=Windows, 2=Linux)"),
703
+ account: Optional[str] = typer.Option(None, "--account", "-a", help="Account name to create"),
704
+ password: Optional[str] = typer.Option(None, "--password", help="Account password"),
705
+ functional_account: Optional[int] = typer.Option(None, "--functional-account", "-f", help="Functional account ID for auto-management"),
706
+ auto_manage: bool = typer.Option(True, "--auto-manage/--no-auto-manage", help="Enable auto password management"),
707
+ port: Optional[int] = typer.Option(None, "--port", help="Connection port"),
708
+ elevation: Optional[str] = typer.Option(None, "--elevation", "-e", help="Elevation command (e.g., 'sudo')"),
709
+ output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
710
+ ) -> None:
711
+ """Onboard a system in one step: create asset, managed system, and account.
712
+
713
+ Combines: create asset -> create managed system -> create managed account
714
+
715
+ If required options are not provided, you will be prompted interactively.
716
+
717
+ Examples:
718
+ # Interactive mode - prompts for missing info
719
+ bt pws quick onboard
720
+
721
+ # Basic Linux server
722
+ bt pws quick onboard -n "my-server" -i "10.0.1.50" -w 3
723
+
724
+ # EC2 instance with internal DNS (recommended for AWS)
725
+ bt pws quick onboard -n "web-01" -i "10.0.1.100" -d "ip-10-0-1-100.compute.internal" -w 3
726
+
727
+ # With functional account for auto-management
728
+ bt pws quick onboard -n "web-01" -i "10.0.1.100" -w 3 -f 7 -e "sudo"
729
+
730
+ # Windows server
731
+ bt pws quick onboard -n "win-srv" -i "10.0.1.200" -w 2 -p 1 -a "Administrator" --port 5985
732
+
733
+ # With specific password
734
+ bt pws quick onboard -n "db-01" -i "10.0.1.150" -w 3 -a "postgres" --password "InitialPass123"
735
+ """
736
+ try:
737
+ with get_client() as client:
738
+ client.authenticate()
739
+
740
+ # Interactive prompting for missing required fields
741
+ name = prompt_if_missing(name, "System name")
742
+ ip = prompt_if_missing(ip, "IP address")
743
+
744
+ if workgroup is None:
745
+ workgroups = client.list_workgroups()
746
+ workgroup = prompt_from_list(
747
+ workgroups, "Workgroup ID", "ID", "Name", "Available Workgroups", int
748
+ )
749
+
750
+ # Prompt for platform if not specified
751
+ if platform is None:
752
+ platform_choice = prompt_choice(
753
+ "Platform",
754
+ [
755
+ ("linux", "Linux/Unix (SSH, port 22)"),
756
+ ("windows", "Windows (WinRM, port 5985)"),
757
+ ],
758
+ default="linux",
759
+ )
760
+ if platform_choice == "windows":
761
+ platform = 1
762
+ default_account = "Administrator"
763
+ default_port = 5985
764
+ else:
765
+ platform = 2
766
+ default_account = "root"
767
+ default_port = 22
768
+ else:
769
+ # Platform specified via CLI
770
+ if platform == 1:
771
+ default_account = "Administrator"
772
+ default_port = 5985
773
+ else:
774
+ default_account = "root"
775
+ default_port = 22
776
+
777
+ # Prompt for account name if not specified
778
+ if account is None:
779
+ account = typer.prompt("Account name", default=default_account)
780
+
781
+ # Set port default based on platform
782
+ if port is None:
783
+ port = default_port
784
+
785
+ # Optionally prompt for functional account
786
+ if functional_account is None:
787
+ setup_auto = typer.confirm("Configure auto-management with functional account?", default=False)
788
+ if setup_auto:
789
+ all_func_accounts = client.list_functional_accounts()
790
+ # Filter by platform (PlatformID must match selected platform)
791
+ func_accounts = [
792
+ fa for fa in all_func_accounts
793
+ if fa.get("PlatformID") == platform
794
+ ]
795
+ if func_accounts:
796
+ # Show DisplayName for clarity
797
+ for fa in func_accounts:
798
+ fa["_display"] = f"{fa.get('DisplayName', '')} ({fa.get('AccountName', '')})"
799
+ functional_account = prompt_from_list(
800
+ func_accounts,
801
+ "Functional Account ID",
802
+ "FunctionalAccountID",
803
+ "_display",
804
+ f"Functional Accounts for {'Linux' if platform == 2 else 'Windows'}",
805
+ int,
806
+ )
807
+ if platform == 2 and elevation is None:
808
+ elevation = typer.prompt("Elevation command", default="sudo")
809
+ else:
810
+ platform_name = "Linux" if platform == 2 else "Windows"
811
+ console.print(f"[yellow]No functional accounts found for {platform_name}. Skipping auto-management.[/yellow]")
812
+
813
+ # Step 1: Create asset
814
+ console.print(f"\n[dim]Creating asset '{name}'...[/dim]")
815
+ asset = client.create_asset(
816
+ workgroup_id=workgroup,
817
+ ip_address=ip,
818
+ asset_name=name,
819
+ dns_name=dns,
820
+ )
821
+ asset_id = asset.get("AssetID")
822
+ console.print(f" [green]Created asset ID: {asset_id}[/green]")
823
+
824
+ # Step 2: Create managed system
825
+ console.print(f"[dim]Creating managed system...[/dim]")
826
+ system = client.create_managed_system(
827
+ system_name=name,
828
+ platform_id=platform,
829
+ asset_id=asset_id,
830
+ port=port,
831
+ functional_account_id=functional_account,
832
+ auto_management_flag=auto_manage if functional_account else False,
833
+ elevation_command=elevation,
834
+ )
835
+ system_id = system.get("ManagedSystemID")
836
+ console.print(f" [green]Created managed system ID: {system_id}[/green]")
837
+
838
+ # Step 3: Create managed account
839
+ console.print(f"[dim]Creating managed account '{account}'...[/dim]")
840
+ account_obj = client.create_managed_account(
841
+ system_id=system_id,
842
+ account_name=account,
843
+ password=password,
844
+ auto_management_flag=auto_manage if functional_account else False,
845
+ )
846
+ account_id = account_obj.get("ManagedAccountID")
847
+ console.print(f" [green]Created managed account ID: {account_id}[/green]")
848
+
849
+ # Output
850
+ if output == "json":
851
+ result = {
852
+ "asset_id": asset_id,
853
+ "system_id": system_id,
854
+ "system_name": name,
855
+ "account_id": account_id,
856
+ "account_name": account,
857
+ "workgroup_id": workgroup,
858
+ "platform_id": platform,
859
+ "dns": dns,
860
+ }
861
+ console.print_json(json.dumps(result))
862
+ else:
863
+ dns_line = f"DNS: {dns}\n" if dns else ""
864
+ console.print(Panel(
865
+ f"[green]System onboarded successfully![/green]\n\n"
866
+ f"Asset ID: [cyan]{asset_id}[/cyan]\n"
867
+ f"System ID: [cyan]{system_id}[/cyan]\n"
868
+ f"System Name: [bold]{name}[/bold]\n"
869
+ f"Account ID: [cyan]{account_id}[/cyan]\n"
870
+ f"Account Name: [bold]{account}[/bold]\n"
871
+ f"IP: {ip}\n"
872
+ f"{dns_line}"
873
+ f"Port: {port}\n\n"
874
+ f"[dim]Checkout: bt pws quick checkout -s \"{name}\" -a \"{account}\"[/dim]",
875
+ title="Quick Onboard",
876
+ ))
877
+
878
+ except httpx.HTTPStatusError as e:
879
+ print_api_error(e, "quick onboard")
880
+ raise typer.Exit(1)
881
+ except httpx.RequestError as e:
882
+ print_api_error(e, "quick onboard")
883
+ raise typer.Exit(1)
884
+ except Exception as e:
885
+ print_api_error(e, "quick onboard")
886
+ raise typer.Exit(1)
887
+
888
+
889
+ @app.command("user-entitlements")
890
+ def quick_user_entitlements(
891
+ search: str = typer.Argument(..., help="User search (name or email, partial match)"),
892
+ output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
893
+ ) -> None:
894
+ """Show user entitlements report: groups, roles, and access policies.
895
+
896
+ Searches for a user and shows:
897
+ - User details
898
+ - Groups the user belongs to
899
+ - Role type for each group
900
+ - API registration info (for API/OAuth users)
901
+ - Access policies where user's groups are assignees
902
+
903
+ Examples:
904
+ bt pws quick user-entitlements dave
905
+ bt pws quick user-entitlements admin@example.com
906
+ bt pws quick user-entitlements nhi-provision -o json
907
+ """
908
+ try:
909
+ with get_client() as client:
910
+ client.authenticate()
911
+
912
+ # Step 1: Find the user (API search is exact, so do client-side filtering)
913
+ console.print(f"[dim]Searching for user '{search}'...[/dim]")
914
+ all_users = client.list_users()
915
+ search_lower = search.lower()
916
+ users = [
917
+ u for u in all_users
918
+ if search_lower in (u.get("UserName", "") or "").lower()
919
+ or search_lower in (u.get("EmailAddress", "") or "").lower()
920
+ or search_lower in (u.get("FirstName", "") or "").lower()
921
+ or search_lower in (u.get("LastName", "") or "").lower()
922
+ ]
923
+
924
+ if not users:
925
+ print_error(f"No user found matching '{search}'")
926
+ raise typer.Exit(1)
927
+
928
+ # If multiple users found, show selection
929
+ if len(users) > 1:
930
+ console.print(f"[yellow]Found {len(users)} users matching '{search}':[/yellow]")
931
+ for i, u in enumerate(users[:10]):
932
+ name = f"{u.get('FirstName', '')} {u.get('LastName', '')}".strip() or u.get('UserName')
933
+ console.print(f" {i+1}. {name} ({u.get('UserName')})")
934
+ if len(users) > 10:
935
+ console.print(f" ... and {len(users) - 10} more")
936
+ console.print()
937
+
938
+ user = users[0]
939
+ user_id = user.get("UserID")
940
+ user_name = user.get("UserName")
941
+
942
+ # Step 2: Get all groups and check membership
943
+ console.print(f"[dim]Finding groups for user '{user_name}'...[/dim]")
944
+ all_groups = client.list_user_groups()
945
+ user_groups = []
946
+
947
+ # Role type mapping
948
+ role_types = {0: "Standard", 1: "Administrator", 2: "Auditor"}
949
+
950
+ for group in all_groups:
951
+ group_id = group.get("GroupID")
952
+ try:
953
+ members = client.get_user_group_members(group_id)
954
+ for member in members:
955
+ if member.get("UserID") == user_id:
956
+ # User is in this group - add extra info from membership
957
+ group["_membership"] = member
958
+ user_groups.append(group)
959
+ break
960
+ except Exception:
961
+ # Skip groups we can't access
962
+ pass
963
+
964
+ # Step 3: Get access policies and find which ones apply to user's groups
965
+ console.print(f"[dim]Finding access policies...[/dim]")
966
+ policies = client.list_access_policies()
967
+ user_group_ids = {g.get("GroupID") for g in user_groups}
968
+ user_policies = []
969
+
970
+ for policy in policies:
971
+ policy_id = policy.get("AccessPolicyID")
972
+ try:
973
+ assignees = client.get_access_policy_assignees(policy_id)
974
+ for assignee in assignees:
975
+ if assignee.get("UserGroupID") in user_group_ids:
976
+ user_policies.append({
977
+ "policy": policy,
978
+ "assignee": assignee,
979
+ })
980
+ except Exception:
981
+ pass
982
+
983
+ # Build result
984
+ result = {
985
+ "user": user,
986
+ "groups": user_groups,
987
+ "access_policies": user_policies,
988
+ }
989
+
990
+ if output == "json":
991
+ console.print_json(json.dumps(result, default=str))
992
+ else:
993
+ # User info
994
+ first = user.get('FirstName') or ''
995
+ last = user.get('LastName') or ''
996
+ display_name = f"{first} {last}".strip() or user_name
997
+ console.print(Panel(
998
+ f"[bold]{display_name}[/bold]\n"
999
+ f"Username: {user_name}\n"
1000
+ f"Email: {user.get('EmailAddress') or '-'}\n"
1001
+ f"Active: {'Yes' if user.get('IsActive') else 'No'}\n"
1002
+ f"Last Login: {user.get('LastLoginDate') or 'Never'}\n"
1003
+ f"Auth Type: {user.get('LastLoginAuthenticationType') or '-'}",
1004
+ title=f"User {user_id}",
1005
+ ))
1006
+
1007
+ # Groups table
1008
+ if user_groups:
1009
+ groups_table = Table(title="User Groups")
1010
+ groups_table.add_column("ID", style="cyan")
1011
+ groups_table.add_column("Name", style="green")
1012
+ groups_table.add_column("Type", style="yellow")
1013
+ groups_table.add_column("Role Type", style="magenta")
1014
+ groups_table.add_column("API Reg IDs", style="blue")
1015
+ groups_table.add_column("Client ID", style="dim")
1016
+
1017
+ for g in user_groups:
1018
+ membership = g.get("_membership", {})
1019
+ groups_table.add_row(
1020
+ str(g.get("GroupID", "")),
1021
+ g.get("Name", ""),
1022
+ g.get("GroupType", "-"),
1023
+ role_types.get(g.get("RoleType"), str(g.get("RoleType", "-"))),
1024
+ g.get("ApplicationRegistrationIDs") or "-",
1025
+ membership.get("ClientID") or "-",
1026
+ )
1027
+
1028
+ console.print(groups_table)
1029
+ else:
1030
+ console.print("[yellow]User is not a member of any groups.[/yellow]")
1031
+
1032
+ # Access policies table
1033
+ if user_policies:
1034
+ console.print()
1035
+ policies_table = Table(title="Access Policies (via group membership)")
1036
+ policies_table.add_column("Policy", style="green")
1037
+ policies_table.add_column("Group", style="cyan")
1038
+ policies_table.add_column("Role", style="yellow")
1039
+ policies_table.add_column("Smart Rule", style="magenta")
1040
+
1041
+ for p in user_policies:
1042
+ policy = p["policy"]
1043
+ assignee = p["assignee"]
1044
+ policies_table.add_row(
1045
+ policy.get("Name", ""),
1046
+ assignee.get("UserGroupName", ""),
1047
+ assignee.get("RoleName", ""),
1048
+ assignee.get("SmartRuleTitle", "-"),
1049
+ )
1050
+
1051
+ console.print(policies_table)
1052
+ else:
1053
+ console.print("\n[yellow]No access policies found for user's groups.[/yellow]")
1054
+
1055
+ except httpx.HTTPStatusError as e:
1056
+ print_api_error(e, "quick user-entitlements")
1057
+ raise typer.Exit(1)
1058
+ except httpx.RequestError as e:
1059
+ print_api_error(e, "quick user-entitlements")
1060
+ raise typer.Exit(1)
1061
+ except typer.Exit:
1062
+ raise
1063
+ except Exception as e:
1064
+ print_api_error(e, "quick user-entitlements")
1065
+ raise typer.Exit(1)
1066
+
1067
+
1068
+ @app.command("functional")
1069
+ def quick_functional(
1070
+ output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
1071
+ ) -> None:
1072
+ """Create a functional account interactively.
1073
+
1074
+ Guides you through creating a functional account with smart prompts
1075
+ based on the platform type selected.
1076
+
1077
+ Examples:
1078
+ bt pws quick functional
1079
+ """
1080
+ import os
1081
+
1082
+ try:
1083
+ with get_client() as client:
1084
+ client.authenticate()
1085
+
1086
+ console.print("\n[bold cyan]Create Functional Account[/bold cyan]\n")
1087
+
1088
+ # Step 1: Choose platform
1089
+ platform_choice = prompt_choice(
1090
+ "Platform type",
1091
+ [
1092
+ ("linux", "Linux/Unix (SSH)"),
1093
+ ("windows", "Windows"),
1094
+ ("entra", "Microsoft Entra ID (Azure AD)"),
1095
+ ("aws", "Amazon Web Services"),
1096
+ ("mssql", "MS SQL Server"),
1097
+ ("mysql", "MySQL"),
1098
+ ("postgres", "PostgreSQL"),
1099
+ ("ad", "Active Directory"),
1100
+ ],
1101
+ default="linux",
1102
+ )
1103
+
1104
+ # Map choice to platform ID
1105
+ platform_map = {
1106
+ "linux": 2,
1107
+ "windows": 1,
1108
+ "entra": 84,
1109
+ "aws": 47,
1110
+ "mssql": 11,
1111
+ "mysql": 10,
1112
+ "postgres": 79,
1113
+ "ad": 25,
1114
+ }
1115
+ platform_id = platform_map[platform_choice]
1116
+
1117
+ # Step 2: Basic info
1118
+ account_name = typer.prompt("Account name/username")
1119
+ display_name = typer.prompt("Display name", default=account_name)
1120
+ description = typer.prompt("Description", default="")
1121
+
1122
+ # Step 3: Platform-specific options
1123
+ password = None
1124
+ private_key = None
1125
+ passphrase = None
1126
+ elevation = None
1127
+ application_id = None
1128
+ tenant_id = None
1129
+ object_id = None
1130
+ secret = None
1131
+ api_key = None
1132
+
1133
+ if platform_choice == "linux":
1134
+ # Linux: Ask about SSH key and elevation
1135
+ console.print("\n[dim]Linux authentication options:[/dim]")
1136
+ auth_method = prompt_choice(
1137
+ "Authentication method",
1138
+ [
1139
+ ("password", "Password"),
1140
+ ("sshkey", "SSH Private Key"),
1141
+ ],
1142
+ default="password",
1143
+ )
1144
+
1145
+ if auth_method == "password":
1146
+ password = typer.prompt("Password", hide_input=True)
1147
+ else:
1148
+ key_path = typer.prompt("SSH private key file path", default="~/.ssh/id_rsa")
1149
+ key_path = os.path.expanduser(key_path)
1150
+ if os.path.exists(key_path):
1151
+ with open(key_path, "r") as f:
1152
+ private_key = f.read()
1153
+ console.print(f"[green]Loaded key from {key_path}[/green]")
1154
+ if typer.confirm("Is the key encrypted (has passphrase)?", default=False):
1155
+ passphrase = typer.prompt("Key passphrase", hide_input=True)
1156
+ else:
1157
+ console.print(f"[red]Key file not found: {key_path}[/red]")
1158
+ raise typer.Exit(1)
1159
+
1160
+ # Elevation command
1161
+ if typer.confirm("Configure elevation command (sudo)?", default=True):
1162
+ elevation = typer.prompt("Elevation command", default="sudo")
1163
+
1164
+ elif platform_choice == "windows":
1165
+ # Windows: Just password
1166
+ console.print("\n[dim]Windows authentication:[/dim]")
1167
+ password = typer.prompt("Password", hide_input=True)
1168
+
1169
+ elif platform_choice == "entra":
1170
+ # Entra ID: App registration details
1171
+ console.print("\n[dim]Entra ID (Azure AD) app registration:[/dim]")
1172
+ application_id = typer.prompt("Application (Client) ID")
1173
+ tenant_id = typer.prompt("Tenant ID")
1174
+ object_id = typer.prompt("Object ID")
1175
+ secret = typer.prompt("Client Secret", hide_input=True)
1176
+
1177
+ elif platform_choice == "aws":
1178
+ # AWS: Access keys
1179
+ console.print("\n[dim]AWS IAM credentials:[/dim]")
1180
+ api_key = typer.prompt("Access Key ID")
1181
+ secret = typer.prompt("Secret Access Key", hide_input=True)
1182
+
1183
+ elif platform_choice in ["mssql", "mysql", "postgres"]:
1184
+ # Databases: Just password
1185
+ console.print(f"\n[dim]{platform_choice.upper()} authentication:[/dim]")
1186
+ password = typer.prompt("Password", hide_input=True)
1187
+
1188
+ elif platform_choice == "ad":
1189
+ # Active Directory: Password
1190
+ console.print("\n[dim]Active Directory authentication:[/dim]")
1191
+ password = typer.prompt("Password", hide_input=True)
1192
+
1193
+ # Confirm before creating
1194
+ console.print("\n[bold]Summary:[/bold]")
1195
+ console.print(f" Platform: {platform_choice} (ID: {platform_id})")
1196
+ console.print(f" Account Name: {account_name}")
1197
+ console.print(f" Display Name: {display_name}")
1198
+ if description:
1199
+ console.print(f" Description: {description}")
1200
+ if elevation:
1201
+ console.print(f" Elevation: {elevation}")
1202
+ if application_id:
1203
+ console.print(f" App ID: {application_id}")
1204
+ if api_key:
1205
+ console.print(f" Access Key: {api_key[:8]}...")
1206
+
1207
+ if not typer.confirm("\nCreate this functional account?", default=True):
1208
+ console.print("[yellow]Cancelled.[/yellow]")
1209
+ raise typer.Exit(0)
1210
+
1211
+ # Create the functional account
1212
+ console.print("\n[dim]Creating functional account...[/dim]")
1213
+ account = client.create_functional_account(
1214
+ account_name=account_name,
1215
+ platform_id=platform_id,
1216
+ display_name=display_name if display_name != account_name else None,
1217
+ description=description if description else None,
1218
+ elevation_command=elevation,
1219
+ password=password,
1220
+ private_key=private_key,
1221
+ passphrase=passphrase,
1222
+ application_id=application_id,
1223
+ tenant_id=tenant_id,
1224
+ object_id=object_id,
1225
+ secret=secret,
1226
+ api_key=api_key,
1227
+ )
1228
+
1229
+ if output == "json":
1230
+ console.print_json(json.dumps(account, default=str))
1231
+ else:
1232
+ console.print(Panel(
1233
+ f"[green]Functional account created![/green]\n\n"
1234
+ f"ID: [cyan]{account.get('FunctionalAccountID')}[/cyan]\n"
1235
+ f"Name: [bold]{account.get('DisplayName', account.get('AccountName'))}[/bold]\n"
1236
+ f"Platform: {account.get('PlatformID')}\n"
1237
+ + (f"Elevation: {account.get('ElevationCommand')}\n" if account.get('ElevationCommand') else "")
1238
+ + f"\n[dim]Use with: bt pws quick onboard -f {account.get('FunctionalAccountID')}[/dim]",
1239
+ title="Quick Functional Account",
1240
+ ))
1241
+
1242
+ except httpx.HTTPStatusError as e:
1243
+ print_api_error(e, "quick functional")
1244
+ raise typer.Exit(1)
1245
+ except httpx.RequestError as e:
1246
+ print_api_error(e, "quick functional")
1247
+ raise typer.Exit(1)
1248
+ except typer.Exit:
1249
+ raise
1250
+ except Exception as e:
1251
+ print_api_error(e, "quick functional")
1252
+ raise typer.Exit(1)
1253
+
1254
+
1255
+ @app.command("app-setup")
1256
+ def quick_app_setup(
1257
+ user_search: Optional[str] = typer.Option(None, "--user", "-u", help="User to search for (partial match)"),
1258
+ safe_name: Optional[str] = typer.Option(None, "--safe", "-s", help="Safe name to create"),
1259
+ folder_path: Optional[str] = typer.Option(None, "--folder", "-f", help="Folder path to create (e.g., 'Database/Production')"),
1260
+ output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
1261
+ ) -> None:
1262
+ """Set up a safe for an application user with full permissions.
1263
+
1264
+ Interactive workflow that:
1265
+ 1. Lists users and lets you select one (API users highlighted)
1266
+ 2. Shows access policies for reference
1267
+ 3. Creates a safe
1268
+ 4. Assigns the user full permissions (Read, Create, Edit, Delete, Share, Manage)
1269
+ 5. Optionally creates a folder path within the safe
1270
+
1271
+ Examples:
1272
+ bt pws quick app-setup # Fully interactive
1273
+ bt pws quick app-setup -u nhi-provision # Pre-select user
1274
+ bt pws quick app-setup -u nhi -s "MyApp Secrets" # Pre-fill user and safe
1275
+ bt pws quick app-setup -u nhi -s "MyApp" -f "Database/Prod"
1276
+ """
1277
+ try:
1278
+ with get_client() as client:
1279
+ client.authenticate()
1280
+
1281
+ console.print("\n[bold cyan]Application Safe Setup[/bold cyan]\n")
1282
+
1283
+ # =================================================================
1284
+ # Step 1: Select user
1285
+ # =================================================================
1286
+ console.print("[bold]Step 1: Select User[/bold]")
1287
+
1288
+ all_users = client.list_users()
1289
+
1290
+ # If user search provided, filter
1291
+ if user_search:
1292
+ search_lower = user_search.lower()
1293
+ filtered_users = [
1294
+ u for u in all_users
1295
+ if search_lower in (u.get("UserName", "") or "").lower()
1296
+ or search_lower in (u.get("EmailAddress", "") or "").lower()
1297
+ or search_lower in (u.get("FirstName", "") or "").lower()
1298
+ or search_lower in (u.get("LastName", "") or "").lower()
1299
+ ]
1300
+ if not filtered_users:
1301
+ print_error(f"No user found matching '{user_search}'")
1302
+ raise typer.Exit(1)
1303
+ else:
1304
+ filtered_users = all_users
1305
+
1306
+ # Show users table
1307
+ users_table = Table(title="Available Users")
1308
+ users_table.add_column("#", style="dim", width=4)
1309
+ users_table.add_column("ID", style="cyan", width=6)
1310
+ users_table.add_column("Username", style="green")
1311
+ users_table.add_column("Name", style="yellow")
1312
+ users_table.add_column("Type", style="magenta", width=8)
1313
+
1314
+ # Show max 20 users
1315
+ display_users = filtered_users[:20]
1316
+ for i, user in enumerate(display_users, 1):
1317
+ first = user.get("FirstName") or ""
1318
+ last = user.get("LastName") or ""
1319
+ display_name = f"{first} {last}".strip() or "-"
1320
+ is_api = "API" if user.get("ClientID") else "Human"
1321
+ users_table.add_row(
1322
+ str(i),
1323
+ str(user.get("UserID")),
1324
+ user.get("UserName", ""),
1325
+ display_name,
1326
+ is_api,
1327
+ )
1328
+
1329
+ console.print(users_table)
1330
+
1331
+ if len(filtered_users) > 20:
1332
+ console.print(f"[dim]... and {len(filtered_users) - 20} more users (use --user to filter)[/dim]")
1333
+
1334
+ # Prompt for selection
1335
+ selection = typer.prompt("Select user (enter # or User ID)", type=str)
1336
+ try:
1337
+ sel_int = int(selection)
1338
+ if 1 <= sel_int <= len(display_users):
1339
+ selected_user = display_users[sel_int - 1]
1340
+ else:
1341
+ # Treat as User ID
1342
+ selected_user = next((u for u in all_users if u.get("UserID") == sel_int), None)
1343
+ except ValueError:
1344
+ # Try matching by username
1345
+ selected_user = next(
1346
+ (u for u in all_users if u.get("UserName", "").lower() == selection.lower()),
1347
+ None
1348
+ )
1349
+
1350
+ if not selected_user:
1351
+ print_error(f"Invalid selection: {selection}")
1352
+ raise typer.Exit(1)
1353
+
1354
+ user_id = selected_user.get("UserID")
1355
+ user_name = selected_user.get("UserName")
1356
+ console.print(f"[green]Selected user:[/green] {user_name} (ID: {user_id})")
1357
+
1358
+ # =================================================================
1359
+ # Step 2: Show access policies (informational)
1360
+ # =================================================================
1361
+ console.print("\n[bold]Step 2: Access Policies (Reference)[/bold]")
1362
+ console.print("[dim]These are the available access policies in the system:[/dim]")
1363
+
1364
+ policies = client.get("/AccessPolicies")
1365
+ if policies:
1366
+ policies_table = Table(title="Access Policies")
1367
+ policies_table.add_column("ID", style="cyan", width=8)
1368
+ policies_table.add_column("Name", style="green")
1369
+ policies_table.add_column("Description", style="yellow")
1370
+
1371
+ for policy in policies[:10]:
1372
+ policies_table.add_row(
1373
+ str(policy.get("AccessPolicyID", "")),
1374
+ policy.get("Name", ""),
1375
+ (policy.get("Description") or "-")[:50],
1376
+ )
1377
+ console.print(policies_table)
1378
+ console.print("[dim]Note: Access policies are assigned via user groups, not directly to safes.[/dim]")
1379
+ else:
1380
+ console.print("[yellow]No access policies found.[/yellow]")
1381
+
1382
+ # =================================================================
1383
+ # Step 3: Create safe
1384
+ # =================================================================
1385
+ console.print("\n[bold]Step 3: Create Safe[/bold]")
1386
+
1387
+ if not safe_name:
1388
+ safe_name = typer.prompt("Safe name")
1389
+
1390
+ safe_description = typer.prompt("Safe description (optional)", default="")
1391
+
1392
+ # Confirmation before creating
1393
+ console.print("\n[bold]Review:[/bold]")
1394
+ console.print(f" User: [cyan]{user_name}[/cyan] (ID: {user_id})")
1395
+ console.print(f" Safe: [cyan]{safe_name}[/cyan]")
1396
+ if safe_description:
1397
+ console.print(f" Description: [dim]{safe_description}[/dim]")
1398
+ console.print(f" Permissions: [green]Full (Read, Create, Edit, Delete, Share, Manage)[/green]")
1399
+
1400
+ if not typer.confirm("\nProceed with setup?", default=True):
1401
+ console.print("[yellow]Cancelled.[/yellow]")
1402
+ raise typer.Exit(0)
1403
+
1404
+ console.print(f"\n[dim]Creating safe '{safe_name}'...[/dim]")
1405
+ safe = client.create_safe(name=safe_name, description=safe_description or None)
1406
+ safe_id = safe.get("Id")
1407
+ console.print(f"[green]Created safe:[/green] {safe_name} (ID: {safe_id})")
1408
+
1409
+ # =================================================================
1410
+ # Step 4: Assign permissions
1411
+ # =================================================================
1412
+ console.print("\n[bold]Step 4: Assign Permissions[/bold]")
1413
+
1414
+ # Full permissions
1415
+ full_permissions = ["Read", "Create", "Edit", "Delete", "Share", "Manage"]
1416
+
1417
+ console.print(f"[dim]Granting full permissions to user {user_name}...[/dim]")
1418
+ try:
1419
+ client.grant_safe_permission_to_user(
1420
+ safe_id=safe_id,
1421
+ user_id=user_id,
1422
+ permission_flags=full_permissions,
1423
+ )
1424
+ console.print(f"[green]Granted permissions:[/green] {', '.join(full_permissions)}")
1425
+ except httpx.HTTPStatusError as perm_err:
1426
+ # Permission assignment failed - clean up the safe
1427
+ console.print(f"[red]Failed to assign permissions.[/red]")
1428
+ if "SecretsSafe" in str(perm_err.response.text) or "role" in str(perm_err.response.text).lower():
1429
+ console.print(f"[yellow]The user '{user_name}' may not have the SecretsSafe/WorkforcePasswords role.[/yellow]")
1430
+ console.print("[dim]This role must be assigned via user group membership in the BeyondInsight console.[/dim]")
1431
+ else:
1432
+ console.print(f"[dim]Error: {perm_err.response.text}[/dim]")
1433
+
1434
+ # Offer to keep or delete the safe
1435
+ if typer.confirm(f"\nDelete the created safe '{safe_name}'?", default=True):
1436
+ try:
1437
+ client.delete(f"/Secrets-Safe/Safes/{safe_id}")
1438
+ console.print(f"[yellow]Deleted safe: {safe_id}[/yellow]")
1439
+ except Exception:
1440
+ console.print(f"[red]Could not delete safe. Delete manually: bt pws secrets safes delete {safe_id}[/red]")
1441
+ else:
1442
+ console.print(f"[dim]Safe kept. You can assign permissions manually or delete with:[/dim]")
1443
+ console.print(f"[dim] bt pws secrets safes delete {safe_id}[/dim]")
1444
+ raise typer.Exit(1)
1445
+
1446
+ # =================================================================
1447
+ # Step 5: Create folder (optional)
1448
+ # =================================================================
1449
+ console.print("\n[bold]Step 5: Create Folder (Optional)[/bold]")
1450
+
1451
+ created_folders = []
1452
+ if folder_path is None:
1453
+ if typer.confirm("Create a folder inside the safe?", default=False):
1454
+ folder_path = typer.prompt("Folder path (e.g., 'Database/Production')")
1455
+
1456
+ if folder_path:
1457
+ # Parse path and create nested folders
1458
+ path_parts = [p.strip() for p in folder_path.split("/") if p.strip()]
1459
+ parent_id = safe_id
1460
+
1461
+ for part in path_parts:
1462
+ console.print(f"[dim]Creating folder '{part}'...[/dim]")
1463
+ folder = client.create_folder(
1464
+ name=part,
1465
+ parent_id=parent_id,
1466
+ description=f"Folder in {safe_name}",
1467
+ )
1468
+ folder_id = folder.get("Id")
1469
+ created_folders.append({"name": part, "id": folder_id})
1470
+ console.print(f"[green]Created folder:[/green] {part} (ID: {folder_id})")
1471
+ parent_id = folder_id
1472
+
1473
+ # =================================================================
1474
+ # Summary
1475
+ # =================================================================
1476
+ console.print()
1477
+
1478
+ if output == "json":
1479
+ result = {
1480
+ "user": selected_user,
1481
+ "safe": safe,
1482
+ "permissions": full_permissions,
1483
+ "folders": created_folders,
1484
+ }
1485
+ console.print_json(json.dumps(result, default=str))
1486
+ else:
1487
+ summary_lines = [
1488
+ f"[green]Application safe setup complete![/green]\n",
1489
+ f"[bold]User:[/bold] {user_name} (ID: {user_id})",
1490
+ f"[bold]Safe:[/bold] {safe_name} (ID: {safe_id})",
1491
+ f"[bold]Permissions:[/bold] {', '.join(full_permissions)}",
1492
+ ]
1493
+ if created_folders:
1494
+ folder_display = " / ".join(f["name"] for f in created_folders)
1495
+ summary_lines.append(f"[bold]Folders:[/bold] {folder_display}")
1496
+
1497
+ summary_lines.append(f"\n[dim]Add secrets: bt pws secrets secrets create --folder {created_folders[-1]['id'] if created_folders else safe_id}[/dim]")
1498
+
1499
+ console.print(Panel(
1500
+ "\n".join(summary_lines),
1501
+ title="App Setup Complete",
1502
+ ))
1503
+
1504
+ except httpx.HTTPStatusError as e:
1505
+ print_api_error(e, "quick app-setup")
1506
+ raise typer.Exit(1)
1507
+ except httpx.RequestError as e:
1508
+ print_api_error(e, "quick app-setup")
1509
+ raise typer.Exit(1)
1510
+ except typer.Exit:
1511
+ raise
1512
+ except Exception as e:
1513
+ print_api_error(e, "quick app-setup")
1514
+ raise typer.Exit(1)
1515
+
1516
+
1517
+ @app.command("get-secret")
1518
+ def quick_get_secret(
1519
+ path: Optional[str] = typer.Argument(None, help="Secret path (e.g., 'Safe/Folder/SecretName')"),
1520
+ show_password: bool = typer.Option(True, "--show-password/--hide-password", help="Show or hide password"),
1521
+ raw: bool = typer.Option(False, "--raw", help="Output only the password (for scripts)"),
1522
+ output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
1523
+ ) -> None:
1524
+ """Get a secret by its path.
1525
+
1526
+ Path format: Safe/Folder/Subfolder/SecretName
1527
+
1528
+ Examples:
1529
+ bt pws quick get-secret "Example1/Folder1/ID-Pass"
1530
+ bt pws quick get-secret "PAM Demo Credentials/Service Accounts/db-admin"
1531
+ bt pws quick get-secret "MySafe/MySecret" --hide-password
1532
+ PASSWORD=$(bt pws quick get-secret "MySafe/Secret" --raw)
1533
+ """
1534
+ try:
1535
+ with get_client() as client:
1536
+ client.authenticate()
1537
+
1538
+ # Interactive mode if no path provided
1539
+ if not path:
1540
+ console.print("\n[bold cyan]Get Secret by Path[/bold cyan]\n")
1541
+
1542
+ # Show available safes
1543
+ safes = client.list_safes()
1544
+ console.print("[bold]Available Safes:[/bold]")
1545
+ for s in safes:
1546
+ console.print(f" - {s.get('Name')}")
1547
+ console.print()
1548
+
1549
+ path = typer.prompt("Secret path (Safe/Folder/SecretName)")
1550
+
1551
+ # Parse path
1552
+ path_parts = [p.strip() for p in path.split("/") if p.strip()]
1553
+ if len(path_parts) < 2:
1554
+ print_error("Path must have at least Safe/SecretName (e.g., 'MySafe/MySecret')")
1555
+ raise typer.Exit(1)
1556
+
1557
+ secret_name = path_parts[-1]
1558
+ folder_path = "/".join(path_parts[:-1])
1559
+
1560
+ if not raw:
1561
+ console.print(f"[dim]Searching for secret '{secret_name}' in '{folder_path}'...[/dim]")
1562
+
1563
+ # Get all secrets and find by path
1564
+ secrets = client.list_secrets()
1565
+ matched_secret = None
1566
+
1567
+ for secret in secrets:
1568
+ secret_folder_path = secret.get("FolderPath", "")
1569
+ secret_title = secret.get("Title", "")
1570
+
1571
+ # Match by folder path and title
1572
+ if secret_folder_path.lower() == folder_path.lower() and secret_title.lower() == secret_name.lower():
1573
+ matched_secret = secret
1574
+ break
1575
+
1576
+ if not matched_secret:
1577
+ # Try partial match
1578
+ for secret in secrets:
1579
+ secret_folder_path = secret.get("FolderPath", "")
1580
+ secret_title = secret.get("Title", "")
1581
+ full_path = f"{secret_folder_path}/{secret_title}".lower()
1582
+
1583
+ if path.lower() in full_path or full_path.endswith(path.lower()):
1584
+ matched_secret = secret
1585
+ if not raw:
1586
+ console.print(f"[yellow]Partial match found: {secret_folder_path}/{secret_title}[/yellow]")
1587
+ break
1588
+
1589
+ if not matched_secret:
1590
+ print_error(f"No secret found at path '{path}'")
1591
+
1592
+ # Show similar paths
1593
+ similar = []
1594
+ for secret in secrets:
1595
+ fp = secret.get("FolderPath", "")
1596
+ title = secret.get("Title", "")
1597
+ if path_parts[0].lower() in fp.lower() or secret_name.lower() in title.lower():
1598
+ similar.append(f"{fp}/{title}")
1599
+
1600
+ if similar and not raw:
1601
+ console.print("\n[dim]Similar secrets:[/dim]")
1602
+ for s in similar[:5]:
1603
+ console.print(f" - {s}")
1604
+
1605
+ raise typer.Exit(1)
1606
+
1607
+ # Get full secret details (includes password)
1608
+ secret_id = matched_secret.get("Id")
1609
+ secret_details = client.get_secret(secret_id)
1610
+
1611
+ # Output
1612
+ if raw:
1613
+ print(secret_details.get("Password", ""), end="")
1614
+ elif output == "json":
1615
+ if not show_password:
1616
+ secret_details["Password"] = "********"
1617
+ console.print_json(json.dumps(secret_details, default=str))
1618
+ else:
1619
+ full_path = f"{secret_details.get('FolderPath', '')}/{secret_details.get('Title', '')}"
1620
+ password = secret_details.get("Password", "")
1621
+
1622
+ console.print(Panel(
1623
+ f"[bold]Path:[/bold] {full_path}\n"
1624
+ f"[bold]Title:[/bold] {secret_details.get('Title', '-')}\n"
1625
+ f"[bold]Type:[/bold] {secret_details.get('SecretType', '-')}\n"
1626
+ f"[bold]Username:[/bold] {secret_details.get('Username') or '-'}\n"
1627
+ f"[bold]Password:[/bold] {'[green]' + password + '[/green]' if show_password else '[dim]********[/dim]'}\n"
1628
+ + (f"[bold]Description:[/bold] {secret_details.get('Description')}\n" if secret_details.get('Description') else "")
1629
+ + (f"[bold]Notes:[/bold] {secret_details.get('Notes')}\n" if secret_details.get('Notes') else "")
1630
+ + (f"[bold]URLs:[/bold] {', '.join(u.get('Url', '') for u in secret_details.get('Urls', []))}\n" if secret_details.get('Urls') else "")
1631
+ + f"\n[dim]Owner: {secret_details.get('Owner', '-')}[/dim]\n"
1632
+ f"[dim]Modified: {secret_details.get('ModifiedOn', '-')} by {secret_details.get('ModifiedBy', '-')}[/dim]",
1633
+ title=f"Secret: {secret_details.get('Title')}",
1634
+ ))
1635
+
1636
+ except httpx.HTTPStatusError as e:
1637
+ print_api_error(e, "quick get-secret")
1638
+ raise typer.Exit(1)
1639
+ except httpx.RequestError as e:
1640
+ print_api_error(e, "quick get-secret")
1641
+ raise typer.Exit(1)
1642
+ except typer.Exit:
1643
+ raise
1644
+ except Exception as e:
1645
+ print_api_error(e, "quick get-secret")
1646
+ raise typer.Exit(1)