bt-cli 0.4.13__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. bt_cli/__init__.py +3 -0
  2. bt_cli/cli.py +830 -0
  3. bt_cli/commands/__init__.py +1 -0
  4. bt_cli/commands/configure.py +415 -0
  5. bt_cli/commands/learn.py +229 -0
  6. bt_cli/commands/quick.py +784 -0
  7. bt_cli/core/__init__.py +1 -0
  8. bt_cli/core/auth.py +213 -0
  9. bt_cli/core/client.py +313 -0
  10. bt_cli/core/config.py +393 -0
  11. bt_cli/core/config_file.py +420 -0
  12. bt_cli/core/csv_utils.py +91 -0
  13. bt_cli/core/errors.py +247 -0
  14. bt_cli/core/output.py +205 -0
  15. bt_cli/core/prompts.py +87 -0
  16. bt_cli/core/rest_debug.py +221 -0
  17. bt_cli/data/CLAUDE.md +94 -0
  18. bt_cli/data/__init__.py +0 -0
  19. bt_cli/data/skills/bt/SKILL.md +108 -0
  20. bt_cli/data/skills/entitle/SKILL.md +170 -0
  21. bt_cli/data/skills/epmw/SKILL.md +144 -0
  22. bt_cli/data/skills/pra/SKILL.md +150 -0
  23. bt_cli/data/skills/pws/SKILL.md +198 -0
  24. bt_cli/entitle/__init__.py +1 -0
  25. bt_cli/entitle/client/__init__.py +5 -0
  26. bt_cli/entitle/client/base.py +443 -0
  27. bt_cli/entitle/commands/__init__.py +24 -0
  28. bt_cli/entitle/commands/accounts.py +53 -0
  29. bt_cli/entitle/commands/applications.py +39 -0
  30. bt_cli/entitle/commands/auth.py +68 -0
  31. bt_cli/entitle/commands/bundles.py +218 -0
  32. bt_cli/entitle/commands/integrations.py +60 -0
  33. bt_cli/entitle/commands/permissions.py +70 -0
  34. bt_cli/entitle/commands/policies.py +97 -0
  35. bt_cli/entitle/commands/resources.py +131 -0
  36. bt_cli/entitle/commands/roles.py +74 -0
  37. bt_cli/entitle/commands/users.py +123 -0
  38. bt_cli/entitle/commands/workflows.py +187 -0
  39. bt_cli/entitle/models/__init__.py +31 -0
  40. bt_cli/entitle/models/bundle.py +28 -0
  41. bt_cli/entitle/models/common.py +37 -0
  42. bt_cli/entitle/models/integration.py +30 -0
  43. bt_cli/entitle/models/permission.py +27 -0
  44. bt_cli/entitle/models/policy.py +25 -0
  45. bt_cli/entitle/models/resource.py +29 -0
  46. bt_cli/entitle/models/role.py +28 -0
  47. bt_cli/entitle/models/user.py +24 -0
  48. bt_cli/entitle/models/workflow.py +55 -0
  49. bt_cli/epmw/__init__.py +1 -0
  50. bt_cli/epmw/client/__init__.py +5 -0
  51. bt_cli/epmw/client/base.py +848 -0
  52. bt_cli/epmw/commands/__init__.py +33 -0
  53. bt_cli/epmw/commands/audits.py +250 -0
  54. bt_cli/epmw/commands/auth.py +55 -0
  55. bt_cli/epmw/commands/computers.py +140 -0
  56. bt_cli/epmw/commands/events.py +233 -0
  57. bt_cli/epmw/commands/groups.py +215 -0
  58. bt_cli/epmw/commands/policies.py +673 -0
  59. bt_cli/epmw/commands/quick.py +348 -0
  60. bt_cli/epmw/commands/requests.py +224 -0
  61. bt_cli/epmw/commands/roles.py +78 -0
  62. bt_cli/epmw/commands/tasks.py +38 -0
  63. bt_cli/epmw/commands/users.py +219 -0
  64. bt_cli/epmw/models/__init__.py +1 -0
  65. bt_cli/pra/__init__.py +1 -0
  66. bt_cli/pra/client/__init__.py +5 -0
  67. bt_cli/pra/client/base.py +618 -0
  68. bt_cli/pra/commands/__init__.py +30 -0
  69. bt_cli/pra/commands/auth.py +55 -0
  70. bt_cli/pra/commands/import_export.py +442 -0
  71. bt_cli/pra/commands/jump_clients.py +139 -0
  72. bt_cli/pra/commands/jump_groups.py +146 -0
  73. bt_cli/pra/commands/jump_items.py +638 -0
  74. bt_cli/pra/commands/jumpoints.py +95 -0
  75. bt_cli/pra/commands/policies.py +197 -0
  76. bt_cli/pra/commands/quick.py +470 -0
  77. bt_cli/pra/commands/teams.py +81 -0
  78. bt_cli/pra/commands/users.py +87 -0
  79. bt_cli/pra/commands/vault.py +564 -0
  80. bt_cli/pra/models/__init__.py +27 -0
  81. bt_cli/pra/models/common.py +12 -0
  82. bt_cli/pra/models/jump_client.py +25 -0
  83. bt_cli/pra/models/jump_group.py +15 -0
  84. bt_cli/pra/models/jump_item.py +72 -0
  85. bt_cli/pra/models/jumpoint.py +19 -0
  86. bt_cli/pra/models/team.py +14 -0
  87. bt_cli/pra/models/user.py +17 -0
  88. bt_cli/pra/models/vault.py +45 -0
  89. bt_cli/pws/__init__.py +1 -0
  90. bt_cli/pws/client/__init__.py +5 -0
  91. bt_cli/pws/client/base.py +356 -0
  92. bt_cli/pws/client/beyondinsight.py +869 -0
  93. bt_cli/pws/client/passwordsafe.py +1786 -0
  94. bt_cli/pws/commands/__init__.py +33 -0
  95. bt_cli/pws/commands/accounts.py +372 -0
  96. bt_cli/pws/commands/assets.py +311 -0
  97. bt_cli/pws/commands/auth.py +166 -0
  98. bt_cli/pws/commands/clouds.py +221 -0
  99. bt_cli/pws/commands/config.py +344 -0
  100. bt_cli/pws/commands/credentials.py +347 -0
  101. bt_cli/pws/commands/databases.py +306 -0
  102. bt_cli/pws/commands/directories.py +199 -0
  103. bt_cli/pws/commands/functional.py +298 -0
  104. bt_cli/pws/commands/import_export.py +452 -0
  105. bt_cli/pws/commands/platforms.py +118 -0
  106. bt_cli/pws/commands/quick.py +1646 -0
  107. bt_cli/pws/commands/search.py +256 -0
  108. bt_cli/pws/commands/secrets.py +1343 -0
  109. bt_cli/pws/commands/systems.py +389 -0
  110. bt_cli/pws/commands/users.py +415 -0
  111. bt_cli/pws/commands/workgroups.py +166 -0
  112. bt_cli/pws/config.py +18 -0
  113. bt_cli/pws/models/__init__.py +19 -0
  114. bt_cli/pws/models/account.py +186 -0
  115. bt_cli/pws/models/asset.py +102 -0
  116. bt_cli/pws/models/common.py +132 -0
  117. bt_cli/pws/models/system.py +121 -0
  118. bt_cli-0.4.13.dist-info/METADATA +417 -0
  119. bt_cli-0.4.13.dist-info/RECORD +121 -0
  120. bt_cli-0.4.13.dist-info/WHEEL +4 -0
  121. bt_cli-0.4.13.dist-info/entry_points.txt +2 -0
