bt-cli 0.4.13__py3-none-any.whl → 0.4.21__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.
bt_cli/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """BeyondTrust Unified Admin CLI."""
2
2
 
3
- __version__ = "0.4.13"
3
+ __version__ = "0.4.21"
bt_cli/cli.py CHANGED
@@ -3,6 +3,14 @@
3
3
  import sys
4
4
  from typing import Optional
5
5
 
6
+ # Use OS certificate store instead of bundled certifi (for enterprise CAs)
7
+ # This must be done before any SSL connections are made
8
+ try:
9
+ import truststore
10
+ truststore.inject_into_ssl()
11
+ except ImportError:
12
+ pass # Python < 3.10 or truststore not available
13
+
6
14
  import typer
7
15
 
8
16
  # Check rich version early to give helpful error
@@ -131,6 +139,13 @@ except Exception:
131
139
  pass # Quick module not ready yet
132
140
 
133
141
 
142
+ def _version_callback(value: bool) -> None:
143
+ """Print version and exit."""
144
+ if value:
145
+ print(f"bt-cli version {__version__}")
146
+ raise typer.Exit()
147
+
148
+
134
149
  @app.callback()
135
150
  def main_callback(
136
151
  profile: Optional[str] = typer.Option(
@@ -145,6 +160,13 @@ def main_callback(
145
160
  help="Show REST API calls (method, URL, headers, body)",
146
161
  envvar="BT_SHOW_REST",
147
162
  ),
163
+ version: bool = typer.Option(
164
+ False,
165
+ "--version", "-V",
166
+ help="Show version and exit",
167
+ callback=_version_callback,
168
+ is_eager=True,
169
+ ),
148
170
  ) -> None:
149
171
  """BeyondTrust Platform CLI.
150
172
 
@@ -179,8 +179,8 @@ def _configure_interactive(product: Optional[str] = None, profile: Optional[str]
179
179
  default=str(default) if default else ""
180
180
  )
181
181
 
182
- # Skip empty optional fields
183
- if not value and not field_info.get("required"):
182
+ # Skip empty optional fields (but keep False booleans - they're valid)
183
+ if value is None or (value == "" and not field_info.get("required")):
184
184
  continue
185
185
 
186
186
  # Store secrets in keyring if enabled
@@ -285,7 +285,7 @@ def _test_connection(product: str, profile: str) -> None:
285
285
  elif product == "epmw":
286
286
  from ..epmw.client import get_client
287
287
  with get_client() as client:
288
- client.authenticate()
288
+ # EPMW auto-authenticates on first request via OAuth
289
289
  client.get("/Computers", params={"pageSize": 1})
290
290
  print_success("EPM Windows connection successful!")
291
291
 
@@ -205,6 +205,7 @@ def save_config_file(config: ConfigFile, path: Optional[Path] = None) -> None:
205
205
  # This prevents TOCTOU race where file could be readable between
206
206
  # creation and chmod.
207
207
  import os
208
+ import sys
208
209
  import tempfile
209
210
 
210
211
  # Write to temp file in same directory, then atomic rename
@@ -213,12 +214,22 @@ def save_config_file(config: ConfigFile, path: Optional[Path] = None) -> None:
213
214
  # Create temp file with secure permissions
214
215
  fd, tmp_path = tempfile.mkstemp(dir=dir_path, prefix=".config_", suffix=".tmp")
215
216
  try:
216
- # Set permissions on file descriptor before writing
217
- os.fchmod(fd, 0o600)
217
+ # Set permissions on file descriptor before writing (POSIX only)
218
+ # Windows doesn't support fchmod - permissions work differently there
219
+ if sys.platform != "win32" and hasattr(os, "fchmod"):
220
+ os.fchmod(fd, 0o600)
218
221
  with os.fdopen(fd, "w") as f:
219
222
  yaml.dump(data, f, default_flow_style=False, sort_keys=False)
220
- # Atomic rename
223
+ # Atomic rename (on Windows, need to remove target first if exists)
224
+ if sys.platform == "win32" and path.exists():
225
+ os.unlink(path)
221
226
  os.rename(tmp_path, path)
227
+ # On Windows, set permissions after the fact using chmod
228
+ if sys.platform == "win32":
229
+ try:
230
+ os.chmod(path, 0o600)
231
+ except OSError:
232
+ pass # Best effort on Windows
222
233
  except Exception:
223
234
  # Clean up temp file on error
224
235
  try:
@@ -14,6 +14,40 @@ description: Entitle commands for JIT access, bundles, workflows, and permission
14
14
 
15
15
  List affected resources first, then ask for explicit confirmation.
16
16
 
17
+ ## Performance Tips
18
+
19
+ **ALWAYS use server-side filters** - never download all data and filter locally.
20
+
21
+ ```bash
22
+ # ✓ FAST - Server-side filtering
23
+ bt entitle permissions list --resource <resource_id>
24
+ bt entitle permissions list --user <user_id>
25
+ bt entitle permissions list --integration <integration_id>
26
+
27
+ # ✗ SLOW - Downloads ALL 23k+ permissions, filters locally
28
+ bt entitle permissions list | jq 'select(...)'
29
+ bt entitle permissions list -o json | grep "something"
30
+ ```
31
+
32
+ **Available filters for permissions list:**
33
+
34
+ | Flag | Purpose |
35
+ |------|---------|
36
+ | `--resource -r` | Filter by resource ID |
37
+ | `--user -u` | Filter by user ID |
38
+ | `--integration -i` | Filter by integration ID |
39
+
40
+ **Dataset size warning:** Entitle can have 20,000+ permissions. Unfiltered queries will be very slow.
41
+
42
+ **Workflow - Check standing access for a resource:**
43
+ ```bash
44
+ # 1. Find resource ID
45
+ bt entitle resources list --integration <integration_id> | grep -i "admin"
46
+
47
+ # 2. Query permissions with resource filter (fast)
48
+ bt entitle permissions list --resource <resource_id> -o json | jq -r '.[] | "\(.actor.name) | \(.actor.email)"'
49
+ ```
50
+
17
51
  ## Integrations & Resources
18
52
 
19
53
  ```bash
@@ -351,10 +351,10 @@ class EntitleClient:
351
351
  # =========================================================================
352
352
 
353
353
  def list_users(
354
- self, search: Optional[str] = None, limit: int = 100
354
+ self, search: Optional[str] = None, limit: int = 100, max_pages: Optional[int] = None
355
355
  ) -> list[dict[str, Any]]:
356
- """List all users."""
357
- return self.paginate("/users", {"search": search}, page_size=limit)
356
+ """List users with optional pagination limit."""
357
+ return self.paginate("/users", {"search": search}, page_size=limit, max_pages=max_pages)
358
358
 
359
359
  def get_user(self, user_id: str) -> dict[str, Any]:
360
360
  """Get a user by ID."""
@@ -14,7 +14,8 @@ app = typer.Typer(no_args_is_help=True, help="Manage users")
14
14
  @app.command("list")
15
15
  def list_users(
16
16
  search: Optional[str] = typer.Option(None, "--search", "-s", help="Search by email or name"),
17
- limit: int = typer.Option(100, "--limit", "-l", help="Maximum results to return"),
17
+ limit: int = typer.Option(50, "--limit", "-l", help="Results per page"),
18
+ fetch_all: bool = typer.Option(False, "--all", help="Fetch all results (may be slow for large datasets)"),
18
19
  output: str = typer.Option("table", "--output", "-o", help="Output format: table, json"),
19
20
  ) -> None:
20
21
  """List users in Entitle.
@@ -22,11 +23,14 @@ def list_users(
22
23
  Examples:
23
24
  bt entitle users list
24
25
  bt entitle users list -s "john"
25
- bt entitle users list -l 50 -o json
26
+ bt entitle users list -l 100 --all
27
+ bt entitle users list -o json
26
28
  """
27
29
  try:
28
30
  with get_client() as client:
29
- data = client.list_users(search=search, limit=limit)
31
+ # Only fetch first page by default, use --all for everything
32
+ max_pages = None if fetch_all else 1
33
+ data = client.list_users(search=search, limit=limit, max_pages=max_pages)
30
34
 
31
35
  if output == "json":
32
36
  print_json(data)
@@ -288,15 +288,29 @@ def revert_policy(
288
288
  @app.command("download")
289
289
  def download_policy(
290
290
  policy_id: str = typer.Argument(..., help="Policy ID (UUID)"),
291
+ file: Optional[str] = typer.Option(None, "--file", "-f", help="Save to file instead of stdout"),
291
292
  ):
292
- """Download policy content (XML format)."""
293
+ """Download policy content (XML format).
294
+
295
+ Examples:
296
+ bt epmw policies download <policy_id>
297
+ bt epmw policies download <policy_id> --file policy.xml
298
+ bt epmw policies download <policy_id> > policy.xml
299
+ """
293
300
  from bt_cli.epmw.client import get_client
294
301
 
295
302
  try:
296
303
  client = get_client()
297
304
  content = client.download_policy(policy_id)
298
- # Policy content is XML
299
- typer.echo(content)
305
+
306
+ if file:
307
+ # Write to file
308
+ with open(file, "w", encoding="utf-8") as f:
309
+ f.write(content)
310
+ print_success(f"Policy saved to: {file}")
311
+ else:
312
+ # Output to stdout
313
+ typer.echo(content)
300
314
  except httpx.HTTPStatusError as e:
301
315
  print_api_error(e, "download policy")
302
316
  raise typer.Exit(1)
@@ -16,10 +16,10 @@ import_app = typer.Typer(no_args_is_help=True, help="Import resources from CSV f
16
16
  export_app = typer.Typer(no_args_is_help=True, help="Export sample CSV templates")
17
17
  console = Console()
18
18
 
19
- # Column definitions for CSV formats
19
+ # Column definitions for CSV formats - using NAMES not IDs for readability
20
20
  SYSTEMS_COLUMNS = [
21
- "name", "ip_address", "workgroup_id", "platform_id", "port",
22
- "functional_account_id", "elevation_command", "auto_manage",
21
+ "system_name", "ip_address", "workgroup", "platform", "port",
22
+ "functional_account", "elevation_command", "auto_manage",
23
23
  "account_name", "account_password", "account_description"
24
24
  ]
25
25
 
@@ -27,31 +27,36 @@ SECRETS_COLUMNS = [
27
27
  "folder_path", "title", "username", "password", "description", "notes"
28
28
  ]
29
29
 
30
- # Sample data for templates
30
+ # Sample data for templates - uses names that will be resolved to IDs
31
+ # NOTE: account_password only required if auto_manage=false
32
+ # If auto_manage=true with functional_account, PWS rotates password automatically
31
33
  SYSTEMS_SAMPLE = [
32
34
  {
33
- "name": "web-server-01", "ip_address": "10.0.1.50", "workgroup_id": "3",
34
- "platform_id": "2", "port": "22", "functional_account_id": "7",
35
+ "system_name": "linux-managed-01", "ip_address": "10.0.1.50",
36
+ "workgroup": "Default", # Use workgroup NAME (run: bt pws workgroups list)
37
+ "platform": "Linux SSH", # Use platform NAME (run: bt pws platforms list)
38
+ "port": "22",
39
+ "functional_account": "Linux - Local", # FA DisplayName (run: bt pws functional list)
35
40
  "elevation_command": "sudo", "auto_manage": "true",
36
- "account_name": "root", "account_password": "", "account_description": "Root account"
41
+ "account_name": "root", "account_password": "", "account_description": "Auto-managed root"
37
42
  },
38
43
  {
39
- "name": "web-server-01", "ip_address": "10.0.1.50", "workgroup_id": "3",
40
- "platform_id": "2", "port": "22", "functional_account_id": "7",
41
- "elevation_command": "sudo", "auto_manage": "true",
42
- "account_name": "svc-backup", "account_password": "Backup#2026!", "account_description": "Backup service"
44
+ "system_name": "linux-managed-01", "ip_address": "10.0.1.50",
45
+ "workgroup": "Default", "platform": "Linux SSH", "port": "22",
46
+ "functional_account": "Linux - Local", "elevation_command": "sudo", "auto_manage": "true",
47
+ "account_name": "appuser", "account_password": "", "account_description": "Auto-managed app account"
43
48
  },
44
49
  {
45
- "name": "db-server-01", "ip_address": "10.0.1.60", "workgroup_id": "3",
46
- "platform_id": "2", "port": "22", "functional_account_id": "7",
47
- "elevation_command": "sudo", "auto_manage": "true",
48
- "account_name": "postgres", "account_password": "", "account_description": "Database admin"
50
+ "system_name": "linux-manual-01", "ip_address": "10.0.1.60",
51
+ "workgroup": "Default", "platform": "Linux SSH", "port": "22",
52
+ "functional_account": "", "elevation_command": "", "auto_manage": "false",
53
+ "account_name": "dbadmin", "account_password": "InitialP@ss123!", "account_description": "Manual - password required"
49
54
  },
50
55
  {
51
- "name": "win-server-01", "ip_address": "10.0.2.10", "workgroup_id": "2",
52
- "platform_id": "1", "port": "5985", "functional_account_id": "",
53
- "elevation_command": "", "auto_manage": "false",
54
- "account_name": "Administrator", "account_password": "InitialPass123!", "account_description": "Local admin"
56
+ "system_name": "win-manual-01", "ip_address": "10.0.2.10",
57
+ "workgroup": "Default", "platform": "Windows", "port": "5985",
58
+ "functional_account": "", "elevation_command": "", "auto_manage": "false",
59
+ "account_name": "Administrator", "account_password": "WinP@ss456!", "account_description": "Manual - password required"
55
60
  },
56
61
  ]
57
62
 
@@ -74,25 +79,89 @@ SECRETS_SAMPLE = [
74
79
  ]
75
80
 
76
81
 
82
+ def _resolve_name_to_id(name: str, lookup: dict, resource_type: str) -> Optional[int]:
83
+ """Resolve a name to ID using case-insensitive partial matching."""
84
+ if not name:
85
+ return None
86
+
87
+ # Try exact match first (case-insensitive)
88
+ name_lower = name.lower().strip()
89
+ for key, value in lookup.items():
90
+ if key.lower() == name_lower:
91
+ return value
92
+
93
+ # Try partial match (name contains search term)
94
+ for key, value in lookup.items():
95
+ if name_lower in key.lower():
96
+ return value
97
+
98
+ return None
99
+
100
+
101
+ def _build_folder_path_map(safes: list, folders: list) -> dict[str, str]:
102
+ """Build a map of folder paths to folder IDs.
103
+
104
+ Safes are top-level containers, folders can be nested.
105
+ Returns a dict like {"SafeName/FolderName": "folder-guid", ...}
106
+ """
107
+ # Build ID -> object lookups
108
+ safe_by_id = {s.get("Id"): s for s in safes}
109
+ folder_by_id = {f.get("Id"): f for f in folders}
110
+
111
+ # Build path for each folder
112
+ path_map: dict[str, str] = {}
113
+
114
+ def get_path(folder_id: str) -> str:
115
+ """Recursively build path for a folder."""
116
+ if folder_id in safe_by_id:
117
+ # It's a safe (top-level)
118
+ return safe_by_id[folder_id].get("Name", "")
119
+
120
+ folder = folder_by_id.get(folder_id)
121
+ if not folder:
122
+ return ""
123
+
124
+ parent_id = folder.get("ParentId")
125
+ folder_name = folder.get("Name", "")
126
+
127
+ if parent_id:
128
+ parent_path = get_path(parent_id)
129
+ if parent_path:
130
+ return f"{parent_path}/{folder_name}"
131
+
132
+ return folder_name
133
+
134
+ # Map all folders
135
+ for folder in folders:
136
+ folder_id = folder.get("Id")
137
+ path = get_path(folder_id)
138
+ if path:
139
+ path_map[path.lower()] = folder_id
140
+
141
+ return path_map
142
+
143
+
77
144
  @import_app.command("systems")
78
145
  def import_systems(
79
146
  file: str = typer.Option(..., "--file", "-f", help="CSV file path"),
80
147
  dry_run: bool = typer.Option(False, "--dry-run", help="Validate without creating"),
81
- workgroup: Optional[int] = typer.Option(None, "--workgroup", "-w", help="Override workgroup ID for all rows"),
148
+ workgroup_override: Optional[str] = typer.Option(None, "--workgroup", "-w", help="Override workgroup for all rows (name or ID)"),
82
149
  ) -> None:
83
150
  """Import managed systems and accounts from CSV.
84
151
 
85
152
  Each row creates a system + account. Multiple accounts per system use
86
153
  multiple rows with the same system name (system is created only once).
87
154
 
88
- Required columns: name, ip_address, workgroup_id, account_name
89
- Optional: platform_id, port, functional_account_id, elevation_command,
155
+ Uses NAMES for workgroup, platform, functional_account - resolved to IDs automatically.
156
+
157
+ Required columns: system_name, ip_address, workgroup, account_name
158
+ Optional: platform, port, functional_account, elevation_command,
90
159
  auto_manage, account_password, account_description
91
160
 
92
161
  Examples:
93
162
  bt pws import systems --file systems.csv --dry-run
94
163
  bt pws import systems --file systems.csv
95
- bt pws import systems --file systems.csv --workgroup 3
164
+ bt pws import systems --file systems.csv --workgroup "Default"
96
165
  """
97
166
  try:
98
167
  rows = read_csv(file)
@@ -102,73 +171,148 @@ def import_systems(
102
171
 
103
172
  console.print(f"[dim]Read {len(rows)} rows from {file}[/dim]")
104
173
 
105
- # Validate all rows first
106
- errors = []
107
- required = ["name", "ip_address", "account_name"]
108
- if not workgroup:
109
- required.append("workgroup_id")
110
-
111
- for i, row in enumerate(rows, 1):
112
- row_errors = validate_required_fields(row, required, i)
113
- errors.extend(row_errors)
114
-
115
- if errors:
116
- print_error("Validation errors:")
117
- for err in errors[:20]:
118
- console.print(f" [red]{err}[/red]")
119
- if len(errors) > 20:
120
- console.print(f" [red]... and {len(errors) - 20} more errors[/red]")
121
- raise typer.Exit(1)
122
-
123
- # Group rows by system name
124
- systems_rows: dict[str, list[dict]] = {}
125
- for row in rows:
126
- name = row["name"].strip()
127
- if name not in systems_rows:
128
- systems_rows[name] = []
129
- systems_rows[name].append(row)
130
-
131
- console.print(f"[dim]Found {len(systems_rows)} unique systems with {len(rows)} total accounts[/dim]")
132
-
133
- if dry_run:
134
- console.print("\n[yellow]DRY RUN - No changes will be made[/yellow]\n")
135
- table = Table(title="Systems to Create")
136
- table.add_column("System", style="green")
137
- table.add_column("IP", style="cyan")
138
- table.add_column("Workgroup", style="yellow")
139
- table.add_column("Platform", style="magenta")
140
- table.add_column("Accounts", style="blue")
141
-
142
- for name, sys_rows in systems_rows.items():
143
- first = sys_rows[0]
144
- wg = workgroup or parse_int(first.get("workgroup_id", ""))
145
- accounts = ", ".join(r["account_name"] for r in sys_rows)
146
- table.add_row(
147
- name,
148
- first.get("ip_address", ""),
149
- str(wg),
150
- first.get("platform_id", "2"),
151
- accounts
152
- )
153
-
154
- console.print(table)
155
- console.print(f"\n[dim]Would create {len(systems_rows)} systems with {len(rows)} accounts[/dim]")
156
- return
157
-
158
- # Actually import
174
+ # Connect and build lookup tables for name resolution
159
175
  with get_client() as client:
160
176
  client.authenticate()
161
177
 
178
+ console.print("[dim]Loading workgroups, platforms, functional accounts...[/dim]")
179
+
180
+ # Build name -> ID lookup tables
181
+ workgroups = client.list_workgroups()
182
+ workgroup_lookup = {w.get("Name", ""): w.get("ID") for w in workgroups}
183
+
184
+ platforms = client.list_platforms()
185
+ platform_lookup = {p.get("Name", ""): p.get("PlatformID") for p in platforms}
186
+
187
+ func_accounts = client.list_functional_accounts()
188
+ # Use DisplayName as the primary lookup key, also add AccountName as fallback
189
+ func_acct_lookup = {}
190
+ for f in func_accounts:
191
+ fa_id = f.get("FunctionalAccountID")
192
+ # Primary: DisplayName (e.g., "Linux - Local")
193
+ if f.get("DisplayName"):
194
+ func_acct_lookup[f["DisplayName"]] = fa_id
195
+ # Fallback: AccountName (e.g., "svc_passwordsafe")
196
+ if f.get("AccountName"):
197
+ func_acct_lookup[f["AccountName"]] = fa_id
198
+
199
+ # Resolve workgroup override
200
+ wg_override_id = None
201
+ if workgroup_override:
202
+ # Try as ID first
203
+ try:
204
+ wg_override_id = int(workgroup_override)
205
+ except ValueError:
206
+ wg_override_id = _resolve_name_to_id(workgroup_override, workgroup_lookup, "workgroup")
207
+ if not wg_override_id:
208
+ print_error(f"Workgroup not found: {workgroup_override}")
209
+ console.print("[dim]Available workgroups:[/dim]")
210
+ for name in workgroup_lookup.keys():
211
+ console.print(f" - {name}")
212
+ raise typer.Exit(1)
213
+
214
+ # Validate all rows first
215
+ errors = []
216
+ # Support both old column names (backward compat) and new names
217
+ name_col = "system_name" if "system_name" in rows[0] else "name"
218
+ wg_col = "workgroup" if "workgroup" in rows[0] else "workgroup_id"
219
+
220
+ required = [name_col, "ip_address", "account_name"]
221
+ if not wg_override_id:
222
+ required.append(wg_col)
223
+
224
+ for i, row in enumerate(rows, 1):
225
+ row_errors = validate_required_fields(row, required, i)
226
+ errors.extend(row_errors)
227
+
228
+ if errors:
229
+ print_error("Validation errors:")
230
+ for err in errors[:20]:
231
+ console.print(f" [red]{err}[/red]")
232
+ if len(errors) > 20:
233
+ console.print(f" [red]... and {len(errors) - 20} more errors[/red]")
234
+ raise typer.Exit(1)
235
+
236
+ # Group rows by system name
237
+ systems_rows: dict[str, list[dict]] = {}
238
+ for row in rows:
239
+ name = row.get(name_col, "").strip()
240
+ if name not in systems_rows:
241
+ systems_rows[name] = []
242
+ systems_rows[name].append(row)
243
+
244
+ console.print(f"[dim]Found {len(systems_rows)} unique systems with {len(rows)} total accounts[/dim]")
245
+
246
+ if dry_run:
247
+ console.print("\n[yellow]DRY RUN - No changes will be made[/yellow]\n")
248
+ table = Table(title="Systems to Create")
249
+ table.add_column("System", style="green")
250
+ table.add_column("IP", style="cyan")
251
+ table.add_column("Workgroup", style="yellow")
252
+ table.add_column("Platform", style="magenta")
253
+ table.add_column("Accounts", style="blue")
254
+
255
+ for name, sys_rows in systems_rows.items():
256
+ first = sys_rows[0]
257
+ wg_name = first.get(wg_col, "")
258
+ plat_name = first.get("platform", first.get("platform_id", "Linux"))
259
+ accounts = ", ".join(r["account_name"] for r in sys_rows)
260
+ table.add_row(name, first.get("ip_address", ""), wg_name, plat_name, accounts)
261
+
262
+ console.print(table)
263
+ console.print(f"\n[dim]Would create {len(systems_rows)} systems with {len(rows)} accounts[/dim]")
264
+ return
265
+
266
+ # Actually import
162
267
  created_systems = 0
163
268
  created_accounts = 0
164
- system_ids: dict[str, int] = {} # Map system name to ID
269
+ system_ids: dict[str, int] = {}
165
270
 
166
271
  for name, sys_rows in systems_rows.items():
167
272
  first = sys_rows[0]
168
- wg_id = workgroup or parse_int(first.get("workgroup_id", ""))
273
+
274
+ # Resolve workgroup
275
+ if wg_override_id:
276
+ wg_id = wg_override_id
277
+ else:
278
+ wg_val = first.get(wg_col, "").strip()
279
+ try:
280
+ wg_id = int(wg_val)
281
+ except ValueError:
282
+ wg_id = _resolve_name_to_id(wg_val, workgroup_lookup, "workgroup")
169
283
 
170
284
  if not wg_id:
171
- print_warning(f"Skipping {name}: No workgroup ID")
285
+ print_warning(f"Skipping {name}: Could not resolve workgroup '{first.get(wg_col, '')}'")
286
+ continue
287
+
288
+ # Resolve platform
289
+ plat_val = first.get("platform", first.get("platform_id", "")).strip()
290
+ try:
291
+ platform_id = int(plat_val) if plat_val else 2
292
+ except ValueError:
293
+ platform_id = _resolve_name_to_id(plat_val, platform_lookup, "platform") or 2
294
+
295
+ # Resolve functional account
296
+ func_val = first.get("functional_account", first.get("functional_account_id", "")).strip()
297
+ func_acct_id = None
298
+ if func_val:
299
+ try:
300
+ func_acct_id = int(func_val)
301
+ except ValueError:
302
+ func_acct_id = _resolve_name_to_id(func_val, func_acct_lookup, "functional account")
303
+
304
+ # Get auto_manage flag early for validation
305
+ auto_manage = parse_bool(first.get("auto_manage", ""))
306
+
307
+ # Validate auto_manage + functional account combination
308
+ if auto_manage and func_val and not func_acct_id:
309
+ print_warning(f"Skipping {name}: auto_manage=true but functional account '{func_val}' not found")
310
+ console.print("[dim]Available functional accounts:[/dim]")
311
+ for fa_name in func_acct_lookup.keys():
312
+ console.print(f" - {fa_name}")
313
+ continue
314
+ if auto_manage and not func_val:
315
+ print_warning(f"Skipping {name}: auto_manage=true requires a functional_account")
172
316
  continue
173
317
 
174
318
  try:
@@ -183,10 +327,7 @@ def import_systems(
183
327
 
184
328
  # Create managed system
185
329
  console.print(f"[dim]Creating managed system '{name}'...[/dim]")
186
- platform_id = parse_int(first.get("platform_id", ""), 2)
187
330
  port = parse_int(first.get("port", ""), 22)
188
- func_acct = parse_int(first.get("functional_account_id", ""))
189
- auto_manage = parse_bool(first.get("auto_manage", ""))
190
331
  elevation = first.get("elevation_command", "").strip() or None
191
332
 
192
333
  system = client.create_managed_system(
@@ -194,8 +335,8 @@ def import_systems(
194
335
  platform_id=platform_id,
195
336
  asset_id=asset_id,
196
337
  port=port,
197
- functional_account_id=func_acct,
198
- auto_management_flag=auto_manage if func_acct else False,
338
+ functional_account_id=func_acct_id,
339
+ auto_management_flag=auto_manage if func_acct_id else False,
199
340
  elevation_command=elevation,
200
341
  )
201
342
  system_id = system.get("ManagedSystemID")
@@ -214,7 +355,7 @@ def import_systems(
214
355
  system_id=system_id,
215
356
  account_name=account_name,
216
357
  password=password,
217
- auto_management_flag=auto_manage if func_acct else False,
358
+ auto_management_flag=auto_manage if func_acct_id else False,
218
359
  )
219
360
  account_id = account.get("ManagedAccountID")
220
361
  created_accounts += 1
@@ -301,13 +442,11 @@ def import_secrets(
301
442
  with get_client() as client:
302
443
  client.authenticate()
303
444
 
304
- # Get all folders to map paths to IDs
445
+ # Build folder path -> ID map from safes and folders
446
+ console.print("[dim]Loading safes and folders...[/dim]")
447
+ safes = client.list_safes()
305
448
  all_folders = client.list_folders()
306
- folder_map: dict[str, int] = {}
307
- for f in all_folders:
308
- path = f.get("FolderPath", "") or f.get("Name", "")
309
- if path:
310
- folder_map[path.lower()] = f.get("Id")
449
+ folder_map = _build_folder_path_map(safes, all_folders)
311
450
 
312
451
  created = 0
313
452
  for row in rows:
@@ -318,6 +457,11 @@ def import_secrets(
318
457
  folder_id = folder_map.get(folder_path.lower())
319
458
  if not folder_id:
320
459
  print_warning(f"Folder not found: {folder_path}, skipping {title}")
460
+ console.print("[dim]Available folder paths:[/dim]")
461
+ for path in sorted(folder_map.keys())[:10]:
462
+ console.print(f" - {path}")
463
+ if len(folder_map) > 10:
464
+ console.print(f" ... and {len(folder_map) - 10} more")
321
465
  continue
322
466
 
323
467
  try:
@@ -355,22 +499,46 @@ def export_systems(
355
499
  file: str = typer.Option("pws-systems-template.csv", "--file", "-f", help="Output file path"),
356
500
  sample: bool = typer.Option(True, "--sample/--no-sample", help="Export sample template (default) or current data"),
357
501
  ) -> None:
358
- """Export sample systems CSV template.
502
+ """Export systems CSV template or current data.
503
+
504
+ Uses human-readable NAMES for workgroup, platform, functional_account.
359
505
 
360
506
  Examples:
361
507
  bt pws export systems --file systems-template.csv
362
- bt pws export systems --file systems-template.csv --sample
508
+ bt pws export systems --file current-systems.csv --no-sample
363
509
  """
364
510
  try:
365
511
  if sample:
366
512
  write_csv(file, SYSTEMS_SAMPLE, SYSTEMS_COLUMNS)
367
513
  print_success(f"Sample systems template exported to: {file}")
368
514
  console.print(f"[dim]Contains {len(SYSTEMS_SAMPLE)} example rows[/dim]")
515
+ console.print("\n[dim]Column descriptions:[/dim]")
516
+ console.print(" system_name - Unique name for the managed system")
517
+ console.print(" ip_address - IP or hostname")
518
+ console.print(" workgroup - Workgroup NAME (run: bt pws workgroups list)")
519
+ console.print(" platform - Platform NAME, partial match OK (run: bt pws platforms list)")
520
+ console.print(" port - Connection port (22 for SSH, 5985 for WinRM)")
521
+ console.print(" functional_account - Functional account NAME for auto-management (bt pws functional list)")
522
+ console.print(" elevation_command - 'sudo' or other elevation command (optional)")
523
+ console.print(" auto_manage - true=PWS rotates password, false=manual management")
524
+ console.print(" account_name - Account username to manage")
525
+ console.print(" account_password - Required if auto_manage=false, empty if auto_manage=true")
526
+ console.print(" account_description- Description for the account")
369
527
  else:
370
- # Export actual data from API
528
+ # Export actual data from API with names resolved
371
529
  with get_client() as client:
372
530
  client.authenticate()
373
531
 
532
+ # Build ID -> name lookup tables
533
+ workgroups = client.list_workgroups()
534
+ wg_names = {w.get("ID"): w.get("Name", "") for w in workgroups}
535
+
536
+ platforms = client.list_platforms()
537
+ plat_names = {p.get("PlatformID"): p.get("Name", "") for p in platforms}
538
+
539
+ func_accounts = client.list_functional_accounts()
540
+ func_names = {f.get("FunctionalAccountID"): f.get("FunctionalAccountName", "") for f in func_accounts}
541
+
374
542
  systems = client.list_managed_systems()
375
543
  rows = []
376
544
 
@@ -378,14 +546,18 @@ def export_systems(
378
546
  system_id = sys.get("ManagedSystemID")
379
547
  accounts = client.list_managed_accounts(system_id=system_id)
380
548
 
549
+ wg_id = sys.get("WorkgroupID")
550
+ plat_id = sys.get("PlatformID")
551
+ func_id = sys.get("FunctionalAccountID")
552
+
381
553
  for acc in accounts:
382
554
  rows.append({
383
- "name": sys.get("SystemName", ""),
555
+ "system_name": sys.get("SystemName", ""),
384
556
  "ip_address": sys.get("IPAddress", ""),
385
- "workgroup_id": str(sys.get("WorkgroupID", "")),
386
- "platform_id": str(sys.get("PlatformID", "")),
557
+ "workgroup": wg_names.get(wg_id, str(wg_id or "")),
558
+ "platform": plat_names.get(plat_id, str(plat_id or "")),
387
559
  "port": str(sys.get("Port", "")),
388
- "functional_account_id": str(sys.get("FunctionalAccountID", "") or ""),
560
+ "functional_account": func_names.get(func_id, "") if func_id else "",
389
561
  "elevation_command": sys.get("ElevationCommand", "") or "",
390
562
  "auto_manage": "true" if sys.get("AutoManagementFlag") else "false",
391
563
  "account_name": acc.get("AccountName", ""),
@@ -419,22 +591,25 @@ def export_secrets(
419
591
  write_csv(file, SECRETS_SAMPLE, SECRETS_COLUMNS)
420
592
  print_success(f"Sample secrets template exported to: {file}")
421
593
  console.print(f"[dim]Contains {len(SECRETS_SAMPLE)} example rows[/dim]")
594
+ console.print("\n[dim]Column descriptions:[/dim]")
595
+ console.print(" folder_path - Full path: SafeName/FolderName (run: bt pws secrets folders list)")
596
+ console.print(" title - Unique title for the secret")
597
+ console.print(" username - Username/identifier (optional)")
598
+ console.print(" password - Password/secret value")
599
+ console.print(" description - Description of the secret")
600
+ console.print(" notes - Additional notes (can be JSON)")
422
601
  else:
423
602
  # Export actual data from API
424
603
  with get_client() as client:
425
604
  client.authenticate()
426
605
 
427
606
  secrets = client.list_secrets()
428
- folders = client.list_folders()
429
-
430
- # Map folder IDs to paths
431
- folder_paths = {f.get("Id"): f.get("FolderPath", "") or f.get("Name", "") for f in folders}
432
607
 
433
608
  rows = []
434
609
  for s in secrets:
435
- folder_id = s.get("FolderId")
610
+ # Secrets have FolderPath directly (e.g., "SafeName/FolderName")
436
611
  rows.append({
437
- "folder_path": folder_paths.get(folder_id, ""),
612
+ "folder_path": s.get("FolderPath", "") or s.get("Folder", ""),
438
613
  "title": s.get("Title", ""),
439
614
  "username": s.get("Username", "") or "",
440
615
  "password": "", # Don't export passwords
@@ -568,7 +568,7 @@ def print_secrets_table(secrets: list[dict], title: str = "Secrets") -> None:
568
568
  table.add_column("ID", style="cyan", no_wrap=True, max_width=36)
569
569
  table.add_column("Title", style="green")
570
570
  table.add_column("Username", style="yellow")
571
- table.add_column("Folder", style="blue")
571
+ table.add_column("Folder Path", style="blue")
572
572
  table.add_column("Modified", style="dim")
573
573
 
574
574
  for secret in secrets:
@@ -576,7 +576,7 @@ def print_secrets_table(secrets: list[dict], title: str = "Secrets") -> None:
576
576
  secret.get("Id", "")[:36],
577
577
  secret.get("Title", ""),
578
578
  secret.get("Username", "-"),
579
- secret.get("Folder", secret.get("FolderPath", "-")),
579
+ secret.get("FolderPath", secret.get("Folder", "-")),
580
580
  str(secret.get("ModifiedOn", "-"))[:19],
581
581
  )
582
582
 
@@ -653,7 +653,7 @@ def get_secret(
653
653
  console.print(f" Password: {secret.get('Password', '-')}")
654
654
  else:
655
655
  console.print(" Password: ********")
656
- console.print(f" Folder: {secret.get('Folder', secret.get('FolderPath', '-'))}")
656
+ console.print(f" Folder Path: {secret.get('FolderPath', secret.get('Folder', '-'))}")
657
657
  console.print(f" Description: {secret.get('Description', '-') or '-'}")
658
658
  if secret.get("Notes"):
659
659
  console.print(f" Notes: {secret.get('Notes')}")
@@ -961,7 +961,7 @@ def get_text_secret(
961
961
  else:
962
962
  console.print(f"\n[bold cyan]Text Secret: {secret.get('Title', 'Unknown')}[/bold cyan]\n")
963
963
  console.print(f" ID: {secret.get('Id', 'N/A')}")
964
- console.print(f" Folder: {secret.get('Folder', secret.get('FolderPath', '-'))}")
964
+ console.print(f" Folder Path: {secret.get('FolderPath', secret.get('Folder', '-'))}")
965
965
  console.print(f" Description: {secret.get('Description', '-') or '-'}")
966
966
  if show_text:
967
967
  console.print(f" Text Content:\n{secret.get('Password', '-')}")
@@ -1065,7 +1065,7 @@ def get_file_secret(
1065
1065
  console.print(f"\n[bold cyan]File Secret: {secret.get('Title', 'Unknown')}[/bold cyan]\n")
1066
1066
  console.print(f" ID: {secret.get('Id', 'N/A')}")
1067
1067
  console.print(f" FileName: {secret.get('FileName', '-')}")
1068
- console.print(f" Folder: {secret.get('Folder', secret.get('FolderPath', '-'))}")
1068
+ console.print(f" Folder Path: {secret.get('FolderPath', secret.get('Folder', '-'))}")
1069
1069
  console.print(f" Description: {secret.get('Description', '-') or '-'}")
1070
1070
  console.print(f" Created: {secret.get('CreatedOn', '-')}")
1071
1071
  console.print(f" Modified: {secret.get('ModifiedOn', '-')}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bt-cli
3
- Version: 0.4.13
3
+ Version: 0.4.21
4
4
  Summary: BeyondTrust Platform CLI (unofficial) - Password Safe, Entitle, PRA, EPM
5
5
  Author-email: Dave Grendysz <dgrendysz@beyondtrust.com>
6
6
  License: MIT
@@ -23,8 +23,9 @@ Requires-Dist: httpx>=0.27.0
23
23
  Requires-Dist: pydantic>=2.0.0
24
24
  Requires-Dist: python-dotenv>=1.0.0
25
25
  Requires-Dist: pyyaml>=6.0.0
26
- Requires-Dist: rich<15.0.0,>=13.7.0
26
+ Requires-Dist: rich<14.0.0,>=13.7.0
27
27
  Requires-Dist: shellingham>=1.5.0
28
+ Requires-Dist: truststore>=0.8.0; python_version >= '3.10'
28
29
  Requires-Dist: typer<1.0.0,>=0.12.0
29
30
  Provides-Extra: all
30
31
  Requires-Dist: keyring>=24.0.0; extra == 'all'
@@ -1,14 +1,14 @@
1
- bt_cli/__init__.py,sha256=2Ec_fVFIfrTcwoI2dlXG3AashEKBkbaNUp9YUdyZiOw,61
2
- bt_cli/cli.py,sha256=-V7Q_DcP7Ryw-mRBLMyj-iwlFpof8_eLgFAx-fN5seM,29640
1
+ bt_cli/__init__.py,sha256=U7QGvJ3oZ-05cWQBNsFcsNb9PBwey_czwwhL4aqBcqo,61
2
+ bt_cli/cli.py,sha256=Fd-p9O2IR-LJfDewkQbLRE6tPe9FVLkGJgUHup2Z08Q,30254
3
3
  bt_cli/commands/__init__.py,sha256=Wrf3ZV1sf7JCilbv93VqoWWTyj0d-y4saAaVFD5apU8,38
4
- bt_cli/commands/configure.py,sha256=f3tn09eRDqlGQIq1gpuxj984S4CARYbmKI4XrqxPAAM,14270
4
+ bt_cli/commands/configure.py,sha256=WElK9grslla0_rqdAmDh0vYpLVH5jP21l1fiYl7vvOw,14364
5
5
  bt_cli/commands/learn.py,sha256=BHt4bTH3orQfR-eB9FMGPQZVfTrwakHNYi0QkHQLOy0,7228
6
6
  bt_cli/commands/quick.py,sha256=cAQfJgkdxJcWZ4OLasmWoXVyiDjydwD6Nv0AyKLqsNc,32806
7
7
  bt_cli/core/__init__.py,sha256=Yv_0gTKQcbEYNbNQ07YiK4sC8ueHktI-D20YlPWlT7g,49
8
8
  bt_cli/core/auth.py,sha256=-vzGCgk2ZOsHExZBcxMHer68K3Bi0RMWF-17wtIZvsg,6723
9
9
  bt_cli/core/client.py,sha256=pGxk5vI-U5_csiwBvMr_YwloOTmmWuFhX9TAA9ynoZw,8808
10
10
  bt_cli/core/config.py,sha256=lQZgNH858HCIuizyDDFU0Bs1VbHjy3vaA2OthVgc7-w,13485
11
- bt_cli/core/config_file.py,sha256=I0RdHJEW4ytc2onNcsTGjCnetSuVCNzKA7-tTTIvU8M,13163
11
+ bt_cli/core/config_file.py,sha256=k7yH-M53fEt9ahQBABWCcd9CBdfwjwGK20chNndF_UQ,13740
12
12
  bt_cli/core/csv_utils.py,sha256=L7i-YGSeQV9RnzanKSQM1yvXgkmaQCpBoxUk9fV6RLg,2412
13
13
  bt_cli/core/errors.py,sha256=-LWipqdn8w-FMAC1W1jFbTMAlooiJqmyuR5j1VWMPyM,8783
14
14
  bt_cli/core/output.py,sha256=ACazDf3XAfJhAlns5fh6bQXkRIIGlBFuq1z2M-IBrwQ,5156
@@ -17,13 +17,13 @@ bt_cli/core/rest_debug.py,sha256=7KsqsrsPXUqaYLePjZbwnoqMcg5ivJgYGS35ygaeLfM,597
17
17
  bt_cli/data/CLAUDE.md,sha256=3Y_afs1RGTKLkn4Vr9wMKiReIr4EEtBdjFrqSs2NymQ,3228
18
18
  bt_cli/data/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
19
19
  bt_cli/data/skills/bt/SKILL.md,sha256=DZTvRtSYjiDxDbg7WTpgklYs5Dn-2Z-9w3X8RQJbGNo,3077
20
- bt_cli/data/skills/entitle/SKILL.md,sha256=yOHP9U_1vKh1yLmrAqKl4rNo16n4f3hia0xj_ZJRRYM,4413
20
+ bt_cli/data/skills/entitle/SKILL.md,sha256=_Koad7n6zTfEafbBiSJgRlEoDjXxKv7mGWAX99_l-d0,5529
21
21
  bt_cli/data/skills/epmw/SKILL.md,sha256=brfIXR9MnB5_FwRa-SyX1TwqVRyefgtr2foQfErpVVA,3541
22
22
  bt_cli/data/skills/pra/SKILL.md,sha256=-LTUJvjvLvZn0rMmy0KOeEBtUsotjbHvXX3RCk7mDBw,3749
23
23
  bt_cli/data/skills/pws/SKILL.md,sha256=lrOIN06hVrbHfph5rdu78W5N4UKzjrjuHxcJG4eK5hA,5401
24
24
  bt_cli/entitle/__init__.py,sha256=l2fasFHO_0zgCS1FgmGdAikumU8QimpaFD9Gvh3Jyqg,30
25
25
  bt_cli/entitle/client/__init__.py,sha256=kIQohXlEFGrRAyVWMO9GdESrnAg5J4dmoyJQ6q9NxSo,114
26
- bt_cli/entitle/client/base.py,sha256=Jov6v7iM8DZ6o_DL7esBpdnYyM-WVFzI8u_0Bjh9MgA,14623
26
+ bt_cli/entitle/client/base.py,sha256=s8odTU0tZ32RLdkiDCVxt-8ctZIDePkPfez-nPvIMvU,14704
27
27
  bt_cli/entitle/commands/__init__.py,sha256=07FdoMmXaJwmgqwPM24Qyzojt4Xcrqmf8KWmNSiGtBg,1009
28
28
  bt_cli/entitle/commands/accounts.py,sha256=26C8NQg9ZajVjwsfpjoZ1ZChy8d8oikL-Ndc2C8g3GA,1872
29
29
  bt_cli/entitle/commands/applications.py,sha256=m2khdc0eUlb2OctjFIB_CtPU0omECjEB4_verrD58s0,1139
@@ -34,7 +34,7 @@ bt_cli/entitle/commands/permissions.py,sha256=GJD-Lv0jCQ6XJknD1KhYcE-kwwFCO_eAK0
34
34
  bt_cli/entitle/commands/policies.py,sha256=H1GF7Q2pvF9ukkpX2mhyq7Mm4w-L2PX33yvOKyBJ-tE,3396
35
35
  bt_cli/entitle/commands/resources.py,sha256=SnIndzmoHt8Sr1N_1FPacHx1laDRpC4LhOHwRSuk-Hk,4791
36
36
  bt_cli/entitle/commands/roles.py,sha256=6Jw0tXO26MclrHeIJ1CFLmGTT9Sj5RhGmbuMPxG2DNU,2320
37
- bt_cli/entitle/commands/users.py,sha256=C7BQ_8X2Hv6c6WfO86BGBdKCf-pU0NCxhaCHJNk5Np0,4152
37
+ bt_cli/entitle/commands/users.py,sha256=Epv_2B0lUdmw1SOki2uGIXT5ln2eso1QAj0vmFy33qA,4433
38
38
  bt_cli/entitle/commands/workflows.py,sha256=voroHo_vLKveXoqOkwSy-8WkALr7hjOY5GvYEA2poDw,6944
39
39
  bt_cli/entitle/models/__init__.py,sha256=ThEQ_r4GbdxGjuzqng-MXms2brxF8XM6o7TS9tmiwDc,620
40
40
  bt_cli/entitle/models/bundle.py,sha256=v5vFZI8baaF01chtc8hEQLKyCRNo_RoHtpz8VDNb8sc,787
@@ -55,7 +55,7 @@ bt_cli/epmw/commands/auth.py,sha256=2oC3h01_eh4XyEvsar_ffJAqGKXGE_PrhHQJWFswlN8,
55
55
  bt_cli/epmw/commands/computers.py,sha256=K0eBd8HOaVCe8hhHPlUuqCrZduhPuvwlkxdYt6YjJxM,4146
56
56
  bt_cli/epmw/commands/events.py,sha256=not-9_poxQWRBB4sqZSzJo6031A3IZTdg6uAl2Agk2g,7707
57
57
  bt_cli/epmw/commands/groups.py,sha256=PaMcjqfr2b3xS3W3V2A9CFTlMOAs38UmJbi5LKXbbg0,6759
58
- bt_cli/epmw/commands/policies.py,sha256=mCewGU2Sr9H628JIrW9fMJfG0xeI137NaVIPRdQTmkM,24103
58
+ bt_cli/epmw/commands/policies.py,sha256=0jFQ-ZLzTAC6rPbMWL3DfWm0jdgvE2iwR_dHUHI-QG4,24600
59
59
  bt_cli/epmw/commands/quick.py,sha256=X2iTwnDgAvatW8wVY4nqQztLmIEoGmbn1YeCVwHyvzY,13876
60
60
  bt_cli/epmw/commands/requests.py,sha256=qcx6MsPXVm2UkaBIkhpf3RZh6Lo-BBz2OPF4Tl3h4p8,8251
61
61
  bt_cli/epmw/commands/roles.py,sha256=WYAelLUwzJkBB7rlKoR4FqSxfdoDjV310KcNl6ixCCM,2396
@@ -102,11 +102,11 @@ bt_cli/pws/commands/credentials.py,sha256=p1efDBCI35exGS-6iHQ5IHBQIo0TJMfWuCBoek
102
102
  bt_cli/pws/commands/databases.py,sha256=hHGg7hNtE5mwClCpWoSmVSB1-LdY3PORu9iMRuhbxOg,11961
103
103
  bt_cli/pws/commands/directories.py,sha256=NBZfivK-gh3BlC7nXyM_iWeE-qjPIPMlsptkNGtsQJk,7985
104
104
  bt_cli/pws/commands/functional.py,sha256=C9iMSjNfG2VF-kDAM8a3rlMwp_wMePZpGRPNWwkSTBI,12705
105
- bt_cli/pws/commands/import_export.py,sha256=JVYdul4geotAmwkfV04cMHuQD_KVyJ7EYIPbf2FibXg,18536
105
+ bt_cli/pws/commands/import_export.py,sha256=jGrGqInxmWAGWVdUxVBt1hHtu_CqmXO65oV5RoUsaAA,27395
106
106
  bt_cli/pws/commands/platforms.py,sha256=1NZM8BFMG8-AL3AzRZjU0X8zZwt584Z4FOxQf6mGemc,3999
107
107
  bt_cli/pws/commands/quick.py,sha256=3i985r7A8_0zhXTDNxG9NLbGHXlXAAxZL2tMyAAIUyg,70769
108
108
  bt_cli/pws/commands/search.py,sha256=6AHJgk30mLneviODOYX4xL9VwPZo1bDUoix3oC99LMM,9875
109
- bt_cli/pws/commands/secrets.py,sha256=ig3V-CzenF-8YCle_LbszA6-cOj96LCwghUPDd1_Veg,51644
109
+ bt_cli/pws/commands/secrets.py,sha256=-8WInmkRxhq2kESDyMwvtx3hS7OvB-60-puj_2GnH_4,51664
110
110
  bt_cli/pws/commands/systems.py,sha256=UFWNgyfI--NjKXqSWVbw_g_Ar_td4-Roa0tK1AJSuQE,16905
111
111
  bt_cli/pws/commands/users.py,sha256=yMMC1I32QOjsMr2taU4qD7WajdJ7W3mNDQITbqKCEP4,14078
112
112
  bt_cli/pws/commands/workgroups.py,sha256=fvjGniHE6T_oktQSvGhjFXeeDxeubkJXTwR4RNtrA4E,6080
@@ -115,7 +115,7 @@ bt_cli/pws/models/account.py,sha256=OSCMyULPOH1Yu2WOzK0ZQhSRrggGpb2JPHScwGLqUgI,
115
115
  bt_cli/pws/models/asset.py,sha256=Fl0AlR4_9Yyyu36FL1eKF29DNsxsB-r7FaOBRlfOg2Q,4081
116
116
  bt_cli/pws/models/common.py,sha256=D9Ah4ob5CIiFhTt_IR9nF2cBWRHS2z9OyBR2Sss5yzw,3487
117
117
  bt_cli/pws/models/system.py,sha256=D_J0x1A92H2n6BsaBEK9PSAAcs3BTifA5-M9SQqQFGA,5856
118
- bt_cli-0.4.13.dist-info/METADATA,sha256=42cb2_7fGR8F0GZYH7oUvoc7H0mgfBSjm3chxB2NJFY,11803
119
- bt_cli-0.4.13.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
120
- bt_cli-0.4.13.dist-info/entry_points.txt,sha256=NCOEqTI-XKpJOux0JKKhbRElz0B7upayh_d99X5hoLs,38
121
- bt_cli-0.4.13.dist-info/RECORD,,
118
+ bt_cli-0.4.21.dist-info/METADATA,sha256=0riXjVaI5llOe8bbYGWGF0bxg1YwznhX0DUadjOqLhs,11862
119
+ bt_cli-0.4.21.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
120
+ bt_cli-0.4.21.dist-info/entry_points.txt,sha256=NCOEqTI-XKpJOux0JKKhbRElz0B7upayh_d99X5hoLs,38
121
+ bt_cli-0.4.21.dist-info/RECORD,,