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.
- bt_cli/__init__.py +3 -0
- bt_cli/cli.py +830 -0
- bt_cli/commands/__init__.py +1 -0
- bt_cli/commands/configure.py +415 -0
- bt_cli/commands/learn.py +229 -0
- bt_cli/commands/quick.py +784 -0
- bt_cli/core/__init__.py +1 -0
- bt_cli/core/auth.py +213 -0
- bt_cli/core/client.py +313 -0
- bt_cli/core/config.py +393 -0
- bt_cli/core/config_file.py +420 -0
- bt_cli/core/csv_utils.py +91 -0
- bt_cli/core/errors.py +247 -0
- bt_cli/core/output.py +205 -0
- bt_cli/core/prompts.py +87 -0
- bt_cli/core/rest_debug.py +221 -0
- bt_cli/data/CLAUDE.md +94 -0
- bt_cli/data/__init__.py +0 -0
- bt_cli/data/skills/bt/SKILL.md +108 -0
- bt_cli/data/skills/entitle/SKILL.md +170 -0
- bt_cli/data/skills/epmw/SKILL.md +144 -0
- bt_cli/data/skills/pra/SKILL.md +150 -0
- bt_cli/data/skills/pws/SKILL.md +198 -0
- bt_cli/entitle/__init__.py +1 -0
- bt_cli/entitle/client/__init__.py +5 -0
- bt_cli/entitle/client/base.py +443 -0
- bt_cli/entitle/commands/__init__.py +24 -0
- bt_cli/entitle/commands/accounts.py +53 -0
- bt_cli/entitle/commands/applications.py +39 -0
- bt_cli/entitle/commands/auth.py +68 -0
- bt_cli/entitle/commands/bundles.py +218 -0
- bt_cli/entitle/commands/integrations.py +60 -0
- bt_cli/entitle/commands/permissions.py +70 -0
- bt_cli/entitle/commands/policies.py +97 -0
- bt_cli/entitle/commands/resources.py +131 -0
- bt_cli/entitle/commands/roles.py +74 -0
- bt_cli/entitle/commands/users.py +123 -0
- bt_cli/entitle/commands/workflows.py +187 -0
- bt_cli/entitle/models/__init__.py +31 -0
- bt_cli/entitle/models/bundle.py +28 -0
- bt_cli/entitle/models/common.py +37 -0
- bt_cli/entitle/models/integration.py +30 -0
- bt_cli/entitle/models/permission.py +27 -0
- bt_cli/entitle/models/policy.py +25 -0
- bt_cli/entitle/models/resource.py +29 -0
- bt_cli/entitle/models/role.py +28 -0
- bt_cli/entitle/models/user.py +24 -0
- bt_cli/entitle/models/workflow.py +55 -0
- bt_cli/epmw/__init__.py +1 -0
- bt_cli/epmw/client/__init__.py +5 -0
- bt_cli/epmw/client/base.py +848 -0
- bt_cli/epmw/commands/__init__.py +33 -0
- bt_cli/epmw/commands/audits.py +250 -0
- bt_cli/epmw/commands/auth.py +55 -0
- bt_cli/epmw/commands/computers.py +140 -0
- bt_cli/epmw/commands/events.py +233 -0
- bt_cli/epmw/commands/groups.py +215 -0
- bt_cli/epmw/commands/policies.py +673 -0
- bt_cli/epmw/commands/quick.py +348 -0
- bt_cli/epmw/commands/requests.py +224 -0
- bt_cli/epmw/commands/roles.py +78 -0
- bt_cli/epmw/commands/tasks.py +38 -0
- bt_cli/epmw/commands/users.py +219 -0
- bt_cli/epmw/models/__init__.py +1 -0
- bt_cli/pra/__init__.py +1 -0
- bt_cli/pra/client/__init__.py +5 -0
- bt_cli/pra/client/base.py +618 -0
- bt_cli/pra/commands/__init__.py +30 -0
- bt_cli/pra/commands/auth.py +55 -0
- bt_cli/pra/commands/import_export.py +442 -0
- bt_cli/pra/commands/jump_clients.py +139 -0
- bt_cli/pra/commands/jump_groups.py +146 -0
- bt_cli/pra/commands/jump_items.py +638 -0
- bt_cli/pra/commands/jumpoints.py +95 -0
- bt_cli/pra/commands/policies.py +197 -0
- bt_cli/pra/commands/quick.py +470 -0
- bt_cli/pra/commands/teams.py +81 -0
- bt_cli/pra/commands/users.py +87 -0
- bt_cli/pra/commands/vault.py +564 -0
- bt_cli/pra/models/__init__.py +27 -0
- bt_cli/pra/models/common.py +12 -0
- bt_cli/pra/models/jump_client.py +25 -0
- bt_cli/pra/models/jump_group.py +15 -0
- bt_cli/pra/models/jump_item.py +72 -0
- bt_cli/pra/models/jumpoint.py +19 -0
- bt_cli/pra/models/team.py +14 -0
- bt_cli/pra/models/user.py +17 -0
- bt_cli/pra/models/vault.py +45 -0
- bt_cli/pws/__init__.py +1 -0
- bt_cli/pws/client/__init__.py +5 -0
- bt_cli/pws/client/base.py +356 -0
- bt_cli/pws/client/beyondinsight.py +869 -0
- bt_cli/pws/client/passwordsafe.py +1786 -0
- bt_cli/pws/commands/__init__.py +33 -0
- bt_cli/pws/commands/accounts.py +372 -0
- bt_cli/pws/commands/assets.py +311 -0
- bt_cli/pws/commands/auth.py +166 -0
- bt_cli/pws/commands/clouds.py +221 -0
- bt_cli/pws/commands/config.py +344 -0
- bt_cli/pws/commands/credentials.py +347 -0
- bt_cli/pws/commands/databases.py +306 -0
- bt_cli/pws/commands/directories.py +199 -0
- bt_cli/pws/commands/functional.py +298 -0
- bt_cli/pws/commands/import_export.py +452 -0
- bt_cli/pws/commands/platforms.py +118 -0
- bt_cli/pws/commands/quick.py +1646 -0
- bt_cli/pws/commands/search.py +256 -0
- bt_cli/pws/commands/secrets.py +1343 -0
- bt_cli/pws/commands/systems.py +389 -0
- bt_cli/pws/commands/users.py +415 -0
- bt_cli/pws/commands/workgroups.py +166 -0
- bt_cli/pws/config.py +18 -0
- bt_cli/pws/models/__init__.py +19 -0
- bt_cli/pws/models/account.py +186 -0
- bt_cli/pws/models/asset.py +102 -0
- bt_cli/pws/models/common.py +132 -0
- bt_cli/pws/models/system.py +121 -0
- bt_cli-0.4.13.dist-info/METADATA +417 -0
- bt_cli-0.4.13.dist-info/RECORD +121 -0
- bt_cli-0.4.13.dist-info/WHEEL +4 -0
- 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
|