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,420 @@
1
+ """File-based configuration management for BeyondTrust CLI.
2
+
3
+ Supports:
4
+ - YAML config file at ~/.bt-cli/config.yaml
5
+ - Multiple profiles (dev, production, etc.)
6
+ - Layered configuration (CLI flags > env vars > config file)
7
+ - Optional keyring integration for secrets
8
+ """
9
+
10
+ import logging
11
+ import os
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any, Optional
15
+
16
+ import yaml
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # Default config directory and file
21
+ CONFIG_DIR = Path.home() / ".bt-cli"
22
+ CONFIG_FILE = CONFIG_DIR / "config.yaml"
23
+
24
+ # Products and their configuration fields
25
+ PRODUCTS = {
26
+ "pws": {
27
+ "name": "Password Safe",
28
+ "fields": {
29
+ "api_url": {
30
+ "prompt": "API URL",
31
+ "required": True,
32
+ "secret": False,
33
+ "example": "https://your-server/BeyondTrust/api/public/v3",
34
+ },
35
+ "auth_method": {"prompt": "Auth method (oauth/apikey)", "required": True, "secret": False, "choices": ["oauth", "apikey"]},
36
+ "client_id": {"prompt": "Client ID", "required": False, "secret": False, "if": "auth_method == oauth"},
37
+ "client_secret": {"prompt": "Client Secret", "required": False, "secret": True, "if": "auth_method == oauth"},
38
+ "api_key": {"prompt": "API Key", "required": False, "secret": True, "if": "auth_method == apikey"},
39
+ "run_as": {"prompt": "Run As (impersonation username)", "required": False, "secret": False},
40
+ "verify_ssl": {"prompt": "Verify SSL", "required": False, "secret": False, "default": True},
41
+ "timeout": {"prompt": "Timeout (seconds)", "required": False, "secret": False, "default": 30},
42
+ },
43
+ },
44
+ "entitle": {
45
+ "name": "Entitle",
46
+ "fields": {
47
+ "api_url": {
48
+ "prompt": "API URL",
49
+ "required": True,
50
+ "secret": False,
51
+ "default": "https://api.us.entitle.io",
52
+ "example": "https://api.us.entitle.io or https://api.eu.entitle.io",
53
+ },
54
+ "api_key": {"prompt": "API Key", "required": True, "secret": True},
55
+ "verify_ssl": {"prompt": "Verify SSL", "required": False, "secret": False, "default": True},
56
+ "timeout": {"prompt": "Timeout (seconds)", "required": False, "secret": False, "default": 30},
57
+ },
58
+ },
59
+ "pra": {
60
+ "name": "Privileged Remote Access",
61
+ "fields": {
62
+ "api_url": {
63
+ "prompt": "API URL",
64
+ "required": True,
65
+ "secret": False,
66
+ "example": "https://your-site.beyondtrustcloud.com",
67
+ },
68
+ "client_id": {"prompt": "Client ID", "required": True, "secret": False},
69
+ "client_secret": {"prompt": "Client Secret", "required": True, "secret": True},
70
+ "verify_ssl": {"prompt": "Verify SSL", "required": False, "secret": False, "default": True},
71
+ "timeout": {"prompt": "Timeout (seconds)", "required": False, "secret": False, "default": 30},
72
+ },
73
+ },
74
+ "epmw": {
75
+ "name": "EPM Windows",
76
+ "fields": {
77
+ "api_url": {
78
+ "prompt": "API URL",
79
+ "required": True,
80
+ "secret": False,
81
+ "example": "https://your-site-services.epm.bt3ng.com",
82
+ },
83
+ "client_id": {"prompt": "Client ID", "required": True, "secret": False},
84
+ "client_secret": {"prompt": "Client Secret", "required": True, "secret": True},
85
+ "verify_ssl": {"prompt": "Verify SSL", "required": False, "secret": False, "default": True},
86
+ "timeout": {"prompt": "Timeout (seconds)", "required": False, "secret": False, "default": 30},
87
+ },
88
+ },
89
+ }
90
+
91
+
92
+ @dataclass
93
+ class ConfigFile:
94
+ """Represents the config file structure."""
95
+
96
+ default_profile: str = "default"
97
+ profiles: dict[str, dict[str, dict[str, Any]]] = field(default_factory=dict)
98
+
99
+ def get_product_config(self, product: str, profile: Optional[str] = None) -> dict[str, Any]:
100
+ """Get configuration for a product from a profile.
101
+
102
+ Args:
103
+ product: Product name (pws, entitle, pra, epmw)
104
+ profile: Profile name, uses default_profile if not specified
105
+
106
+ Returns:
107
+ Dictionary of configuration values
108
+ """
109
+ profile = profile or self.default_profile
110
+ if profile not in self.profiles:
111
+ return {}
112
+ return self.profiles[profile].get(product, {})
113
+
114
+ def set_product_config(
115
+ self,
116
+ product: str,
117
+ config: dict[str, Any],
118
+ profile: Optional[str] = None
119
+ ) -> None:
120
+ """Set configuration for a product in a profile.
121
+
122
+ Args:
123
+ product: Product name
124
+ config: Configuration dictionary
125
+ profile: Profile name, uses default_profile if not specified
126
+ """
127
+ profile = profile or self.default_profile
128
+ if profile not in self.profiles:
129
+ self.profiles[profile] = {}
130
+ self.profiles[profile][product] = config
131
+
132
+ def list_profiles(self) -> list[str]:
133
+ """List all available profiles."""
134
+ return list(self.profiles.keys())
135
+
136
+ def delete_profile(self, profile: str) -> bool:
137
+ """Delete a profile.
138
+
139
+ Args:
140
+ profile: Profile name to delete
141
+
142
+ Returns:
143
+ True if deleted, False if not found
144
+ """
145
+ if profile in self.profiles:
146
+ del self.profiles[profile]
147
+ if self.default_profile == profile:
148
+ self.default_profile = "default"
149
+ return True
150
+ return False
151
+
152
+
153
+ def ensure_config_dir() -> Path:
154
+ """Ensure the config directory exists with proper permissions."""
155
+ CONFIG_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
156
+ return CONFIG_DIR
157
+
158
+
159
+ def load_config_file(path: Optional[Path] = None) -> ConfigFile:
160
+ """Load configuration from YAML file.
161
+
162
+ Args:
163
+ path: Optional path to config file, uses default if not specified
164
+
165
+ Returns:
166
+ ConfigFile object (empty if file doesn't exist)
167
+ """
168
+ path = path or CONFIG_FILE
169
+
170
+ if not path.exists():
171
+ return ConfigFile()
172
+
173
+ try:
174
+ with open(path, "r") as f:
175
+ data = yaml.safe_load(f) or {}
176
+
177
+ return ConfigFile(
178
+ default_profile=data.get("default_profile", "default"),
179
+ profiles=data.get("profiles", {}),
180
+ )
181
+ except (yaml.YAMLError, OSError) as e:
182
+ # Return empty config on error, caller can handle
183
+ return ConfigFile()
184
+
185
+
186
+ def save_config_file(config: ConfigFile, path: Optional[Path] = None) -> None:
187
+ """Save configuration to YAML file.
188
+
189
+ Args:
190
+ config: ConfigFile object to save
191
+ path: Optional path to config file, uses default if not specified
192
+
193
+ Security: Uses atomic file creation with 0o600 permissions to prevent
194
+ race conditions where the file could be readable by others.
195
+ """
196
+ path = path or CONFIG_FILE
197
+ ensure_config_dir()
198
+
199
+ data = {
200
+ "default_profile": config.default_profile,
201
+ "profiles": config.profiles,
202
+ }
203
+
204
+ # Security: Create file atomically with secure permissions (0o600)
205
+ # This prevents TOCTOU race where file could be readable between
206
+ # creation and chmod.
207
+ import os
208
+ import tempfile
209
+
210
+ # Write to temp file in same directory, then atomic rename
211
+ dir_path = path.parent
212
+ try:
213
+ # Create temp file with secure permissions
214
+ fd, tmp_path = tempfile.mkstemp(dir=dir_path, prefix=".config_", suffix=".tmp")
215
+ try:
216
+ # Set permissions on file descriptor before writing
217
+ os.fchmod(fd, 0o600)
218
+ with os.fdopen(fd, "w") as f:
219
+ yaml.dump(data, f, default_flow_style=False, sort_keys=False)
220
+ # Atomic rename
221
+ os.rename(tmp_path, path)
222
+ except Exception:
223
+ # Clean up temp file on error
224
+ try:
225
+ os.unlink(tmp_path)
226
+ except OSError:
227
+ pass
228
+ raise
229
+ except OSError as e:
230
+ logger.error(f"Failed to save config file: {e}")
231
+ raise
232
+
233
+
234
+ def get_layered_config(
235
+ product: str,
236
+ profile: Optional[str] = None,
237
+ cli_overrides: Optional[dict[str, Any]] = None,
238
+ ) -> dict[str, Any]:
239
+ """Get configuration with layered precedence.
240
+
241
+ Precedence (highest to lowest):
242
+ 1. CLI flags (cli_overrides)
243
+ 2. Environment variables
244
+ 3. Config file
245
+
246
+ Args:
247
+ product: Product name (pws, entitle, pra, epmw)
248
+ profile: Profile name, uses default if not specified
249
+ cli_overrides: Dictionary of CLI flag overrides
250
+
251
+ Returns:
252
+ Merged configuration dictionary
253
+ """
254
+ # Start with config file values
255
+ config_file = load_config_file()
256
+ config = config_file.get_product_config(product, profile)
257
+
258
+ # Layer environment variables
259
+ env_prefix = _get_env_prefix(product)
260
+ env_mappings = _get_env_mappings(product)
261
+
262
+ for config_key, env_var in env_mappings.items():
263
+ env_value = os.getenv(env_var)
264
+ if env_value is not None:
265
+ config[config_key] = _parse_env_value(env_value, config_key)
266
+
267
+ # Layer CLI overrides (highest precedence)
268
+ if cli_overrides:
269
+ for key, value in cli_overrides.items():
270
+ if value is not None:
271
+ config[key] = value
272
+
273
+ return config
274
+
275
+
276
+ def _get_env_prefix(product: str) -> str:
277
+ """Get environment variable prefix for a product."""
278
+ prefixes = {
279
+ "pws": "BT_PWS",
280
+ "entitle": "BT_ENTITLE",
281
+ "pra": "BT_PRA",
282
+ "epmw": "BT_EPM",
283
+ }
284
+ return prefixes.get(product, f"BT_{product.upper()}")
285
+
286
+
287
+ def _get_env_mappings(product: str) -> dict[str, str]:
288
+ """Get mapping of config keys to environment variable names."""
289
+ prefix = _get_env_prefix(product)
290
+
291
+ # Common mappings
292
+ mappings = {
293
+ "api_url": f"{prefix}_API_URL",
294
+ "api_key": f"{prefix}_API_KEY",
295
+ "client_id": f"{prefix}_CLIENT_ID",
296
+ "client_secret": f"{prefix}_CLIENT_SECRET",
297
+ "verify_ssl": f"{prefix}_VERIFY_SSL",
298
+ "timeout": f"{prefix}_TIMEOUT",
299
+ }
300
+
301
+ # Product-specific additions
302
+ if product == "pws":
303
+ mappings["run_as"] = f"{prefix}_RUN_AS"
304
+ mappings["api_version"] = f"{prefix}_API_VERSION"
305
+
306
+ return mappings
307
+
308
+
309
+ def _parse_env_value(value: str, key: str) -> Any:
310
+ """Parse environment variable value to appropriate type."""
311
+ # Boolean fields
312
+ if key in ("verify_ssl",):
313
+ return value.lower() not in ("false", "0", "no", "off")
314
+
315
+ # Numeric fields
316
+ if key in ("timeout",):
317
+ try:
318
+ return float(value)
319
+ except ValueError:
320
+ return 30.0
321
+
322
+ return value
323
+
324
+
325
+ # Keyring support (optional)
326
+ _keyring_warning_shown = False
327
+
328
+
329
+ def _keyring_available() -> bool:
330
+ """Check if keyring is available."""
331
+ try:
332
+ import keyring
333
+ return True
334
+ except ImportError:
335
+ return False
336
+
337
+
338
+ def _warn_keyring_unavailable() -> None:
339
+ """Warn user that keyring is unavailable and secrets stored in plaintext.
340
+
341
+ Only shows once per process.
342
+ """
343
+ global _keyring_warning_shown
344
+ if _keyring_warning_shown:
345
+ return
346
+ _keyring_warning_shown = True
347
+
348
+ import sys
349
+ print(
350
+ "\033[93mWarning: Keyring not available. Secrets stored in plaintext config file.\n"
351
+ "Install keyring package for secure storage: pip install keyring\033[0m",
352
+ file=sys.stderr,
353
+ )
354
+
355
+
356
+ def get_secret_from_keyring(service: str, key: str) -> Optional[str]:
357
+ """Get a secret from the system keyring.
358
+
359
+ Args:
360
+ service: Service name (e.g., "bt-cli")
361
+ key: Key name (e.g., "pws-default-client_secret")
362
+
363
+ Returns:
364
+ Secret value or None if not found/available
365
+ """
366
+ if not _keyring_available():
367
+ _warn_keyring_unavailable()
368
+ return None
369
+
370
+ try:
371
+ import keyring
372
+ return keyring.get_password(service, key)
373
+ except Exception as e:
374
+ logger.warning(f"Failed to retrieve secret '{key}' from keyring: {e}")
375
+ return None
376
+
377
+
378
+ def set_secret_in_keyring(service: str, key: str, value: str) -> bool:
379
+ """Store a secret in the system keyring.
380
+
381
+ Args:
382
+ service: Service name
383
+ key: Key name
384
+ value: Secret value
385
+
386
+ Returns:
387
+ True if stored successfully, False otherwise
388
+ """
389
+ if not _keyring_available():
390
+ return False
391
+
392
+ try:
393
+ import keyring
394
+ keyring.set_password(service, key, value)
395
+ return True
396
+ except Exception as e:
397
+ logger.warning(f"Failed to store secret '{key}' in keyring: {e}")
398
+ return False
399
+
400
+
401
+ def delete_secret_from_keyring(service: str, key: str) -> bool:
402
+ """Delete a secret from the system keyring.
403
+
404
+ Args:
405
+ service: Service name
406
+ key: Key name
407
+
408
+ Returns:
409
+ True if deleted successfully, False otherwise
410
+ """
411
+ if not _keyring_available():
412
+ return False
413
+
414
+ try:
415
+ import keyring
416
+ keyring.delete_password(service, key)
417
+ return True
418
+ except Exception as e:
419
+ logger.warning(f"Failed to delete secret '{key}' from keyring: {e}")
420
+ return False
@@ -0,0 +1,91 @@
1
+ """Shared CSV utilities for import/export commands."""
2
+
3
+ import csv
4
+ from pathlib import Path
5
+ from typing import Any, Optional
6
+
7
+
8
+ def read_csv(file_path: str) -> list[dict[str, Any]]:
9
+ """Read CSV file and return list of dicts.
10
+
11
+ Args:
12
+ file_path: Path to CSV file
13
+
14
+ Returns:
15
+ List of dictionaries, one per row
16
+ """
17
+ path = Path(file_path)
18
+ if not path.exists():
19
+ raise FileNotFoundError(f"CSV file not found: {file_path}")
20
+
21
+ with open(path, newline='', encoding='utf-8') as f:
22
+ reader = csv.DictReader(f)
23
+ return list(reader)
24
+
25
+
26
+ def write_csv(file_path: str, rows: list[dict[str, Any]], fieldnames: list[str]) -> None:
27
+ """Write list of dicts to CSV file.
28
+
29
+ Args:
30
+ file_path: Output path
31
+ rows: List of dictionaries to write
32
+ fieldnames: Column headers in order
33
+ """
34
+ path = Path(file_path)
35
+ path.parent.mkdir(parents=True, exist_ok=True)
36
+
37
+ with open(path, 'w', newline='', encoding='utf-8') as f:
38
+ writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction='ignore')
39
+ writer.writeheader()
40
+ writer.writerows(rows)
41
+
42
+
43
+ def validate_required_fields(row: dict[str, Any], required: list[str], row_num: int) -> list[str]:
44
+ """Validate that required fields are present and non-empty.
45
+
46
+ Args:
47
+ row: Row dictionary
48
+ required: List of required field names
49
+ row_num: Row number for error messages
50
+
51
+ Returns:
52
+ List of error messages (empty if valid)
53
+ """
54
+ errors = []
55
+ for field in required:
56
+ value = row.get(field, "").strip()
57
+ if not value:
58
+ errors.append(f"Row {row_num}: Missing required field '{field}'")
59
+ return errors
60
+
61
+
62
+ def parse_bool(value: str) -> bool:
63
+ """Parse boolean from CSV string.
64
+
65
+ Args:
66
+ value: String value like 'true', 'false', 'yes', 'no', '1', '0'
67
+
68
+ Returns:
69
+ Boolean value
70
+ """
71
+ if not value:
72
+ return False
73
+ return value.lower().strip() in ('true', 'yes', '1', 'y')
74
+
75
+
76
+ def parse_int(value: str, default: Optional[int] = None) -> Optional[int]:
77
+ """Parse integer from CSV string.
78
+
79
+ Args:
80
+ value: String value
81
+ default: Default if empty or invalid
82
+
83
+ Returns:
84
+ Integer value or default
85
+ """
86
+ if not value or not value.strip():
87
+ return default
88
+ try:
89
+ return int(value.strip())
90
+ except ValueError:
91
+ return default