bt_cli/core/errors.py ADDED
@@ -0,0 +1,247 @@
1
+ """Centralized error handling for bt-cli CLI.
2
+
3
+ Provides:
4
+ - Error message sanitization to prevent credential leakage
5
+ - HTTP status code to user-friendly message mapping
6
+ - Consistent error formatting across all products
7
+ """
8
+
9
+ import json
10
+ import logging
11
+ import re
12
+ from typing import Any, Optional
13
+
14
+ import httpx
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # Patterns to sanitize from error messages
19
+ # Each pattern captures a prefix group and replaces the sensitive value
20
+ SENSITIVE_PATTERNS = [
21
+ # Bearer tokens (entire token after "Bearer ")
22
+ (r"(Bearer\s+)[A-Za-z0-9\-._~+/]+=*", r"\1[REDACTED]"),
23
+ # API keys in various formats
24
+ (r"(api[_-]?key[=:]\s*)[^\s&\"']+", r"\1[REDACTED]"),
25
+ (r"(apikey[=:]\s*)[^\s&\"']+", r"\1[REDACTED]"),
26
+ # Client secrets
27
+ (r"(client[_-]?secret[=:]\s*)[^\s&\"']+", r"\1[REDACTED]"),
28
+ # Passwords
29
+ (r"(password[=:]\s*)[^\s&\"']+", r"\1[REDACTED]"),
30
+ # Generic tokens
31
+ (r"(token[=:]\s*)[^\s&\"']+", r"\1[REDACTED]"),
32
+ # Authorization headers - capture everything after colon/equals (including spaces)
33
+ (r"(authorization[=:]\s*).+", r"\1[REDACTED]"),
34
+ # PS-Auth header values
35
+ (r"(PS-Auth[=:]\s*)[^\s&\"']+", r"\1[REDACTED]"),
36
+ # Basic auth in URLs
37
+ (r"(://[^:]+:)[^@]+(@)", r"\1[REDACTED]\2"),
38
+ ]
39
+
40
+ # Fields to redact in JSON structures (case-insensitive)
41
+ SENSITIVE_FIELDS = {
42
+ "password", "secret", "token", "api_key", "apikey", "api-key",
43
+ "client_secret", "client-secret", "clientsecret",
44
+ "authorization", "bearer", "credential", "credentials",
45
+ "access_token", "refresh_token", "id_token", "session_token",
46
+ "private_key", "privatekey", "ssh_key", "sshkey",
47
+ }
48
+
49
+
50
+ def _sanitize_dict(data: Any, depth: int = 0, max_depth: int = 10) -> Any:
51
+ """Recursively sanitize sensitive fields in a dictionary.
52
+
53
+ Args:
54
+ data: Data structure to sanitize (dict, list, or primitive)
55
+ depth: Current recursion depth
56
+ max_depth: Maximum recursion depth to prevent infinite loops
57
+
58
+ Returns:
59
+ Sanitized data structure
60
+ """
61
+ if depth > max_depth:
62
+ return "[TRUNCATED - max depth]"
63
+
64
+ if isinstance(data, dict):
65
+ sanitized = {}
66
+ for key, value in data.items():
67
+ key_lower = str(key).lower().replace("-", "_").replace(" ", "_")
68
+ # Check if field name contains any sensitive pattern
69
+ if any(s in key_lower for s in SENSITIVE_FIELDS):
70
+ sanitized[key] = "[REDACTED]"
71
+ else:
72
+ sanitized[key] = _sanitize_dict(value, depth + 1, max_depth)
73
+ return sanitized
74
+ elif isinstance(data, list):
75
+ return [_sanitize_dict(item, depth + 1, max_depth) for item in data]
76
+ elif isinstance(data, str):
77
+ # Apply regex patterns to string values
78
+ return sanitize_error_message(data)
79
+ else:
80
+ return data
81
+
82
+
83
+ def sanitize_error_message(message: str) -> str:
84
+ """Remove sensitive data from error messages.
85
+
86
+ Scans the message for patterns that may contain credentials,
87
+ tokens, or other sensitive information and replaces them.
88
+
89
+ Args:
90
+ message: The error message to sanitize
91
+
92
+ Returns:
93
+ Sanitized message with sensitive data replaced
94
+ """
95
+ if not message:
96
+ return message
97
+
98
+ result = message
99
+ for pattern, replacement in SENSITIVE_PATTERNS:
100
+ result = re.sub(pattern, replacement, result, flags=re.IGNORECASE)
101
+
102
+ return result
103
+
104
+
105
+ # HTTP status code to user-friendly message mapping
106
+ HTTP_STATUS_MESSAGES = {
107
+ 400: "Bad request - the server could not understand the request",
108
+ 401: "Authentication failed - check your credentials",
109
+ 403: "Access denied - insufficient permissions for this operation",
110
+ 404: "Resource not found",
111
+ 405: "Method not allowed",
112
+ 408: "Request timeout - the server took too long to respond",
113
+ 409: "Conflict - the resource may already exist or be in use",
114
+ 410: "Resource no longer available",
115
+ 422: "Invalid data - the server could not process the request",
116
+ 429: "Too many requests - please wait and try again",
117
+ 500: "Internal server error",
118
+ 502: "Bad gateway - the server received an invalid response",
119
+ 503: "Service unavailable - the server is temporarily overloaded",
120
+ 504: "Gateway timeout - the server took too long to respond",
121
+ }
122
+
123
+
124
+ def get_http_error_message(error: httpx.HTTPStatusError) -> str:
125
+ """Map HTTP status error to user-friendly message.
126
+
127
+ Args:
128
+ error: The HTTP status error from httpx
129
+
130
+ Returns:
131
+ User-friendly error message
132
+ """
133
+ status_code = error.response.status_code
134
+
135
+ # Get base message for status code
136
+ base_message = HTTP_STATUS_MESSAGES.get(
137
+ status_code, f"HTTP error {status_code}"
138
+ )
139
+
140
+ # Try to extract additional detail from response body
141
+ detail = None
142
+ try:
143
+ response_json = error.response.json()
144
+ # Deep sanitize the entire response to catch nested credentials
145
+ sanitized_response = _sanitize_dict(response_json)
146
+
147
+ # Common error detail field names across different APIs
148
+ for field in ["message", "error", "detail", "Message", "Error", "Description"]:
149
+ if field in sanitized_response:
150
+ value = sanitized_response[field]
151
+ # Handle nested error objects
152
+ if isinstance(value, dict):
153
+ detail = value.get("message") or str(value)
154
+ else:
155
+ detail = str(value)
156
+ break
157
+ # Handle nested error objects as fallback
158
+ if not detail and "error" in sanitized_response:
159
+ err = sanitized_response["error"]
160
+ if isinstance(err, dict):
161
+ detail = err.get("message")
162
+ elif isinstance(err, str):
163
+ detail = err
164
+ except Exception:
165
+ # Response may not be JSON
166
+ pass
167
+
168
+ if detail:
169
+ # Additional regex sanitization for any patterns in the extracted detail
170
+ detail = sanitize_error_message(str(detail))
171
+ return f"{base_message}: {detail}"
172
+
173
+ return base_message
174
+
175
+
176
+ def get_connection_error_message(error: httpx.RequestError) -> str:
177
+ """Map connection/request error to user-friendly message.
178
+
179
+ Args:
180
+ error: The request error from httpx
181
+
182
+ Returns:
183
+ User-friendly error message
184
+ """
185
+ error_type = type(error).__name__
186
+ error_str = str(error)
187
+
188
+ # Sanitize the error string
189
+ error_str = sanitize_error_message(error_str)
190
+
191
+ # Check specific timeout types first (before generic TimeoutException)
192
+ if isinstance(error, httpx.ConnectTimeout):
193
+ return "Connection timed out - could not reach the server"
194
+ elif isinstance(error, httpx.ReadTimeout):
195
+ return "Read timed out - the server stopped responding"
196
+ elif isinstance(error, httpx.WriteTimeout):
197
+ return "Write timed out - could not send data to server"
198
+ elif isinstance(error, httpx.PoolTimeout):
199
+ return "Connection pool exhausted - too many concurrent requests"
200
+ elif isinstance(error, httpx.TimeoutException):
201
+ return "Request timed out - the server took too long to respond"
202
+ elif isinstance(error, httpx.ConnectError):
203
+ return "Could not connect to server - check the URL and network connection"
204
+ else:
205
+ # Generic request error
206
+ return f"Request failed: {error_str}"
207
+
208
+
209
+ def handle_api_error(error: Exception, operation: str) -> str:
210
+ """Generate sanitized, contextual error message for API errors.
211
+
212
+ This is the main entry point for error handling. It determines
213
+ the error type and generates an appropriate user-friendly message.
214
+
215
+ Args:
216
+ error: The exception that occurred
217
+ operation: Description of the operation that failed (e.g., "list systems")
218
+
219
+ Returns:
220
+ User-friendly error message suitable for display
221
+ """
222
+ # Log full error for debugging
223
+ logger.debug(f"Error during '{operation}': {error}", exc_info=True)
224
+
225
+ if isinstance(error, httpx.HTTPStatusError):
226
+ message = get_http_error_message(error)
227
+ return f"Failed to {operation}: {message}"
228
+
229
+ elif isinstance(error, httpx.RequestError):
230
+ message = get_connection_error_message(error)
231
+ return f"Failed to {operation}: {message}"
232
+
233
+ elif isinstance(error, ValueError):
234
+ # Configuration or validation errors
235
+ sanitized = sanitize_error_message(str(error))
236
+ return f"Configuration error: {sanitized}"
237
+
238
+ elif isinstance(error, FileNotFoundError):
239
+ return f"Failed to {operation}: File not found"
240
+
241
+ elif isinstance(error, PermissionError):
242
+ return f"Failed to {operation}: Permission denied"
243
+
244
+ else:
245
+ # Generic error - sanitize the message
246
+ sanitized = sanitize_error_message(str(error))
247
+ return f"Failed to {operation}: {sanitized}"
bt_cli/core/output.py ADDED
@@ -0,0 +1,205 @@
1
+ """Shared output formatting utilities for BeyondTrust CLI."""
2
+
3
+ import json
4
+ from enum import Enum
5
+ from typing import Any, Optional
6
+
7
+ from rich.console import Console
8
+ from rich.panel import Panel
9
+ from rich.table import Table
10
+
11
+ from .errors import handle_api_error
12
+
13
+
14
+ class OutputFormat(str, Enum):
15
+ """Output format for CLI commands."""
16
+
17
+ TABLE = "table"
18
+ JSON = "json"
19
+
20
+
21
+ # Shared console instance
22
+ console = Console()
23
+
24
+
25
+ def print_json(data: Any) -> None:
26
+ """Print data as formatted JSON.
27
+
28
+ Args:
29
+ data: Any JSON-serializable data
30
+ """
31
+ console.print_json(json.dumps(data, indent=2, default=str))
32
+
33
+
34
+ def print_table(
35
+ data: list[dict[str, Any]],
36
+ columns: list[tuple[str, str]],
37
+ title: Optional[str] = None,
38
+ show_header: bool = True,
39
+ ) -> None:
40
+ """Print data as a rich table.
41
+
42
+ Args:
43
+ data: List of dictionaries to display
44
+ columns: List of (display_name, key) tuples defining columns
45
+ title: Optional table title
46
+ show_header: Whether to show column headers
47
+ """
48
+ if not data:
49
+ console.print("[dim]No results found[/dim]")
50
+ return
51
+
52
+ table = Table(title=title, show_header=show_header, header_style="bold cyan")
53
+
54
+ # Add columns
55
+ for display_name, _ in columns:
56
+ table.add_column(display_name)
57
+
58
+ # Add rows
59
+ for item in data:
60
+ row = []
61
+ for _, key in columns:
62
+ val = _format_value(item.get(key))
63
+ row.append(val)
64
+ table.add_row(*row)
65
+
66
+ console.print(table)
67
+
68
+
69
+ def print_detail(
70
+ data: dict[str, Any],
71
+ fields: list[tuple[str, str]],
72
+ title: Optional[str] = None,
73
+ ) -> None:
74
+ """Print detailed view of a single item.
75
+
76
+ Args:
77
+ data: Dictionary containing item data
78
+ fields: List of (label, key) tuples for fields to display
79
+ title: Optional panel title
80
+ """
81
+ table = Table(show_header=False, box=None, padding=(0, 2))
82
+ table.add_column("Field", style="dim")
83
+ table.add_column("Value")
84
+
85
+ for label, key in fields:
86
+ value = data.get(key)
87
+ if value is not None:
88
+ table.add_row(label, _format_value(value))
89
+
90
+ if title:
91
+ console.print(Panel(table, title=title))
92
+ else:
93
+ console.print(table)
94
+
95
+
96
+ def _format_value(value: Any) -> str:
97
+ """Format a value for display.
98
+
99
+ Args:
100
+ value: Value to format
101
+
102
+ Returns:
103
+ Formatted string representation
104
+ """
105
+ if value is None:
106
+ return "-"
107
+ if isinstance(value, bool):
108
+ return "Yes" if value else "No"
109
+ if isinstance(value, dict):
110
+ # Try common nested object patterns
111
+ return str(
112
+ value.get("name")
113
+ or value.get("Name")
114
+ or value.get("id")
115
+ or value.get("Id")
116
+ or value.get("ID")
117
+ or json.dumps(value, default=str)
118
+ )
119
+ if isinstance(value, list):
120
+ if not value:
121
+ return "-"
122
+ # Format list items
123
+ formatted = []
124
+ for v in value[:3]: # Limit to first 3
125
+ if isinstance(v, dict):
126
+ formatted.append(
127
+ str(v.get("name") or v.get("Name") or v.get("id") or v)
128
+ )
129
+ else:
130
+ formatted.append(str(v))
131
+ result = ", ".join(formatted)
132
+ if len(value) > 3:
133
+ result += f" (+{len(value) - 3} more)"
134
+ return result
135
+ return str(value)
136
+
137
+
138
+ def print_success(message: str) -> None:
139
+ """Print a success message.
140
+
141
+ Args:
142
+ message: Message to display
143
+ """
144
+ console.print(f"[green]{message}[/green]")
145
+
146
+
147
+ def print_error(message: str) -> None:
148
+ """Print an error message.
149
+
150
+ Args:
151
+ message: Error message to display
152
+ """
153
+ console.print(f"[red]Error:[/red] {message}")
154
+
155
+
156
+ def print_warning(message: str) -> None:
157
+ """Print a warning message.
158
+
159
+ Args:
160
+ message: Warning message to display
161
+ """
162
+ console.print(f"[yellow]Warning:[/yellow] {message}")
163
+
164
+
165
+ def print_info(message: str) -> None:
166
+ """Print an info message.
167
+
168
+ Args:
169
+ message: Info message to display
170
+ """
171
+ console.print(f"[blue]{message}[/blue]")
172
+
173
+
174
+ def confirm_action(message: str, default: bool = False) -> bool:
175
+ """Prompt user for confirmation.
176
+
177
+ Args:
178
+ message: Confirmation prompt
179
+ default: Default value if user presses Enter
180
+
181
+ Returns:
182
+ True if confirmed, False otherwise
183
+ """
184
+ suffix = " [Y/n]" if default else " [y/N]"
185
+ response = console.input(f"{message}{suffix} ").strip().lower()
186
+
187
+ if not response:
188
+ return default
189
+ return response in ("y", "yes")
190
+
191
+
192
+ def print_api_error(error: Exception, operation: str) -> None:
193
+ """Print a sanitized API error message with context.
194
+
195
+ Uses centralized error handling to:
196
+ - Map HTTP status codes to user-friendly messages
197
+ - Sanitize error messages to prevent credential leakage
198
+ - Provide consistent error formatting
199
+
200
+ Args:
201
+ error: The exception that occurred
202
+ operation: Description of the operation that failed (e.g., "list systems")
203
+ """
204
+ message = handle_api_error(error, operation)
205
+ console.print(f"[red]Error:[/red] {message}")
bt_cli/core/prompts.py ADDED
@@ -0,0 +1,87 @@
1
+ """Reusable interactive prompts for CLI commands."""
2
+
3
+ from typing import Any, Callable, Optional, TypeVar
4
+
5
+ import typer
6
+ from rich.console import Console
7
+
8
+ console = Console()
9
+
10
+ T = TypeVar("T")
11
+
12
+
13
+ def prompt_if_missing(
14
+ value: Optional[T],
15
+ prompt_text: str,
16
+ value_type: type = str,
17
+ ) -> T:
18
+ """Prompt for a value if it's missing.
19
+
20
+ Args:
21
+ value: Current value (may be None)
22
+ prompt_text: Text to show in prompt
23
+ value_type: Type for typer.prompt (str, int, float)
24
+
25
+ Returns:
26
+ The value (either original or prompted)
27
+ """
28
+ if value is None or value == "":
29
+ return typer.prompt(prompt_text, type=value_type)
30
+ return value
31
+
32
+
33
+ def prompt_from_list(
34
+ items: list[dict[str, Any]],
35
+ prompt_text: str,
36
+ id_key: str,
37
+ name_key: str,
38
+ title: str,
39
+ value_type: type = int,
40
+ ) -> Any:
41
+ """Show a list of items and prompt for selection.
42
+
43
+ Args:
44
+ items: List of dicts to display
45
+ prompt_text: Prompt text (e.g., "Workgroup ID")
46
+ id_key: Key for the ID field in each dict
47
+ name_key: Key for the display name field
48
+ title: Title to show above list
49
+ value_type: Type of the ID (int or str)
50
+
51
+ Returns:
52
+ The selected ID
53
+ """
54
+ console.print(f"\n[bold]{title}:[/bold]")
55
+ for item in items:
56
+ item_id = item.get(id_key, "")
57
+ item_name = item.get(name_key, "Unknown")
58
+ console.print(f" {item_id}: {item_name}")
59
+ return typer.prompt(prompt_text, type=value_type)
60
+
61
+
62
+ def prompt_choice(
63
+ prompt_text: str,
64
+ choices: list[tuple[str, str]],
65
+ default: Optional[str] = None,
66
+ ) -> str:
67
+ """Prompt for a choice from a list of options.
68
+
69
+ Args:
70
+ prompt_text: Text to show
71
+ choices: List of (value, description) tuples
72
+ default: Default value
73
+
74
+ Returns:
75
+ The selected value
76
+ """
77
+ console.print(f"\n[bold]{prompt_text}:[/bold]")
78
+ for value, desc in choices:
79
+ marker = " (default)" if value == default else ""
80
+ console.print(f" {value}: {desc}{marker}")
81
+
82
+ result = typer.prompt("Choice", default=default or choices[0][0])
83
+ valid_values = [v for v, _ in choices]
84
+ while result not in valid_values:
85
+ console.print(f"[red]Invalid choice. Options: {', '.join(valid_values)}[/red]")
86
+ result = typer.prompt("Choice", default=default or choices[0][0])
87
+ return result