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,1343 @@
1
+ """CLI commands for Secrets Safe management."""
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.table import Table
10
+
11
+ from ...core.output import print_api_error
12
+ from ..client.base import get_client
13
+
14
+ app = typer.Typer(no_args_is_help=True, help="Manage Secrets Safe safes, folders, and secrets")
15
+ safes_app = typer.Typer(no_args_is_help=True, help="Manage Secrets Safe safes (top-level containers)")
16
+ folders_app = typer.Typer(no_args_is_help=True, help="Manage Secrets Safe folders")
17
+ secrets_app = typer.Typer(no_args_is_help=True, help="Manage Secrets Safe secrets")
18
+
19
+ app.add_typer(safes_app, name="safes")
20
+ app.add_typer(folders_app, name="folders")
21
+ app.add_typer(secrets_app, name="secrets")
22
+
23
+ console = Console()
24
+
25
+
26
+ # =============================================================================
27
+ # Safes Commands
28
+ # =============================================================================
29
+
30
+
31
+ def print_safes_table(safes: list[dict], title: str = "Safes") -> None:
32
+ """Print safes in a formatted table."""
33
+ table = Table(title=title)
34
+ table.add_column("ID", style="cyan", no_wrap=True, max_width=36)
35
+ table.add_column("Name", style="green")
36
+ table.add_column("Description", style="dim")
37
+
38
+ for safe in safes:
39
+ table.add_row(
40
+ safe.get("Id", "")[:36],
41
+ safe.get("Name", ""),
42
+ safe.get("Description", "-") or "-",
43
+ )
44
+
45
+ console.print(table)
46
+
47
+
48
+ @safes_app.command("list")
49
+ def list_safes(
50
+ limit: int = typer.Option(50, "--limit", "-l", help="Maximum results (default: 50)"),
51
+ fetch_all: bool = typer.Option(False, "--all", help="Fetch all results"),
52
+ output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
53
+ ) -> None:
54
+ """List all Secrets Safe safes accessible to current user.
55
+
56
+ Examples:
57
+ bt pws secrets safes list # First 50 safes
58
+ bt pws secrets safes list --all # All safes
59
+ """
60
+ try:
61
+ with get_client() as client:
62
+ client.authenticate()
63
+ safes = client.list_safes()
64
+
65
+ # Apply client-side limit
66
+ total_count = len(safes)
67
+ if not fetch_all and len(safes) > limit:
68
+ safes = safes[:limit]
69
+
70
+ if output == "json":
71
+ console.print_json(json.dumps(safes, default=str))
72
+ else:
73
+ if safes:
74
+ print_safes_table(safes)
75
+ if not fetch_all and total_count > limit:
76
+ console.print(f"[dim]Showing {len(safes)} of {total_count} results. Use --all to fetch all results.[/dim]")
77
+ else:
78
+ console.print("[yellow]No safes found.[/yellow]")
79
+
80
+ except httpx.HTTPStatusError as e:
81
+ print_api_error(e, "manage secrets")
82
+ raise typer.Exit(1)
83
+ except httpx.RequestError as e:
84
+ print_api_error(e, "manage secrets")
85
+ raise typer.Exit(1)
86
+ except Exception as e:
87
+ print_api_error(e, "manage secrets")
88
+ raise typer.Exit(1)
89
+
90
+
91
+ @safes_app.command("get")
92
+ def get_safe(
93
+ safe_id: str = typer.Argument(..., help="Safe ID (GUID)"),
94
+ output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
95
+ ) -> None:
96
+ """Get a safe by ID."""
97
+ try:
98
+ with get_client() as client:
99
+ client.authenticate()
100
+ safe = client.get_safe(safe_id)
101
+
102
+ if output == "json":
103
+ console.print_json(json.dumps(safe, default=str))
104
+ else:
105
+ console.print(f"\n[bold cyan]Safe: {safe.get('Name', 'Unknown')}[/bold cyan]\n")
106
+ console.print(f" ID: {safe.get('Id', 'N/A')}")
107
+ console.print(f" Description: {safe.get('Description', '-') or '-'}")
108
+ console.print(f" Created: {safe.get('CreatedOn', '-')}")
109
+ console.print(f" Modified: {safe.get('ModifiedOn', '-')}")
110
+
111
+ except httpx.HTTPStatusError as e:
112
+ print_api_error(e, "manage secrets")
113
+ raise typer.Exit(1)
114
+ except httpx.RequestError as e:
115
+ print_api_error(e, "manage secrets")
116
+ raise typer.Exit(1)
117
+ except Exception as e:
118
+ print_api_error(e, "manage secrets")
119
+ raise typer.Exit(1)
120
+
121
+
122
+ @safes_app.command("create")
123
+ def create_safe(
124
+ name: str = typer.Option(..., "--name", "-n", help="Safe name"),
125
+ description: Optional[str] = typer.Option(None, "--description", "-d", help="Description"),
126
+ output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
127
+ ) -> None:
128
+ """Create a new safe in Secrets Safe."""
129
+ try:
130
+ with get_client() as client:
131
+ client.authenticate()
132
+ safe = client.create_safe(name=name, description=description)
133
+
134
+ if output == "json":
135
+ console.print_json(json.dumps(safe, default=str))
136
+ else:
137
+ console.print(f"[green]Created safe:[/green] {safe.get('Name', name)}")
138
+ console.print(f" ID: {safe.get('Id', 'N/A')}")
139
+
140
+ except httpx.HTTPStatusError as e:
141
+ print_api_error(e, "manage secrets")
142
+ raise typer.Exit(1)
143
+ except httpx.RequestError as e:
144
+ print_api_error(e, "manage secrets")
145
+ raise typer.Exit(1)
146
+ except Exception as e:
147
+ print_api_error(e, "manage secrets")
148
+ raise typer.Exit(1)
149
+
150
+
151
+ @safes_app.command("update")
152
+ def update_safe(
153
+ safe_id: str = typer.Argument(..., help="Safe ID (GUID) to update"),
154
+ name: Optional[str] = typer.Option(None, "--name", "-n", help="New name"),
155
+ description: Optional[str] = typer.Option(None, "--description", "-d", help="New description"),
156
+ output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
157
+ ) -> None:
158
+ """Update an existing safe."""
159
+ try:
160
+ with get_client() as client:
161
+ client.authenticate()
162
+ safe = client.update_safe(safe_id=safe_id, name=name, description=description)
163
+
164
+ if output == "json":
165
+ console.print_json(json.dumps(safe, default=str))
166
+ else:
167
+ console.print(f"[green]Updated safe:[/green] {safe.get('Name', 'Unknown')}")
168
+ console.print(f" ID: {safe_id}")
169
+
170
+ except httpx.HTTPStatusError as e:
171
+ print_api_error(e, "manage secrets")
172
+ raise typer.Exit(1)
173
+ except httpx.RequestError as e:
174
+ print_api_error(e, "manage secrets")
175
+ raise typer.Exit(1)
176
+ except Exception as e:
177
+ print_api_error(e, "manage secrets")
178
+ raise typer.Exit(1)
179
+
180
+
181
+ @safes_app.command("delete")
182
+ def delete_safe(
183
+ safe_id: str = typer.Argument(..., help="Safe ID (GUID) to delete"),
184
+ force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
185
+ ) -> None:
186
+ """Delete a safe from Secrets Safe.
187
+
188
+ Note: Safe must be empty (no folders or secrets) to delete.
189
+ """
190
+ try:
191
+ with get_client() as client:
192
+ client.authenticate()
193
+
194
+ if not force:
195
+ safe = client.get_safe(safe_id)
196
+ name = safe.get("Name", "Unknown")
197
+ confirm = typer.confirm(
198
+ f"Are you sure you want to delete safe '{name}'?"
199
+ )
200
+ if not confirm:
201
+ console.print("[yellow]Cancelled.[/yellow]")
202
+ raise typer.Exit(0)
203
+
204
+ client.delete_safe(safe_id)
205
+ console.print(f"[green]Deleted safe: {safe_id}[/green]")
206
+
207
+ except httpx.HTTPStatusError as e:
208
+ print_api_error(e, "manage secrets")
209
+ raise typer.Exit(1)
210
+ except httpx.RequestError as e:
211
+ print_api_error(e, "manage secrets")
212
+ raise typer.Exit(1)
213
+ except Exception as e:
214
+ print_api_error(e, "manage secrets")
215
+ raise typer.Exit(1)
216
+
217
+
218
+ @safes_app.command("permissions")
219
+ def list_safe_permissions(
220
+ safe_id: str = typer.Argument(..., help="Safe ID (GUID)"),
221
+ output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
222
+ ) -> None:
223
+ """List permissions assigned to a safe."""
224
+ try:
225
+ with get_client() as client:
226
+ client.authenticate()
227
+ permissions = client.get_safe_permissions(safe_id)
228
+
229
+ if output == "json":
230
+ console.print_json(json.dumps(permissions, default=str))
231
+ else:
232
+ if permissions:
233
+ table = Table(title="Safe Permissions")
234
+ table.add_column("ID", style="dim", max_width=36)
235
+ table.add_column("User ID", style="green")
236
+ table.add_column("Group ID", style="cyan")
237
+ table.add_column("Permissions", style="yellow")
238
+ table.add_column("Expires", style="dim")
239
+
240
+ for perm in permissions:
241
+ flags = perm.get("PermissionFlags") or []
242
+ table.add_row(
243
+ str(perm.get("Id", "-"))[:36],
244
+ str(perm.get("UserId", "-")),
245
+ str(perm.get("GroupId", "-")),
246
+ ", ".join(flags) if flags else "-",
247
+ str(perm.get("ExpiresOn", "-"))[:19] if perm.get("ExpiresOn") else "-",
248
+ )
249
+ console.print(table)
250
+ else:
251
+ console.print("[yellow]No permissions found.[/yellow]")
252
+
253
+ except httpx.HTTPStatusError as e:
254
+ print_api_error(e, "manage secrets")
255
+ raise typer.Exit(1)
256
+ except httpx.RequestError as e:
257
+ print_api_error(e, "manage secrets")
258
+ raise typer.Exit(1)
259
+ except Exception as e:
260
+ print_api_error(e, "manage secrets")
261
+ raise typer.Exit(1)
262
+
263
+
264
+ @safes_app.command("permissions-options")
265
+ def list_permissions_options(
266
+ output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
267
+ ) -> None:
268
+ """List all possible safe permission flags."""
269
+ try:
270
+ with get_client() as client:
271
+ client.authenticate()
272
+ options = client.list_safe_permissions_options()
273
+
274
+ if output == "json":
275
+ console.print_json(json.dumps(options, default=str))
276
+ else:
277
+ if options:
278
+ console.print("\n[bold cyan]Available Permission Flags:[/bold cyan]\n")
279
+ for opt in options:
280
+ console.print(f" - {opt}")
281
+ else:
282
+ console.print("[yellow]No permission options found.[/yellow]")
283
+
284
+ except httpx.HTTPStatusError as e:
285
+ print_api_error(e, "manage secrets")
286
+ raise typer.Exit(1)
287
+ except httpx.RequestError as e:
288
+ print_api_error(e, "manage secrets")
289
+ raise typer.Exit(1)
290
+ except Exception as e:
291
+ print_api_error(e, "manage secrets")
292
+ raise typer.Exit(1)
293
+
294
+
295
+ @safes_app.command("grant")
296
+ def grant_safe_permission(
297
+ safe_id: str = typer.Argument(..., help="Safe ID (GUID)"),
298
+ user_id: Optional[int] = typer.Option(None, "--user", "-u", help="User ID to grant permission"),
299
+ group_id: Optional[int] = typer.Option(None, "--group", "-g", help="Group ID to grant permission"),
300
+ permissions: str = typer.Option(..., "--permissions", "-p", help="Comma-separated permission flags (e.g., Read,Write)"),
301
+ expires: Optional[str] = typer.Option(None, "--expires", "-e", help="Expiry datetime (ISO format)"),
302
+ ) -> None:
303
+ """Grant permissions to a user or group on a safe.
304
+
305
+ Use 'pws secrets safes permissions-options' to see available permission flags.
306
+ """
307
+ try:
308
+ if not user_id and not group_id:
309
+ console.print("[red]Error:[/red] Either --user or --group is required")
310
+ raise typer.Exit(1)
311
+
312
+ if user_id and group_id:
313
+ console.print("[red]Error:[/red] Specify either --user or --group, not both")
314
+ raise typer.Exit(1)
315
+
316
+ permission_flags = [p.strip() for p in permissions.split(",")]
317
+
318
+ with get_client() as client:
319
+ client.authenticate()
320
+
321
+ if user_id:
322
+ client.grant_safe_permission_to_user(
323
+ safe_id, user_id, permission_flags, expires_on=expires
324
+ )
325
+ console.print(f"[green]Granted [{permissions}] to user {user_id} on safe[/green]")
326
+ else:
327
+ client.grant_safe_permission_to_group(
328
+ safe_id, group_id, permission_flags, expires_on=expires
329
+ )
330
+ console.print(f"[green]Granted [{permissions}] to group {group_id} on safe[/green]")
331
+
332
+ except httpx.HTTPStatusError as e:
333
+ print_api_error(e, "manage secrets")
334
+ raise typer.Exit(1)
335
+ except httpx.RequestError as e:
336
+ print_api_error(e, "manage secrets")
337
+ raise typer.Exit(1)
338
+ except Exception as e:
339
+ print_api_error(e, "manage secrets")
340
+ raise typer.Exit(1)
341
+
342
+
343
+ @safes_app.command("revoke")
344
+ def revoke_safe_permission(
345
+ safe_id: str = typer.Argument(..., help="Safe ID (GUID)"),
346
+ user_id: Optional[int] = typer.Option(None, "--user", "-u", help="User ID to revoke"),
347
+ group_id: Optional[int] = typer.Option(None, "--group", "-g", help="Group ID to revoke"),
348
+ ) -> None:
349
+ """Revoke all permissions from a user or group on a safe.
350
+
351
+ To revoke, grant an empty permission set using the grant command.
352
+ """
353
+ try:
354
+ if not user_id and not group_id:
355
+ console.print("[red]Error:[/red] Either --user or --group is required")
356
+ raise typer.Exit(1)
357
+
358
+ with get_client() as client:
359
+ client.authenticate()
360
+
361
+ # To revoke, we grant an empty permission set
362
+ if user_id:
363
+ client.grant_safe_permission_to_user(safe_id, user_id, [])
364
+ console.print(f"[green]Revoked permissions from user {user_id} on safe[/green]")
365
+ else:
366
+ client.grant_safe_permission_to_group(safe_id, group_id, [])
367
+ console.print(f"[green]Revoked permissions from group {group_id} on safe[/green]")
368
+
369
+ except httpx.HTTPStatusError as e:
370
+ print_api_error(e, "manage secrets")
371
+ raise typer.Exit(1)
372
+ except httpx.RequestError as e:
373
+ print_api_error(e, "manage secrets")
374
+ raise typer.Exit(1)
375
+ except Exception as e:
376
+ print_api_error(e, "manage secrets")
377
+ raise typer.Exit(1)
378
+
379
+
380
+ # =============================================================================
381
+ # Folders Commands
382
+ # =============================================================================
383
+
384
+
385
+ def print_folders_table(folders: list[dict], title: str = "Folders") -> None:
386
+ """Print folders in a formatted table."""
387
+ table = Table(title=title)
388
+ table.add_column("ID", style="cyan", no_wrap=True, max_width=36)
389
+ table.add_column("Name", style="green")
390
+ table.add_column("Path", style="yellow")
391
+ table.add_column("Description", style="dim")
392
+
393
+ for folder in folders:
394
+ table.add_row(
395
+ folder.get("Id", "")[:36],
396
+ folder.get("Name", ""),
397
+ folder.get("FolderPath", folder.get("Name", "")),
398
+ folder.get("Description", "-") or "-",
399
+ )
400
+
401
+ console.print(table)
402
+
403
+
404
+ @folders_app.command("list")
405
+ def list_folders(
406
+ name: Optional[str] = typer.Option(None, "--name", "-n", help="Filter by folder name"),
407
+ path: Optional[str] = typer.Option(None, "--path", "-p", help="Filter by folder path"),
408
+ root_only: bool = typer.Option(False, "--root-only", "-r", help="Show only root folders"),
409
+ limit: int = typer.Option(50, "--limit", "-l", help="Maximum results (default: 50)"),
410
+ fetch_all: bool = typer.Option(False, "--all", help="Fetch all results (may be slow)"),
411
+ output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
412
+ ) -> None:
413
+ """List Secrets Safe folders.
414
+
415
+ Examples:
416
+ bt pws secrets folders list # First 50 folders
417
+ bt pws secrets folders list --all # All folders
418
+ bt pws secrets folders list -r # Root folders only
419
+ """
420
+ try:
421
+ with get_client() as client:
422
+ client.authenticate()
423
+ folders = client.list_folders(
424
+ folder_name=name,
425
+ folder_path=path,
426
+ root_only=root_only,
427
+ limit=None if fetch_all else limit,
428
+ )
429
+
430
+ if output == "json":
431
+ console.print_json(json.dumps(folders, default=str))
432
+ else:
433
+ if folders:
434
+ print_folders_table(folders)
435
+ if not fetch_all and len(folders) == limit:
436
+ console.print(f"[dim]Showing {len(folders)} results. Use --all to fetch all results.[/dim]")
437
+ else:
438
+ console.print("[yellow]No folders found.[/yellow]")
439
+
440
+ except httpx.HTTPStatusError as e:
441
+ print_api_error(e, "manage secrets")
442
+ raise typer.Exit(1)
443
+ except httpx.RequestError as e:
444
+ print_api_error(e, "manage secrets")
445
+ raise typer.Exit(1)
446
+ except Exception as e:
447
+ print_api_error(e, "manage secrets")
448
+ raise typer.Exit(1)
449
+
450
+
451
+ @folders_app.command("get")
452
+ def get_folder(
453
+ folder_id: str = typer.Argument(..., help="Folder ID (GUID)"),
454
+ output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
455
+ ) -> None:
456
+ """Get a folder by ID."""
457
+ try:
458
+ with get_client() as client:
459
+ client.authenticate()
460
+ folder = client.get_folder(folder_id)
461
+
462
+ if output == "json":
463
+ console.print_json(json.dumps(folder, default=str))
464
+ else:
465
+ console.print(f"\n[bold cyan]Folder: {folder.get('Name', 'Unknown')}[/bold cyan]\n")
466
+ console.print(f" ID: {folder.get('Id', 'N/A')}")
467
+ console.print(f" Path: {folder.get('FolderPath', '-')}")
468
+ console.print(f" Description: {folder.get('Description', '-') or '-'}")
469
+ console.print(f" Created: {folder.get('CreatedOn', '-')}")
470
+ console.print(f" Modified: {folder.get('ModifiedOn', '-')}")
471
+
472
+ except httpx.HTTPStatusError as e:
473
+ print_api_error(e, "manage secrets")
474
+ raise typer.Exit(1)
475
+ except httpx.RequestError as e:
476
+ print_api_error(e, "manage secrets")
477
+ raise typer.Exit(1)
478
+ except Exception as e:
479
+ print_api_error(e, "manage secrets")
480
+ raise typer.Exit(1)
481
+
482
+
483
+ @folders_app.command("create")
484
+ def create_folder(
485
+ name: str = typer.Option(..., "--name", "-n", help="Folder name"),
486
+ parent: str = typer.Option(..., "--parent", "-p", help="Parent ID (Safe ID for root folder, or Folder ID for subfolder)"),
487
+ description: Optional[str] = typer.Option(None, "--description", "-d", help="Description"),
488
+ output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
489
+ ) -> None:
490
+ """Create a new folder in Secrets Safe.
491
+
492
+ For root-level folders, use the Safe ID as parent.
493
+ For subfolders, use the parent Folder ID.
494
+ """
495
+ try:
496
+ with get_client() as client:
497
+ client.authenticate()
498
+ folder = client.create_folder(
499
+ name=name,
500
+ parent_id=parent,
501
+ description=description,
502
+ )
503
+
504
+ if output == "json":
505
+ console.print_json(json.dumps(folder, default=str))
506
+ else:
507
+ console.print(f"[green]Created folder:[/green] {folder.get('Name', name)}")
508
+ console.print(f" ID: {folder.get('Id', 'N/A')}")
509
+ if folder.get("FolderPath"):
510
+ console.print(f" Path: {folder.get('FolderPath')}")
511
+
512
+ except httpx.HTTPStatusError as e:
513
+ print_api_error(e, "manage secrets")
514
+ raise typer.Exit(1)
515
+ except httpx.RequestError as e:
516
+ print_api_error(e, "manage secrets")
517
+ raise typer.Exit(1)
518
+ except Exception as e:
519
+ print_api_error(e, "manage secrets")
520
+ raise typer.Exit(1)
521
+
522
+
523
+ @folders_app.command("delete")
524
+ def delete_folder(
525
+ folder_id: str = typer.Argument(..., help="Folder ID (GUID) to delete"),
526
+ force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
527
+ ) -> None:
528
+ """Delete a folder from Secrets Safe.
529
+
530
+ Note: Folders containing secrets cannot be deleted.
531
+ """
532
+ try:
533
+ with get_client() as client:
534
+ client.authenticate()
535
+
536
+ if not force:
537
+ folder = client.get_folder(folder_id)
538
+ name = folder.get("Name", "Unknown")
539
+ confirm = typer.confirm(
540
+ f"Are you sure you want to delete folder '{name}'?"
541
+ )
542
+ if not confirm:
543
+ console.print("[yellow]Cancelled.[/yellow]")
544
+ raise typer.Exit(0)
545
+
546
+ client.delete_folder(folder_id)
547
+ console.print(f"[green]Deleted folder: {folder_id}[/green]")
548
+
549
+ except httpx.HTTPStatusError as e:
550
+ print_api_error(e, "manage secrets")
551
+ raise typer.Exit(1)
552
+ except httpx.RequestError as e:
553
+ print_api_error(e, "manage secrets")
554
+ raise typer.Exit(1)
555
+ except Exception as e:
556
+ print_api_error(e, "manage secrets")
557
+ raise typer.Exit(1)
558
+
559
+
560
+ # =============================================================================
561
+ # Secrets Commands
562
+ # =============================================================================
563
+
564
+
565
+ def print_secrets_table(secrets: list[dict], title: str = "Secrets") -> None:
566
+ """Print secrets in a formatted table."""
567
+ table = Table(title=title)
568
+ table.add_column("ID", style="cyan", no_wrap=True, max_width=36)
569
+ table.add_column("Title", style="green")
570
+ table.add_column("Username", style="yellow")
571
+ table.add_column("Folder", style="blue")
572
+ table.add_column("Modified", style="dim")
573
+
574
+ for secret in secrets:
575
+ table.add_row(
576
+ secret.get("Id", "")[:36],
577
+ secret.get("Title", ""),
578
+ secret.get("Username", "-"),
579
+ secret.get("Folder", secret.get("FolderPath", "-")),
580
+ str(secret.get("ModifiedOn", "-"))[:19],
581
+ )
582
+
583
+ console.print(table)
584
+
585
+
586
+ @secrets_app.command("list")
587
+ def list_secrets(
588
+ folder: Optional[str] = typer.Option(None, "--folder", "-f", help="Folder ID to list secrets from"),
589
+ title: Optional[str] = typer.Option(None, "--title", "-t", help="Filter by secret title"),
590
+ limit: int = typer.Option(50, "--limit", "-l", help="Maximum results (default: 50)"),
591
+ fetch_all: bool = typer.Option(False, "--all", help="Fetch all results (may be slow)"),
592
+ output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
593
+ ) -> None:
594
+ """List secrets in Secrets Safe.
595
+
596
+ Examples:
597
+ bt pws secrets secrets list # First 50 secrets
598
+ bt pws secrets secrets list --all # All secrets
599
+ bt pws secrets secrets list -f <id> # Secrets in folder
600
+ """
601
+ try:
602
+ with get_client() as client:
603
+ client.authenticate()
604
+ secrets = client.list_secrets(
605
+ folder_id=folder,
606
+ title=title,
607
+ limit=None if fetch_all else limit,
608
+ )
609
+
610
+ if output == "json":
611
+ console.print_json(json.dumps(secrets, default=str))
612
+ else:
613
+ if secrets:
614
+ print_secrets_table(secrets)
615
+ if not fetch_all and len(secrets) == limit:
616
+ console.print(f"[dim]Showing {len(secrets)} results. Use --all to fetch all results.[/dim]")
617
+ else:
618
+ console.print("[yellow]No secrets found.[/yellow]")
619
+
620
+ except httpx.HTTPStatusError as e:
621
+ print_api_error(e, "manage secrets")
622
+ raise typer.Exit(1)
623
+ except httpx.RequestError as e:
624
+ print_api_error(e, "manage secrets")
625
+ raise typer.Exit(1)
626
+ except Exception as e:
627
+ print_api_error(e, "manage secrets")
628
+ raise typer.Exit(1)
629
+
630
+
631
+ @secrets_app.command("get")
632
+ def get_secret(
633
+ secret_id: str = typer.Argument(..., help="Secret ID (GUID)"),
634
+ show_password: bool = typer.Option(False, "--show-password", "-p", help="Show password value"),
635
+ output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
636
+ ) -> None:
637
+ """Get a secret by ID (retrieves password)."""
638
+ try:
639
+ with get_client() as client:
640
+ client.authenticate()
641
+ secret = client.get_secret(secret_id)
642
+
643
+ if output == "json":
644
+ if not show_password and "Password" in secret:
645
+ secret = dict(secret)
646
+ secret["Password"] = "********"
647
+ console.print_json(json.dumps(secret, default=str))
648
+ else:
649
+ console.print(f"\n[bold cyan]Secret: {secret.get('Title', 'Unknown')}[/bold cyan]\n")
650
+ console.print(f" ID: {secret.get('Id', 'N/A')}")
651
+ console.print(f" Username: {secret.get('Username', '-')}")
652
+ if show_password:
653
+ console.print(f" Password: {secret.get('Password', '-')}")
654
+ else:
655
+ console.print(" Password: ********")
656
+ console.print(f" Folder: {secret.get('Folder', secret.get('FolderPath', '-'))}")
657
+ console.print(f" Description: {secret.get('Description', '-') or '-'}")
658
+ if secret.get("Notes"):
659
+ console.print(f" Notes: {secret.get('Notes')}")
660
+ if secret.get("Urls"):
661
+ urls = [u.get("Url", "") for u in secret.get("Urls", [])]
662
+ console.print(f" URLs: {', '.join(urls)}")
663
+ console.print(f" Created: {secret.get('CreatedOn', '-')}")
664
+ console.print(f" Modified: {secret.get('ModifiedOn', '-')}")
665
+
666
+ except httpx.HTTPStatusError as e:
667
+ print_api_error(e, "manage secrets")
668
+ raise typer.Exit(1)
669
+ except httpx.RequestError as e:
670
+ print_api_error(e, "manage secrets")
671
+ raise typer.Exit(1)
672
+ except Exception as e:
673
+ print_api_error(e, "manage secrets")
674
+ raise typer.Exit(1)
675
+
676
+
677
+ @secrets_app.command("show")
678
+ def show_password(
679
+ secret_id: str = typer.Argument(..., help="Secret ID (GUID)"),
680
+ ) -> None:
681
+ """Show only the password for a secret (for scripting).
682
+
683
+ WARNING: This outputs the password to stdout for piping to other commands.
684
+ Be careful when running interactively as the password will be visible.
685
+ """
686
+ import sys
687
+
688
+ try:
689
+ with get_client() as client:
690
+ client.authenticate()
691
+ secret = client.get_secret(secret_id)
692
+ password = secret.get("Password", "")
693
+
694
+ # Warn if running interactively (stdout is a terminal)
695
+ if sys.stdout.isatty():
696
+ console.print(
697
+ "[yellow]Warning:[/yellow] Password will be displayed. "
698
+ "Press Ctrl+C to cancel.",
699
+ err=True,
700
+ )
701
+
702
+ # Print just the password for piping
703
+ print(password)
704
+
705
+ except Exception as e:
706
+ console.print(f"[red]Error:[/red] {e}", err=True)
707
+ raise typer.Exit(1)
708
+
709
+
710
+ @secrets_app.command("create")
711
+ def create_secret(
712
+ folder: str = typer.Option(..., "--folder", "-f", help="Folder ID (GUID) to create secret in"),
713
+ title: str = typer.Option(..., "--title", "-t", help="Secret title"),
714
+ username: str = typer.Option(..., "--username", "-u", help="Username"),
715
+ password: str = typer.Option(..., "--password", "-p", help="Password value", prompt=True, hide_input=True),
716
+ description: Optional[str] = typer.Option(None, "--description", "-d", help="Description"),
717
+ notes: Optional[str] = typer.Option(None, "--notes", "-n", help="Additional notes"),
718
+ url: Optional[list[str]] = typer.Option(None, "--url", help="Associated URL (can specify multiple)"),
719
+ output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
720
+ ) -> None:
721
+ """Create a new secret in Secrets Safe."""
722
+ try:
723
+ with get_client() as client:
724
+ client.authenticate()
725
+ secret = client.create_secret(
726
+ folder_id=folder,
727
+ title=title,
728
+ username=username,
729
+ password=password,
730
+ description=description,
731
+ notes=notes,
732
+ urls=url,
733
+ )
734
+
735
+ if output == "json":
736
+ if "Password" in secret:
737
+ secret = dict(secret)
738
+ secret["Password"] = "********"
739
+ console.print_json(json.dumps(secret, default=str))
740
+ else:
741
+ console.print(f"[green]Created secret:[/green] {secret.get('Title', title)}")
742
+ console.print(f" ID: {secret.get('Id', 'N/A')}")
743
+ console.print(f" Username: {username}")
744
+ console.print(f" Folder: {secret.get('Folder', folder)}")
745
+
746
+ except httpx.HTTPStatusError as e:
747
+ print_api_error(e, "manage secrets")
748
+ raise typer.Exit(1)
749
+ except httpx.RequestError as e:
750
+ print_api_error(e, "manage secrets")
751
+ raise typer.Exit(1)
752
+ except Exception as e:
753
+ print_api_error(e, "manage secrets")
754
+ raise typer.Exit(1)
755
+
756
+
757
+ @secrets_app.command("update")
758
+ def update_secret(
759
+ secret_id: str = typer.Argument(..., help="Secret ID (GUID) to update"),
760
+ title: Optional[str] = typer.Option(None, "--title", "-t", help="New title"),
761
+ username: Optional[str] = typer.Option(None, "--username", "-u", help="New username"),
762
+ password: Optional[str] = typer.Option(None, "--password", "-p", help="New password"),
763
+ description: Optional[str] = typer.Option(None, "--description", "-d", help="New description"),
764
+ notes: Optional[str] = typer.Option(None, "--notes", "-n", help="New notes"),
765
+ output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
766
+ ) -> None:
767
+ """Update an existing secret."""
768
+ try:
769
+ with get_client() as client:
770
+ client.authenticate()
771
+ secret = client.update_secret(
772
+ secret_id=secret_id,
773
+ title=title,
774
+ username=username,
775
+ password=password,
776
+ description=description,
777
+ notes=notes,
778
+ )
779
+
780
+ if output == "json":
781
+ if "Password" in secret:
782
+ secret = dict(secret)
783
+ secret["Password"] = "********"
784
+ console.print_json(json.dumps(secret, default=str))
785
+ else:
786
+ console.print(f"[green]Updated secret:[/green] {secret.get('Title', 'Unknown')}")
787
+ console.print(f" ID: {secret_id}")
788
+
789
+ except httpx.HTTPStatusError as e:
790
+ print_api_error(e, "manage secrets")
791
+ raise typer.Exit(1)
792
+ except httpx.RequestError as e:
793
+ print_api_error(e, "manage secrets")
794
+ raise typer.Exit(1)
795
+ except Exception as e:
796
+ print_api_error(e, "manage secrets")
797
+ raise typer.Exit(1)
798
+
799
+
800
+ @secrets_app.command("delete")
801
+ def delete_secret(
802
+ secret_id: str = typer.Argument(..., help="Secret ID (GUID) to delete"),
803
+ force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
804
+ ) -> None:
805
+ """Delete a secret from Secrets Safe."""
806
+ try:
807
+ with get_client() as client:
808
+ client.authenticate()
809
+
810
+ if not force:
811
+ secret = client.get_secret(secret_id)
812
+ title = secret.get("Title", "Unknown")
813
+ confirm = typer.confirm(
814
+ f"Are you sure you want to delete secret '{title}'?"
815
+ )
816
+ if not confirm:
817
+ console.print("[yellow]Cancelled.[/yellow]")
818
+ raise typer.Exit(0)
819
+
820
+ client.delete_secret(secret_id)
821
+ console.print(f"[green]Deleted secret: {secret_id}[/green]")
822
+
823
+ except httpx.HTTPStatusError as e:
824
+ print_api_error(e, "manage secrets")
825
+ raise typer.Exit(1)
826
+ except httpx.RequestError as e:
827
+ print_api_error(e, "manage secrets")
828
+ raise typer.Exit(1)
829
+ except Exception as e:
830
+ print_api_error(e, "manage secrets")
831
+ raise typer.Exit(1)
832
+
833
+
834
+ @secrets_app.command("create-text")
835
+ def create_text_secret(
836
+ folder: str = typer.Option(..., "--folder", "-f", help="Folder ID (GUID) to create secret in"),
837
+ title: str = typer.Option(..., "--title", "-t", help="Secret title"),
838
+ text: str = typer.Option(None, "--text", help="Text content (or use --file to read from file)"),
839
+ file: Optional[str] = typer.Option(None, "--file", help="Read text content from file"),
840
+ description: Optional[str] = typer.Option(None, "--description", "-d", help="Description"),
841
+ notes: Optional[str] = typer.Option(None, "--notes", "-n", help="Additional notes"),
842
+ output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
843
+ ) -> None:
844
+ """Create a Text type secret (for JSON, configs, etc)."""
845
+ try:
846
+ # Get text content from file or parameter
847
+ if file:
848
+ with open(file, "r") as f:
849
+ text_content = f.read()
850
+ elif text:
851
+ text_content = text
852
+ else:
853
+ console.print("[red]Error:[/red] Either --text or --file is required")
854
+ raise typer.Exit(1)
855
+
856
+ with get_client() as client:
857
+ client.authenticate()
858
+ secret = client.create_text_secret(
859
+ folder_id=folder,
860
+ title=title,
861
+ text=text_content,
862
+ description=description,
863
+ notes=notes,
864
+ )
865
+
866
+ if output == "json":
867
+ console.print_json(json.dumps(secret, default=str))
868
+ else:
869
+ console.print(f"[green]Created text secret:[/green] {secret.get('Title', title)}")
870
+ console.print(f" ID: {secret.get('Id', 'N/A')}")
871
+ console.print(f" Folder: {secret.get('Folder', folder)}")
872
+
873
+ except httpx.HTTPStatusError as e:
874
+ print_api_error(e, "manage secrets")
875
+ raise typer.Exit(1)
876
+ except httpx.RequestError as e:
877
+ print_api_error(e, "manage secrets")
878
+ raise typer.Exit(1)
879
+ except Exception as e:
880
+ print_api_error(e, "manage secrets")
881
+ raise typer.Exit(1)
882
+
883
+
884
+ @secrets_app.command("create-file")
885
+ def create_file_secret(
886
+ folder: str = typer.Option(..., "--folder", "-f", help="Folder ID (GUID) to create secret in"),
887
+ title: str = typer.Option(..., "--title", "-t", help="Secret title"),
888
+ file: str = typer.Option(..., "--file", help="Path to file to upload (max 5MB)"),
889
+ description: Optional[str] = typer.Option(None, "--description", "-d", help="Description"),
890
+ notes: Optional[str] = typer.Option(None, "--notes", "-n", help="Additional notes"),
891
+ output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
892
+ ) -> None:
893
+ """Create a File type secret (for certificates, keys, etc)."""
894
+ import os
895
+
896
+ MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB
897
+
898
+ try:
899
+ # Read file content atomically to avoid TOCTOU race condition
900
+ # Check size after reading, not before
901
+ if not os.path.exists(file):
902
+ console.print(f"[red]Error:[/red] File not found: {file}")
903
+ raise typer.Exit(1)
904
+
905
+ with open(file, "rb") as f:
906
+ file_content = f.read(MAX_FILE_SIZE + 1) # Read one extra byte to detect oversized files
907
+
908
+ if len(file_content) > MAX_FILE_SIZE:
909
+ console.print(f"[red]Error:[/red] File too large. Max size is 5MB.")
910
+ raise typer.Exit(1)
911
+
912
+ file_name = os.path.basename(file)
913
+
914
+ with get_client() as client:
915
+ client.authenticate()
916
+ secret = client.create_file_secret(
917
+ folder_id=folder,
918
+ title=title,
919
+ file_content=file_content,
920
+ file_name=file_name,
921
+ description=description,
922
+ notes=notes,
923
+ )
924
+
925
+ if output == "json":
926
+ console.print_json(json.dumps(secret, default=str))
927
+ else:
928
+ console.print(f"[green]Created file secret:[/green] {secret.get('Title', title)}")
929
+ console.print(f" ID: {secret.get('Id', 'N/A')}")
930
+ console.print(f" FileName: {secret.get('FileName', file_name)}")
931
+ console.print(f" Folder: {secret.get('Folder', folder)}")
932
+
933
+ except httpx.HTTPStatusError as e:
934
+ print_api_error(e, "manage secrets")
935
+ raise typer.Exit(1)
936
+ except httpx.RequestError as e:
937
+ print_api_error(e, "manage secrets")
938
+ raise typer.Exit(1)
939
+ except Exception as e:
940
+ print_api_error(e, "manage secrets")
941
+ raise typer.Exit(1)
942
+
943
+
944
+ @secrets_app.command("get-text")
945
+ def get_text_secret(
946
+ secret_id: str = typer.Argument(..., help="Secret ID (GUID)"),
947
+ show_text: bool = typer.Option(False, "--show-text", "-s", help="Show text content"),
948
+ output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
949
+ ) -> None:
950
+ """Get a Text type secret by ID."""
951
+ try:
952
+ with get_client() as client:
953
+ client.authenticate()
954
+ secret = client.get_secret(secret_id)
955
+
956
+ if output == "json":
957
+ if not show_text and "Password" in secret:
958
+ secret = dict(secret)
959
+ secret["Password"] = "********"
960
+ console.print_json(json.dumps(secret, default=str))
961
+ else:
962
+ console.print(f"\n[bold cyan]Text Secret: {secret.get('Title', 'Unknown')}[/bold cyan]\n")
963
+ console.print(f" ID: {secret.get('Id', 'N/A')}")
964
+ console.print(f" Folder: {secret.get('Folder', secret.get('FolderPath', '-'))}")
965
+ console.print(f" Description: {secret.get('Description', '-') or '-'}")
966
+ if show_text:
967
+ console.print(f" Text Content:\n{secret.get('Password', '-')}")
968
+ else:
969
+ console.print(" Text Content: [dim](use --show-text to reveal)[/dim]")
970
+ console.print(f" Created: {secret.get('CreatedOn', '-')}")
971
+ console.print(f" Modified: {secret.get('ModifiedOn', '-')}")
972
+
973
+ except httpx.HTTPStatusError as e:
974
+ print_api_error(e, "manage secrets")
975
+ raise typer.Exit(1)
976
+ except httpx.RequestError as e:
977
+ print_api_error(e, "manage secrets")
978
+ raise typer.Exit(1)
979
+ except Exception as e:
980
+ print_api_error(e, "manage secrets")
981
+ raise typer.Exit(1)
982
+
983
+
984
+ @secrets_app.command("show-text")
985
+ def show_text_content(
986
+ secret_id: str = typer.Argument(..., help="Secret ID (GUID)"),
987
+ ) -> None:
988
+ """Show only the text content for a Text secret (for scripting)."""
989
+ try:
990
+ with get_client() as client:
991
+ client.authenticate()
992
+ secret = client.get_secret(secret_id)
993
+ text = secret.get("Password", "")
994
+ print(text)
995
+
996
+ except Exception as e:
997
+ console.print(f"[red]Error:[/red] {e}", err=True)
998
+ raise typer.Exit(1)
999
+
1000
+
1001
+ @secrets_app.command("update-text")
1002
+ def update_text_secret(
1003
+ secret_id: str = typer.Argument(..., help="Secret ID (GUID) to update"),
1004
+ title: Optional[str] = typer.Option(None, "--title", "-t", help="New title"),
1005
+ text: Optional[str] = typer.Option(None, "--text", help="New text content"),
1006
+ file: Optional[str] = typer.Option(None, "--file", help="Read new text content from file"),
1007
+ description: Optional[str] = typer.Option(None, "--description", "-d", help="New description"),
1008
+ notes: Optional[str] = typer.Option(None, "--notes", "-n", help="New notes"),
1009
+ output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
1010
+ ) -> None:
1011
+ """Update an existing Text type secret."""
1012
+ try:
1013
+ # Get text content from file or parameter
1014
+ text_content = None
1015
+ if file:
1016
+ with open(file, "r") as f:
1017
+ text_content = f.read()
1018
+ elif text:
1019
+ text_content = text
1020
+
1021
+ with get_client() as client:
1022
+ client.authenticate()
1023
+ secret = client.update_text_secret(
1024
+ secret_id=secret_id,
1025
+ text=text_content,
1026
+ title=title,
1027
+ description=description,
1028
+ notes=notes,
1029
+ )
1030
+
1031
+ if output == "json":
1032
+ console.print_json(json.dumps(secret, default=str))
1033
+ else:
1034
+ console.print(f"[green]Updated text secret:[/green] {secret.get('Title', 'Unknown')}")
1035
+ console.print(f" ID: {secret_id}")
1036
+
1037
+ except httpx.HTTPStatusError as e:
1038
+ print_api_error(e, "manage secrets")
1039
+ raise typer.Exit(1)
1040
+ except httpx.RequestError as e:
1041
+ print_api_error(e, "manage secrets")
1042
+ raise typer.Exit(1)
1043
+ except Exception as e:
1044
+ print_api_error(e, "manage secrets")
1045
+ raise typer.Exit(1)
1046
+
1047
+
1048
+ @secrets_app.command("get-file")
1049
+ def get_file_secret(
1050
+ secret_id: str = typer.Argument(..., help="Secret ID (GUID)"),
1051
+ output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
1052
+ ) -> None:
1053
+ """Get a File type secret metadata by ID."""
1054
+ try:
1055
+ with get_client() as client:
1056
+ client.authenticate()
1057
+ secret = client.get_secret(secret_id)
1058
+
1059
+ if output == "json":
1060
+ # Don't include file content in json output
1061
+ secret = dict(secret)
1062
+ secret.pop("Password", None)
1063
+ console.print_json(json.dumps(secret, default=str))
1064
+ else:
1065
+ console.print(f"\n[bold cyan]File Secret: {secret.get('Title', 'Unknown')}[/bold cyan]\n")
1066
+ console.print(f" ID: {secret.get('Id', 'N/A')}")
1067
+ console.print(f" FileName: {secret.get('FileName', '-')}")
1068
+ console.print(f" Folder: {secret.get('Folder', secret.get('FolderPath', '-'))}")
1069
+ console.print(f" Description: {secret.get('Description', '-') or '-'}")
1070
+ console.print(f" Created: {secret.get('CreatedOn', '-')}")
1071
+ console.print(f" Modified: {secret.get('ModifiedOn', '-')}")
1072
+ console.print("\n [dim]Use 'pws secrets secrets download' to download the file content[/dim]")
1073
+
1074
+ except httpx.HTTPStatusError as e:
1075
+ print_api_error(e, "manage secrets")
1076
+ raise typer.Exit(1)
1077
+ except httpx.RequestError as e:
1078
+ print_api_error(e, "manage secrets")
1079
+ raise typer.Exit(1)
1080
+ except Exception as e:
1081
+ print_api_error(e, "manage secrets")
1082
+ raise typer.Exit(1)
1083
+
1084
+
1085
+ def _sanitize_filename(filename: str) -> str:
1086
+ """Sanitize filename to prevent path traversal attacks.
1087
+
1088
+ Removes path separators and parent directory references.
1089
+ """
1090
+ import os
1091
+ # Get just the basename to strip any directory components
1092
+ safe_name = os.path.basename(filename)
1093
+ # Remove any remaining path traversal attempts
1094
+ safe_name = safe_name.replace("..", "").replace("/", "").replace("\\", "")
1095
+ # If nothing left, use a default name
1096
+ if not safe_name or safe_name.startswith("."):
1097
+ safe_name = "downloaded_file"
1098
+ return safe_name
1099
+
1100
+
1101
+ @secrets_app.command("download")
1102
+ def download_file_secret(
1103
+ secret_id: str = typer.Argument(..., help="Secret ID (GUID)"),
1104
+ output_path: Optional[str] = typer.Option(None, "--output", "-o", help="Output file path (uses original filename if not specified)"),
1105
+ ) -> None:
1106
+ """Download a File type secret to disk."""
1107
+ import os
1108
+
1109
+ try:
1110
+ with get_client() as client:
1111
+ client.authenticate()
1112
+ # Get metadata first for filename
1113
+ secret = client.get_secret(secret_id)
1114
+ # Sanitize filename from API to prevent path traversal
1115
+ file_name = _sanitize_filename(secret.get("FileName", "downloaded_file"))
1116
+
1117
+ # Get file content
1118
+ content = client.get_file_content(secret_id)
1119
+
1120
+ # Determine output path
1121
+ if output_path:
1122
+ # User-specified path is trusted (they control where to write)
1123
+ out_file = output_path
1124
+ else:
1125
+ # API-provided filename must be sanitized
1126
+ out_file = file_name
1127
+
1128
+ # Resolve to absolute path and verify it's in expected location
1129
+ out_file = os.path.abspath(out_file)
1130
+
1131
+ # Write to disk
1132
+ with open(out_file, "wb") as f:
1133
+ f.write(content)
1134
+
1135
+ console.print(f"[green]Downloaded:[/green] {out_file}")
1136
+ console.print(f" Size: {len(content)} bytes")
1137
+
1138
+ except httpx.HTTPStatusError as e:
1139
+ print_api_error(e, "manage secrets")
1140
+ raise typer.Exit(1)
1141
+ except httpx.RequestError as e:
1142
+ print_api_error(e, "manage secrets")
1143
+ raise typer.Exit(1)
1144
+ except Exception as e:
1145
+ print_api_error(e, "manage secrets")
1146
+ raise typer.Exit(1)
1147
+
1148
+
1149
+ @secrets_app.command("update-file")
1150
+ def update_file_secret(
1151
+ secret_id: str = typer.Argument(..., help="Secret ID (GUID) to update"),
1152
+ title: Optional[str] = typer.Option(None, "--title", "-t", help="New title"),
1153
+ file: Optional[str] = typer.Option(None, "--file", help="New file to upload"),
1154
+ description: Optional[str] = typer.Option(None, "--description", "-d", help="New description"),
1155
+ notes: Optional[str] = typer.Option(None, "--notes", "-n", help="New notes"),
1156
+ output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
1157
+ ) -> None:
1158
+ """Update an existing File type secret."""
1159
+ import os
1160
+
1161
+ try:
1162
+ file_content = None
1163
+ file_name = None
1164
+
1165
+ if file:
1166
+ if not os.path.exists(file):
1167
+ console.print(f"[red]Error:[/red] File not found: {file}")
1168
+ raise typer.Exit(1)
1169
+
1170
+ file_size = os.path.getsize(file)
1171
+ if file_size > 5 * 1024 * 1024:
1172
+ console.print(f"[red]Error:[/red] File too large ({file_size} bytes). Max size is 5MB.")
1173
+ raise typer.Exit(1)
1174
+
1175
+ with open(file, "rb") as f:
1176
+ file_content = f.read()
1177
+ file_name = os.path.basename(file)
1178
+
1179
+ with get_client() as client:
1180
+ client.authenticate()
1181
+ secret = client.update_file_secret(
1182
+ secret_id=secret_id,
1183
+ file_content=file_content,
1184
+ file_name=file_name,
1185
+ title=title,
1186
+ description=description,
1187
+ notes=notes,
1188
+ )
1189
+
1190
+ if output == "json":
1191
+ console.print_json(json.dumps(secret, default=str))
1192
+ else:
1193
+ console.print(f"[green]Updated file secret:[/green] {secret.get('Title', 'Unknown')}")
1194
+ console.print(f" ID: {secret_id}")
1195
+
1196
+ except httpx.HTTPStatusError as e:
1197
+ print_api_error(e, "manage secrets")
1198
+ raise typer.Exit(1)
1199
+ except httpx.RequestError as e:
1200
+ print_api_error(e, "manage secrets")
1201
+ raise typer.Exit(1)
1202
+ except Exception as e:
1203
+ print_api_error(e, "manage secrets")
1204
+ raise typer.Exit(1)
1205
+
1206
+
1207
+ @secrets_app.command("move")
1208
+ def move_secret(
1209
+ secret_id: str = typer.Argument(..., help="Secret ID (GUID) to move"),
1210
+ folder: str = typer.Option(..., "--folder", "-f", help="Target folder ID (GUID)"),
1211
+ ) -> None:
1212
+ """Move a secret to a different folder."""
1213
+ try:
1214
+ with get_client() as client:
1215
+ client.authenticate()
1216
+ result = client.move_secret(secret_id, folder)
1217
+
1218
+ console.print(f"[green]Moved secret:[/green] {secret_id}")
1219
+ console.print(f" To folder: {folder}")
1220
+
1221
+ except httpx.HTTPStatusError as e:
1222
+ print_api_error(e, "manage secrets")
1223
+ raise typer.Exit(1)
1224
+ except httpx.RequestError as e:
1225
+ print_api_error(e, "manage secrets")
1226
+ raise typer.Exit(1)
1227
+ except Exception as e:
1228
+ print_api_error(e, "manage secrets")
1229
+ raise typer.Exit(1)
1230
+
1231
+
1232
+ # =============================================================================
1233
+ # Shares Commands
1234
+ # =============================================================================
1235
+
1236
+
1237
+ @secrets_app.command("shares")
1238
+ def list_shares(
1239
+ secret_id: str = typer.Argument(..., help="Secret ID (GUID)"),
1240
+ output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
1241
+ ) -> None:
1242
+ """List all shares of a secret."""
1243
+ try:
1244
+ with get_client() as client:
1245
+ client.authenticate()
1246
+ shares = client.list_shares(secret_id)
1247
+
1248
+ if output == "json":
1249
+ console.print_json(json.dumps(shares, default=str))
1250
+ else:
1251
+ if shares:
1252
+ table = Table(title="Secret Shares")
1253
+ table.add_column("Share ID", style="cyan")
1254
+ table.add_column("Folder ID", style="green")
1255
+ table.add_column("Folder", style="yellow")
1256
+
1257
+ for share in shares:
1258
+ table.add_row(
1259
+ share.get("Id", "")[:36],
1260
+ share.get("FolderId", "")[:36],
1261
+ share.get("FolderName", "-"),
1262
+ )
1263
+ console.print(table)
1264
+ else:
1265
+ console.print("[yellow]No shares found for this secret.[/yellow]")
1266
+
1267
+ except httpx.HTTPStatusError as e:
1268
+ print_api_error(e, "manage secrets")
1269
+ raise typer.Exit(1)
1270
+ except httpx.RequestError as e:
1271
+ print_api_error(e, "manage secrets")
1272
+ raise typer.Exit(1)
1273
+ except Exception as e:
1274
+ print_api_error(e, "manage secrets")
1275
+ raise typer.Exit(1)
1276
+
1277
+
1278
+ @secrets_app.command("share")
1279
+ def share_secret(
1280
+ secret_id: str = typer.Argument(..., help="Secret ID (GUID) to share"),
1281
+ folder: str = typer.Option(..., "--folder", "-f", help="Target folder ID (GUID) to share to"),
1282
+ ) -> None:
1283
+ """Share a secret to another folder."""
1284
+ try:
1285
+ with get_client() as client:
1286
+ client.authenticate()
1287
+ result = client.share_secret(secret_id, folder)
1288
+
1289
+ console.print(f"[green]Shared secret:[/green] {secret_id}")
1290
+ console.print(f" To folder: {folder}")
1291
+ if result.get("Id"):
1292
+ console.print(f" Share ID: {result.get('Id')}")
1293
+
1294
+ except httpx.HTTPStatusError as e:
1295
+ print_api_error(e, "manage secrets")
1296
+ raise typer.Exit(1)
1297
+ except httpx.RequestError as e:
1298
+ print_api_error(e, "manage secrets")
1299
+ raise typer.Exit(1)
1300
+ except Exception as e:
1301
+ print_api_error(e, "manage secrets")
1302
+ raise typer.Exit(1)
1303
+
1304
+
1305
+ @secrets_app.command("unshare")
1306
+ def unshare_secret(
1307
+ secret_id: str = typer.Argument(..., help="Secret ID (GUID)"),
1308
+ share_id: Optional[str] = typer.Option(None, "--share-id", "-s", help="Specific share ID to remove"),
1309
+ all_shares: bool = typer.Option(False, "--all", "-a", help="Remove all shares"),
1310
+ force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
1311
+ ) -> None:
1312
+ """Remove a share from a secret."""
1313
+ try:
1314
+ with get_client() as client:
1315
+ client.authenticate()
1316
+
1317
+ if all_shares:
1318
+ if not force:
1319
+ confirm = typer.confirm(
1320
+ f"Are you sure you want to remove ALL shares from secret {secret_id}?"
1321
+ )
1322
+ if not confirm:
1323
+ console.print("[yellow]Cancelled.[/yellow]")
1324
+ raise typer.Exit(0)
1325
+
1326
+ client.delete_all_shares(secret_id)
1327
+ console.print(f"[green]Removed all shares from secret:[/green] {secret_id}")
1328
+ elif share_id:
1329
+ client.delete_share(secret_id, share_id)
1330
+ console.print(f"[green]Removed share:[/green] {share_id}")
1331
+ else:
1332
+ console.print("[red]Error:[/red] Either --share-id or --all is required")
1333
+ raise typer.Exit(1)
1334
+
1335
+ except httpx.HTTPStatusError as e:
1336
+ print_api_error(e, "manage secrets")
1337
+ raise typer.Exit(1)
1338
+ except httpx.RequestError as e:
1339
+ print_api_error(e, "manage secrets")
1340
+ raise typer.Exit(1)
1341
+ except Exception as e:
1342
+ print_api_error(e, "manage secrets")
1343
+ raise typer.Exit(1)