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
bt_cli/cli.py ADDED
@@ -0,0 +1,830 @@
1
+ """Main CLI entry point for BeyondTrust Unified Admin."""
2
+
3
+ import sys
4
+ from typing import Optional
5
+
6
+ import typer
7
+
8
+ # Check rich version early to give helpful error
9
+ try:
10
+ from rich.console import Console
11
+ # Get rich version from metadata (rich>=14 removed __version__)
12
+ try:
13
+ from importlib.metadata import version as get_version
14
+ rich_version = get_version("rich")
15
+ except ImportError:
16
+ import rich
17
+ rich_version = getattr(rich, "__version__", "13.7.0") # Assume OK if can't check
18
+
19
+ # rich 13.7+ required for typer compatibility
20
+ parts = rich_version.split(".")
21
+ major, minor = int(parts[0]), int(parts[1]) if len(parts) > 1 else 0
22
+ if major < 13 or (major == 13 and minor < 7):
23
+ print(
24
+ f"Error: rich {rich_version} is too old. bt-cli requires rich>=13.7.0\n"
25
+ f"Fix: pip install --upgrade rich>=13.7.0",
26
+ file=sys.stderr,
27
+ )
28
+ sys.exit(1)
29
+ except ImportError as e:
30
+ print(f"Error: Missing required dependency: {e}\nFix: pip install bt-cli", file=sys.stderr)
31
+ sys.exit(1)
32
+
33
+ from . import __version__
34
+
35
+ # Create main app
36
+ app = typer.Typer(
37
+ name="bt",
38
+ help="BeyondTrust Platform CLI - Manage Password Safe, Entitle, PRA, and EPM",
39
+ no_args_is_help=True,
40
+ )
41
+
42
+ console = Console()
43
+
44
+ # Global profile option (shared across all commands)
45
+ _active_profile: Optional[str] = None
46
+
47
+
48
+ def get_active_profile() -> Optional[str]:
49
+ """Get the currently active profile."""
50
+ return _active_profile
51
+
52
+
53
+ # Lazy load product apps to avoid import errors during development
54
+ def _get_pws_app() -> typer.Typer:
55
+ """Lazy load Password Safe commands."""
56
+ from .pws.commands import app as pws_app
57
+ return pws_app
58
+
59
+
60
+ def _get_entitle_app() -> typer.Typer:
61
+ """Lazy load Entitle commands."""
62
+ from .entitle.commands import app as entitle_app
63
+ return entitle_app
64
+
65
+
66
+ def _get_pra_app() -> typer.Typer:
67
+ """Lazy load PRA commands."""
68
+ from .pra.commands import app as pra_app
69
+ return pra_app
70
+
71
+
72
+ def _get_epmw_app() -> typer.Typer:
73
+ """Lazy load EPM Windows commands."""
74
+ from .epmw.commands import app as epmw_app
75
+ return epmw_app
76
+
77
+
78
+ def _get_configure_app() -> typer.Typer:
79
+ """Lazy load configure commands."""
80
+ from .commands.configure import app as configure_app
81
+ return configure_app
82
+
83
+
84
+ def _get_learn_app() -> typer.Typer:
85
+ """Lazy load learn commands."""
86
+ from .commands.learn import app as learn_app
87
+ return learn_app
88
+
89
+
90
+ def _get_quick_app() -> typer.Typer:
91
+ """Lazy load global quick commands."""
92
+ from .commands.quick import app as quick_app
93
+ return quick_app
94
+
95
+
96
+ # Register product CLIs
97
+ # These will be loaded when the subcommand is invoked
98
+ try:
99
+ app.add_typer(_get_pws_app(), name="pws", help="Password Safe commands")
100
+ except Exception:
101
+ pass # PWS module not ready yet
102
+
103
+ try:
104
+ app.add_typer(_get_entitle_app(), name="entitle", help="Entitle commands")
105
+ except Exception:
106
+ pass # Entitle module not ready yet
107
+
108
+ try:
109
+ app.add_typer(_get_pra_app(), name="pra", help="Privileged Remote Access commands")
110
+ except Exception:
111
+ pass # PRA module not ready yet
112
+
113
+ try:
114
+ app.add_typer(_get_epmw_app(), name="epmw", help="EPM Windows commands")
115
+ except Exception:
116
+ pass # EPMW module not ready yet
117
+
118
+ try:
119
+ app.add_typer(_get_configure_app(), name="configure", help="Configure bt-cli settings")
120
+ except Exception:
121
+ pass # Configure module not ready yet
122
+
123
+ try:
124
+ app.add_typer(_get_learn_app(), name="learn", help="Learning log for workflows and insights")
125
+ except Exception:
126
+ pass # Learn module not ready yet
127
+
128
+ try:
129
+ app.add_typer(_get_quick_app(), name="quick", help="Cross-product quick commands (Total PASM)")
130
+ except Exception:
131
+ pass # Quick module not ready yet
132
+
133
+
134
+ @app.callback()
135
+ def main_callback(
136
+ profile: Optional[str] = typer.Option(
137
+ None,
138
+ "--profile", "-P",
139
+ help="Use a specific configuration profile",
140
+ envvar="BT_PROFILE",
141
+ ),
142
+ show_rest: bool = typer.Option(
143
+ False,
144
+ "--show-rest",
145
+ help="Show REST API calls (method, URL, headers, body)",
146
+ envvar="BT_SHOW_REST",
147
+ ),
148
+ ) -> None:
149
+ """BeyondTrust Platform CLI.
150
+
151
+ A comprehensive CLI tool for managing BeyondTrust products:
152
+
153
+ - Password Safe: Credential management, secrets, systems
154
+ - Entitle: Just-in-time access management
155
+ - PRA: Privileged Remote Access (jumpoints, vault, jump items)
156
+ - EPMW: EPM Windows endpoint privilege management
157
+
158
+ Configuration (in order of precedence):
159
+
160
+ 1. Command-line flags
161
+ 2. Environment variables (BT_PWS_*, BT_ENTITLE_*, etc.)
162
+ 3. Config file (~/.bt-cli/config.yaml)
163
+
164
+ Setup:
165
+
166
+ bt configure # Interactive setup wizard
167
+ bt configure --product pws --api-url https://...
168
+
169
+ Examples:
170
+
171
+ # Use default profile
172
+ bt pws auth test
173
+ bt pws systems list
174
+
175
+ # Use a specific profile
176
+ bt --profile production pws systems list
177
+ bt -P dev entitle integrations list
178
+
179
+ # Show REST API calls
180
+ bt --show-rest pws systems list
181
+ """
182
+ global _active_profile
183
+ _active_profile = profile
184
+
185
+ # Enable REST debugging if requested
186
+ if show_rest:
187
+ from .core.rest_debug import set_show_rest
188
+ set_show_rest(True)
189
+
190
+
191
+ @app.command("version")
192
+ def version() -> None:
193
+ """Show version information."""
194
+ console.print(f"bt-cli version {__version__}")
195
+
196
+
197
+ @app.command("find")
198
+ def find_command(
199
+ term: str = typer.Argument(..., help="Search term to find in command names/help"),
200
+ ) -> None:
201
+ """Search for commands by name or description.
202
+
203
+ Searches all bt commands and shows matches.
204
+
205
+ Examples:
206
+ bt find functional # Find commands related to functional accounts
207
+ bt find secret # Find secret-related commands
208
+ bt find jump # Find jump-related commands
209
+ """
210
+ from rich.table import Table
211
+
212
+ # Build command registry
213
+ commands = _get_all_commands()
214
+
215
+ # Search
216
+ term_lower = term.lower()
217
+ matches = []
218
+ for cmd_path, cmd_help in commands:
219
+ if term_lower in cmd_path.lower() or term_lower in cmd_help.lower():
220
+ matches.append((cmd_path, cmd_help))
221
+
222
+ if not matches:
223
+ console.print(f"[yellow]No commands found matching '{term}'[/yellow]")
224
+ console.print("\nTry: bt tree # Show all commands")
225
+ return
226
+
227
+ table = Table(title=f"Commands matching '{term}'")
228
+ table.add_column("Command", style="cyan")
229
+ table.add_column("Description", style="dim")
230
+
231
+ for cmd_path, cmd_help in sorted(matches):
232
+ # Truncate help text
233
+ short_help = cmd_help[:60] + "..." if len(cmd_help) > 60 else cmd_help
234
+ table.add_row(cmd_path, short_help)
235
+
236
+ console.print(table)
237
+ console.print(f"\n[dim]{len(matches)} command(s) found[/dim]")
238
+
239
+
240
+ @app.command("tree")
241
+ def tree_command(
242
+ product: Optional[str] = typer.Argument(None, help="Filter by product: pws, pra, entitle, epmw"),
243
+ ) -> None:
244
+ """Show command hierarchy tree.
245
+
246
+ Displays the full command tree for navigation.
247
+
248
+ Examples:
249
+ bt tree # Show all commands
250
+ bt tree pws # Show only PWS commands
251
+ bt tree pra # Show only PRA commands
252
+ """
253
+ from rich.tree import Tree
254
+
255
+ if product:
256
+ product = product.lower()
257
+ if product not in ["pws", "pra", "entitle", "epmw", "quick", "configure"]:
258
+ console.print(f"[red]Unknown product: {product}[/red]")
259
+ console.print("Available: pws, pra, entitle, epmw, quick, configure")
260
+ raise typer.Exit(1)
261
+
262
+ tree = Tree("[bold cyan]bt[/bold cyan]")
263
+
264
+ # Top-level commands
265
+ if not product:
266
+ tree.add("[green]version[/green] - Show version")
267
+ tree.add("[green]whoami[/green] - Test all connections")
268
+ tree.add("[green]find[/green] <term> - Search commands")
269
+ tree.add("[green]tree[/green] - Show this tree")
270
+ tree.add("[green]skills[/green] - Install Claude Code skills")
271
+ tree.add("[green]configure[/green] - Configure products")
272
+
273
+ # PWS
274
+ if not product or product == "pws":
275
+ pws = tree.add("[bold yellow]pws[/bold yellow] - Password Safe")
276
+ pws.add("[green]auth[/green] test")
277
+ pws.add("[green]search[/green] <query> - Search across all entities")
278
+
279
+ systems = pws.add("[green]systems[/green] list|get|create|update|delete")
280
+ accounts = pws.add("[green]accounts[/green] list|get|create|update|delete|rotate")
281
+ pws.add("[green]functional[/green] list|get|create|update|delete")
282
+ pws.add("[green]assets[/green] list|search|get|create|update|delete")
283
+ pws.add("[green]credentials[/green] checkout|checkin")
284
+ pws.add("[green]platforms[/green] list|get")
285
+ pws.add("[green]workgroups[/green] list|get")
286
+
287
+ secrets = pws.add("[green]secrets[/green]")
288
+ secrets.add("safes list|get|create|delete")
289
+ secrets.add("folders list|get|create|delete")
290
+ secrets.add("secrets list|get|create|create-text|create-file|delete")
291
+
292
+ pws.add("[green]quick[/green] checkout|onboard|offboard")
293
+
294
+ # PRA
295
+ if not product or product == "pra":
296
+ pra = tree.add("[bold yellow]pra[/bold yellow] - Privileged Remote Access")
297
+ pra.add("[green]auth[/green] test")
298
+ pra.add("[green]jumpoint[/green] list|get")
299
+ pra.add("[green]jump-groups[/green] list|get|create")
300
+
301
+ ji = pra.add("[green]jump-items[/green]")
302
+ ji.add("shell list|get|create|update|delete")
303
+ ji.add("rdp list|get|create|delete")
304
+
305
+ vault = pra.add("[green]vault[/green]")
306
+ vault.add("accounts list|get|create|delete|checkout|checkin|get-user-data|get-public-key")
307
+ vault.add("groups list|get")
308
+
309
+ pra.add("[green]quick[/green] shell-jump|rdp-jump")
310
+
311
+ # Entitle
312
+ if not product or product == "entitle":
313
+ ent = tree.add("[bold yellow]entitle[/bold yellow] - Just-in-Time Access")
314
+ ent.add("[green]auth[/green] test")
315
+ ent.add("[green]integrations[/green] list|get")
316
+ ent.add("[green]resources[/green] list|get|create-virtual|delete")
317
+ ent.add("[green]roles[/green] list|get")
318
+ ent.add("[green]bundles[/green] list|get|create|delete")
319
+ ent.add("[green]workflows[/green] list|get")
320
+ ent.add("[green]users[/green] list|get")
321
+ ent.add("[green]permissions[/green] list|revoke")
322
+ ent.add("[green]policies[/green] list|get")
323
+ ent.add("[green]accounts[/green] list")
324
+ ent.add("[green]agents[/green] list|get|status")
325
+
326
+ # EPMW
327
+ if not product or product == "epmw":
328
+ epmw = tree.add("[bold yellow]epmw[/bold yellow] - EPM Windows")
329
+ epmw.add("[green]auth[/green] test")
330
+ epmw.add("[green]computers[/green] list|get|archive")
331
+ epmw.add("[green]groups[/green] list|get")
332
+ epmw.add("[green]policies[/green] list|get")
333
+ epmw.add("[green]requests[/green] list|get|approve|deny")
334
+ epmw.add("[green]quick[/green] approve-all")
335
+
336
+ # Quick
337
+ if not product or product == "quick":
338
+ quick = tree.add("[bold yellow]quick[/bold yellow] - Cross-product workflows")
339
+ quick.add("[green]pasm-onboard[/green] - Onboard to PWS + PRA")
340
+ quick.add("[green]pasm-offboard[/green] - Remove from PWS + PRA")
341
+ quick.add("[green]pasm-search[/green] - Search across PWS + PRA")
342
+
343
+ console.print(tree)
344
+
345
+
346
+ def _get_all_commands() -> list[tuple[str, str]]:
347
+ """Build list of all commands with their help text."""
348
+ commands = [
349
+ ("bt version", "Show version information"),
350
+ ("bt whoami", "Test all configured products and show connection info"),
351
+ ("bt find <term>", "Search for commands by name or description"),
352
+ ("bt tree", "Show command hierarchy tree"),
353
+ ("bt skills", "Install Claude Code skills"),
354
+ ("bt configure", "Configure bt-cli settings"),
355
+ # PWS
356
+ ("bt pws auth test", "Test Password Safe connection"),
357
+ ("bt pws search <query>", "Search across all PWS entities"),
358
+ ("bt pws systems list", "List managed systems"),
359
+ ("bt pws systems get", "Get a managed system"),
360
+ ("bt pws systems create", "Create a managed system"),
361
+ ("bt pws systems update", "Update a managed system"),
362
+ ("bt pws systems delete", "Delete a managed system"),
363
+ ("bt pws accounts list", "List managed accounts"),
364
+ ("bt pws accounts get", "Get a managed account"),
365
+ ("bt pws accounts create", "Create a managed account"),
366
+ ("bt pws accounts update", "Update a managed account"),
367
+ ("bt pws accounts delete", "Delete a managed account"),
368
+ ("bt pws accounts rotate", "Rotate account password"),
369
+ ("bt pws functional list", "List functional accounts (for auto-management)"),
370
+ ("bt pws functional get", "Get a functional account"),
371
+ ("bt pws functional create", "Create a functional account"),
372
+ ("bt pws functional update", "Update a functional account"),
373
+ ("bt pws functional delete", "Delete a functional account"),
374
+ ("bt pws assets list", "List assets"),
375
+ ("bt pws assets search", "Search assets"),
376
+ ("bt pws assets get", "Get an asset"),
377
+ ("bt pws assets create", "Create an asset"),
378
+ ("bt pws assets update", "Update an asset"),
379
+ ("bt pws assets delete", "Delete an asset"),
380
+ ("bt pws credentials checkout", "Check out account credentials"),
381
+ ("bt pws credentials checkin", "Check in account credentials"),
382
+ ("bt pws secrets safes list", "List Secrets Safe safes"),
383
+ ("bt pws secrets folders list", "List Secrets Safe folders"),
384
+ ("bt pws secrets secrets list", "List secrets"),
385
+ ("bt pws secrets secrets create", "Create a secret"),
386
+ ("bt pws secrets secrets create-text", "Create a text secret (supports --file)"),
387
+ ("bt pws secrets secrets create-file", "Create a file secret"),
388
+ ("bt pws quick checkout", "Quick checkout workflow"),
389
+ ("bt pws quick onboard", "Quick onboard system + account"),
390
+ ("bt pws quick offboard", "Quick offboard system"),
391
+ # PRA
392
+ ("bt pra auth test", "Test PRA connection"),
393
+ ("bt pra jumpoint list", "List jumpoints"),
394
+ ("bt pra jump-groups list", "List jump groups"),
395
+ ("bt pra jump-groups create", "Create a jump group"),
396
+ ("bt pra jump-items shell list", "List shell jump items"),
397
+ ("bt pra jump-items shell create", "Create a shell jump item"),
398
+ ("bt pra jump-items shell update", "Update a shell jump item"),
399
+ ("bt pra jump-items shell delete", "Delete a shell jump item"),
400
+ ("bt pra jump-items rdp list", "List RDP jump items"),
401
+ ("bt pra jump-items rdp create", "Create an RDP jump item"),
402
+ ("bt pra vault accounts list", "List vault accounts"),
403
+ ("bt pra vault accounts get", "Get a vault account"),
404
+ ("bt pra vault accounts create", "Create a vault account"),
405
+ ("bt pra vault accounts checkout", "Checkout vault credentials"),
406
+ ("bt pra vault accounts get-user-data", "Generate EC2 user-data for SSH CA"),
407
+ ("bt pra vault accounts get-public-key", "Get SSH CA public key"),
408
+ # Entitle
409
+ ("bt entitle auth test", "Test Entitle connection"),
410
+ ("bt entitle integrations list", "List integrations"),
411
+ ("bt entitle resources list", "List resources"),
412
+ ("bt entitle resources create-virtual", "Create a virtual resource"),
413
+ ("bt entitle roles list", "List roles"),
414
+ ("bt entitle bundles list", "List bundles"),
415
+ ("bt entitle workflows list", "List workflows"),
416
+ ("bt entitle users list", "List users"),
417
+ ("bt entitle permissions list", "List permissions"),
418
+ ("bt entitle permissions revoke", "Revoke a permission"),
419
+ ("bt entitle agents list", "List agents"),
420
+ ("bt entitle agents status", "Show agent status summary"),
421
+ # EPMW
422
+ ("bt epmw auth test", "Test EPM Windows connection"),
423
+ ("bt epmw computers list", "List managed computers"),
424
+ ("bt epmw computers archive", "Archive a computer"),
425
+ ("bt epmw groups list", "List computer groups"),
426
+ ("bt epmw policies list", "List policies"),
427
+ ("bt epmw requests list", "List elevation requests"),
428
+ ("bt epmw requests approve", "Approve an elevation request"),
429
+ ("bt epmw requests deny", "Deny an elevation request"),
430
+ ("bt epmw quick approve-all", "Approve all pending requests"),
431
+ # Quick
432
+ ("bt quick pasm-onboard", "Onboard host to PWS + PRA (Total PASM)"),
433
+ ("bt quick pasm-offboard", "Offboard host from PWS + PRA"),
434
+ ("bt quick pasm-search", "Search across PWS + PRA"),
435
+ ]
436
+ return commands
437
+
438
+
439
+ @app.command("skills")
440
+ def skills(
441
+ path: Optional[str] = typer.Option(None, "--path", "-p", help="Target directory (default: current)"),
442
+ force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing skills"),
443
+ list_only: bool = typer.Option(False, "--list", "-l", help="List available skills without installing"),
444
+ ) -> None:
445
+ """Install Claude Code skills for AI agent navigation.
446
+
447
+ Copies bt-cli skills to .claude/skills/ in the target directory.
448
+ These skills help AI agents (like Claude Code) navigate bt-cli commands.
449
+
450
+ Skills included:
451
+ /bt - Cross-product commands (PASM workflows)
452
+ /pws - Password Safe commands
453
+ /pra - PRA commands
454
+ /entitle - Entitle commands
455
+ /epmw - EPM Windows commands
456
+
457
+ Examples:
458
+ bt skills # Install to current directory
459
+ bt skills -p /my/proj # Install to specific directory
460
+ bt skills --list # Show available skills
461
+ bt skills --force # Overwrite existing skills
462
+ """
463
+ import shutil
464
+ import sys
465
+ from pathlib import Path
466
+
467
+ # Find skills in package data
468
+ skills_source = _get_skills_path()
469
+ if not skills_source:
470
+ console.print("[red]Error:[/red] Skills not found in package")
471
+ raise typer.Exit(1)
472
+
473
+ skills_source = Path(skills_source)
474
+ available_skills = [d.name for d in skills_source.iterdir() if d.is_dir() and not d.name.startswith("_")]
475
+
476
+ if list_only:
477
+ console.print("[bold]Available bt-cli skills:[/bold]\n")
478
+ for skill in sorted(available_skills):
479
+ skill_file = skills_source / skill / "SKILL.md"
480
+ if skill_file.exists():
481
+ # Read first line of description from YAML frontmatter
482
+ content = skill_file.read_text()
483
+ desc = ""
484
+ if "description:" in content:
485
+ for line in content.split("\n"):
486
+ if line.startswith("description:"):
487
+ desc = line.split(":", 1)[1].strip()
488
+ break
489
+ console.print(f" [cyan]/{skill}[/cyan] - {desc[:60]}...")
490
+ console.print(f"\n[dim]Run 'bt skills' to install these to your project.[/dim]")
491
+ return
492
+
493
+ # Determine target directory
494
+ target_base = Path(path) if path else Path.cwd()
495
+ if not target_base.exists():
496
+ console.print(f"[red]Error:[/red] Directory {target_base} does not exist")
497
+ raise typer.Exit(1)
498
+
499
+ target_skills = target_base / ".claude" / "skills"
500
+
501
+ # Check for existing skills
502
+ existing = []
503
+ for skill in available_skills:
504
+ skill_target = target_skills / skill
505
+ if skill_target.exists() and not force:
506
+ existing.append(skill)
507
+
508
+ if existing and not force:
509
+ console.print(f"[yellow]Warning:[/yellow] The following skills already exist:")
510
+ for skill in existing:
511
+ console.print(f" - {skill}")
512
+ console.print("\nUse --force to overwrite, or remove them manually.")
513
+ raise typer.Exit(1)
514
+
515
+ # Create target directory
516
+ target_skills.mkdir(parents=True, exist_ok=True)
517
+
518
+ # Copy skills
519
+ installed = []
520
+ for skill in available_skills:
521
+ skill_source_dir = skills_source / skill
522
+ skill_target_dir = target_skills / skill
523
+
524
+ if skill_target_dir.exists():
525
+ shutil.rmtree(skill_target_dir)
526
+
527
+ shutil.copytree(skill_source_dir, skill_target_dir)
528
+ installed.append(skill)
529
+
530
+ # Also copy CLAUDE.md if it exists
531
+ claude_md_source = _get_claude_md_path()
532
+ if claude_md_source:
533
+ claude_md_target = target_base / "CLAUDE.md"
534
+ if not claude_md_target.exists() or force:
535
+ shutil.copy(claude_md_source, claude_md_target)
536
+ console.print(f"[green]Created[/green] CLAUDE.md")
537
+
538
+ console.print(f"\n[green]Installed {len(installed)} skills to {target_skills}[/green]\n")
539
+ for skill in sorted(installed):
540
+ console.print(f" [cyan]/{skill}[/cyan]")
541
+ console.print("\n[dim]Claude Code will now discover these skills automatically.[/dim]")
542
+
543
+
544
+ def _get_skills_path() -> Optional[str]:
545
+ """Find skills directory in package data."""
546
+ import sys
547
+ from pathlib import Path
548
+
549
+ # Try PyInstaller bundle first
550
+ if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
551
+ bundle_path = Path(sys._MEIPASS) / "bt_cli" / "data" / "skills"
552
+ if bundle_path.exists():
553
+ return str(bundle_path)
554
+
555
+ # Try importlib.resources (files() works with directories in 3.9+)
556
+ try:
557
+ if sys.version_info >= (3, 9):
558
+ from importlib.resources import files
559
+ data_path = files("bt_cli.data").joinpath("skills")
560
+ if data_path.is_dir():
561
+ return str(data_path)
562
+ else:
563
+ from importlib.resources import path
564
+ with path("bt_cli.data", "skills") as p:
565
+ if p.exists():
566
+ return str(p)
567
+ except (ImportError, ModuleNotFoundError, TypeError, FileNotFoundError, IsADirectoryError):
568
+ pass
569
+
570
+ # Fall back to source directory
571
+ current = Path(__file__).resolve().parent
572
+ candidate = current / "data" / "skills"
573
+ if candidate.exists():
574
+ return str(candidate)
575
+
576
+ return None
577
+
578
+
579
+ def _get_claude_md_path() -> Optional[str]:
580
+ """Find CLAUDE.md in package data."""
581
+ import sys
582
+ from pathlib import Path
583
+
584
+ # Try PyInstaller bundle first
585
+ if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
586
+ bundle_path = Path(sys._MEIPASS) / "bt_cli" / "data" / "CLAUDE.md"
587
+ if bundle_path.exists():
588
+ return str(bundle_path)
589
+
590
+ # Try importlib.resources (files() available in 3.9+)
591
+ try:
592
+ if sys.version_info >= (3, 9):
593
+ from importlib.resources import files
594
+ data_path = files("bt_cli.data").joinpath("CLAUDE.md")
595
+ if data_path.is_file():
596
+ return str(data_path)
597
+ else:
598
+ from importlib.resources import path
599
+ with path("bt_cli.data", "CLAUDE.md") as p:
600
+ if p.exists():
601
+ return str(p)
602
+ except (ImportError, ModuleNotFoundError, TypeError, FileNotFoundError):
603
+ pass
604
+
605
+ # Fall back to source directory
606
+ current = Path(__file__).resolve().parent
607
+ candidate = current / "data" / "CLAUDE.md"
608
+ if candidate.exists():
609
+ return str(candidate)
610
+
611
+ return None
612
+
613
+
614
+ @app.command("whoami")
615
+ def whoami(
616
+ output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
617
+ ) -> None:
618
+ """Test all configured products and show connection info.
619
+
620
+ Checks each BeyondTrust product for valid configuration, tests connectivity,
621
+ and displays information about the authenticated API user/connection.
622
+
623
+ Examples:
624
+ bt whoami # Test all configured products
625
+ bt whoami -o json # Output as JSON
626
+ """
627
+ import json
628
+ from rich.table import Table
629
+
630
+ results = []
631
+
632
+ # Test Password Safe
633
+ pws_result = _test_pws_connection()
634
+ if pws_result:
635
+ results.append(pws_result)
636
+
637
+ # Test Entitle
638
+ entitle_result = _test_entitle_connection()
639
+ if entitle_result:
640
+ results.append(entitle_result)
641
+
642
+ # Test PRA
643
+ pra_result = _test_pra_connection()
644
+ if pra_result:
645
+ results.append(pra_result)
646
+
647
+ # Test EPMW
648
+ epmw_result = _test_epmw_connection()
649
+ if epmw_result:
650
+ results.append(epmw_result)
651
+
652
+ if not results:
653
+ console.print("[yellow]No products configured.[/yellow]")
654
+ console.print("\nTo configure products, set environment variables or run:")
655
+ console.print(" bt configure --product <pws|entitle|pra|epmw>")
656
+ raise typer.Exit(1)
657
+
658
+ if output == "json":
659
+ console.print_json(json.dumps(results, indent=2))
660
+ else:
661
+ table = Table(title="BeyondTrust Product Connections")
662
+ table.add_column("Product", style="cyan")
663
+ table.add_column("Status", style="bold")
664
+ table.add_column("URL")
665
+ table.add_column("Auth")
666
+ table.add_column("User/Info")
667
+
668
+ for r in results:
669
+ status = "[green]Connected[/green]" if r["connected"] else f"[red]Failed[/red]"
670
+ table.add_row(
671
+ r["product"],
672
+ status,
673
+ r.get("url", "-"),
674
+ r.get("auth_method", "-"),
675
+ r.get("user_info", r.get("error", "-")),
676
+ )
677
+
678
+ console.print(table)
679
+
680
+ # Summary
681
+ connected = sum(1 for r in results if r["connected"])
682
+ console.print(f"\n[dim]{connected}/{len(results)} products connected[/dim]")
683
+
684
+
685
+ def _test_pws_connection() -> Optional[dict]:
686
+ """Test PWS connection and return status."""
687
+ try:
688
+ from .core.config import load_pws_config
689
+ from .pws.client.base import get_client
690
+
691
+ config = load_pws_config()
692
+ result = {
693
+ "product": "Password Safe",
694
+ "url": config.api_url,
695
+ "auth_method": config.auth_method,
696
+ "connected": False,
697
+ }
698
+
699
+ with get_client() as client:
700
+ response = client.authenticate()
701
+ user_name = response.get("UserName", "Unknown")
702
+ user_id = response.get("UserId", "N/A")
703
+ result["connected"] = True
704
+ result["user_info"] = f"{user_name} (ID: {user_id})"
705
+ result["user_name"] = user_name
706
+ result["user_id"] = user_id
707
+
708
+ return result
709
+ except ValueError:
710
+ # Not configured
711
+ return None
712
+ except Exception as e:
713
+ return {
714
+ "product": "Password Safe",
715
+ "url": "",
716
+ "auth_method": "-",
717
+ "connected": False,
718
+ "error": str(e)[:50],
719
+ }
720
+
721
+
722
+ def _test_entitle_connection() -> Optional[dict]:
723
+ """Test Entitle connection and return status."""
724
+ try:
725
+ from .core.config import load_entitle_config
726
+ from .entitle.client.base import get_client
727
+
728
+ config = load_entitle_config()
729
+ masked_key = config.api_key[:8] + "..." if len(config.api_key) > 8 else "***"
730
+ result = {
731
+ "product": "Entitle",
732
+ "url": config.api_url,
733
+ "auth_method": f"API Key ({masked_key})",
734
+ "connected": False,
735
+ }
736
+
737
+ with get_client() as client:
738
+ apps = client.list_applications(limit=1)
739
+ result["connected"] = True
740
+ result["user_info"] = f"{len(apps)} application(s) accessible"
741
+
742
+ return result
743
+ except ValueError:
744
+ # Not configured
745
+ return None
746
+ except Exception as e:
747
+ return {
748
+ "product": "Entitle",
749
+ "url": "",
750
+ "auth_method": "-",
751
+ "connected": False,
752
+ "error": str(e)[:50],
753
+ }
754
+
755
+
756
+ def _test_pra_connection() -> Optional[dict]:
757
+ """Test PRA connection and return status."""
758
+ try:
759
+ from .core.config import load_pra_config
760
+ from .pra.client import get_client
761
+
762
+ config = load_pra_config()
763
+ masked_id = config.client_id[:8] + "..." if len(config.client_id) > 8 else "***"
764
+ result = {
765
+ "product": "PRA",
766
+ "url": config.api_url,
767
+ "auth_method": f"OAuth ({masked_id})",
768
+ "connected": False,
769
+ }
770
+
771
+ client = get_client()
772
+ jumpoints = client.list_jumpoints()
773
+ result["connected"] = True
774
+ result["user_info"] = f"{len(jumpoints)} jumpoint(s) accessible"
775
+
776
+ return result
777
+ except ValueError:
778
+ # Not configured
779
+ return None
780
+ except Exception as e:
781
+ return {
782
+ "product": "PRA",
783
+ "url": "",
784
+ "auth_method": "-",
785
+ "connected": False,
786
+ "error": str(e)[:50],
787
+ }
788
+
789
+
790
+ def _test_epmw_connection() -> Optional[dict]:
791
+ """Test EPMW connection and return status."""
792
+ try:
793
+ from .core.config import load_epmw_config
794
+ from .epmw.client import get_client
795
+
796
+ config = load_epmw_config()
797
+ masked_id = config.client_id[:8] + "..." if len(config.client_id) > 8 else "***"
798
+ result = {
799
+ "product": "EPM Windows",
800
+ "url": config.api_url,
801
+ "auth_method": f"OAuth ({masked_id})",
802
+ "connected": False,
803
+ }
804
+
805
+ client = get_client()
806
+ computers = client.list_computers()
807
+ result["connected"] = True
808
+ result["user_info"] = f"{len(computers)} computer(s) managed"
809
+
810
+ return result
811
+ except ValueError:
812
+ # Not configured
813
+ return None
814
+ except Exception as e:
815
+ return {
816
+ "product": "EPM Windows",
817
+ "url": "",
818
+ "auth_method": "-",
819
+ "connected": False,
820
+ "error": str(e)[:50],
821
+ }
822
+
823
+
824
+ def run() -> None:
825
+ """Run the CLI application."""
826
+ app()
827
+
828
+
829
+ if __name__ == "__main__":
830
+ run()