htcli 1.1.0__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 (140) hide show
  1. htcli-1.1.0.dist-info/METADATA +509 -0
  2. htcli-1.1.0.dist-info/RECORD +140 -0
  3. htcli-1.1.0.dist-info/WHEEL +4 -0
  4. htcli-1.1.0.dist-info/entry_points.txt +2 -0
  5. htcli-1.1.0.dist-info/licenses/LICENSE +21 -0
  6. src/__init__.py +0 -0
  7. src/htcli/__init__.py +5 -0
  8. src/htcli/client/__init__.py +338 -0
  9. src/htcli/client/extrinsics/__init__.py +26 -0
  10. src/htcli/client/extrinsics/base.py +487 -0
  11. src/htcli/client/extrinsics/consensus.py +79 -0
  12. src/htcli/client/extrinsics/governance.py +714 -0
  13. src/htcli/client/extrinsics/identity.py +490 -0
  14. src/htcli/client/extrinsics/node.py +1054 -0
  15. src/htcli/client/extrinsics/overwatch.py +401 -0
  16. src/htcli/client/extrinsics/staking.py +1504 -0
  17. src/htcli/client/extrinsics/subnet.py +2218 -0
  18. src/htcli/client/extrinsics/validator.py +203 -0
  19. src/htcli/client/extrinsics/wallet.py +323 -0
  20. src/htcli/client/offchain/__init__.py +10 -0
  21. src/htcli/client/offchain/backup.py +385 -0
  22. src/htcli/client/offchain/config.py +541 -0
  23. src/htcli/client/offchain/wallet.py +839 -0
  24. src/htcli/client/rpc/__init__.py +20 -0
  25. src/htcli/client/rpc/chain.py +568 -0
  26. src/htcli/client/rpc/node.py +783 -0
  27. src/htcli/client/rpc/overwatch.py +680 -0
  28. src/htcli/client/rpc/staking.py +216 -0
  29. src/htcli/client/rpc/subnet.py +2104 -0
  30. src/htcli/client/rpc/wallet.py +912 -0
  31. src/htcli/commands/__init__.py +31 -0
  32. src/htcli/commands/chain/__init__.py +66 -0
  33. src/htcli/commands/chain/display.py +204 -0
  34. src/htcli/commands/chain/handlers.py +260 -0
  35. src/htcli/commands/config/__init__.py +158 -0
  36. src/htcli/commands/config/display.py +353 -0
  37. src/htcli/commands/config/handlers.py +347 -0
  38. src/htcli/commands/config/prompts.py +357 -0
  39. src/htcli/commands/consensus/__init__.py +61 -0
  40. src/htcli/commands/consensus/handlers.py +100 -0
  41. src/htcli/commands/governance/__init__.py +49 -0
  42. src/htcli/commands/governance/handlers.py +81 -0
  43. src/htcli/commands/node/__init__.py +304 -0
  44. src/htcli/commands/node/display.py +749 -0
  45. src/htcli/commands/node/error_handling.py +470 -0
  46. src/htcli/commands/node/handlers.py +844 -0
  47. src/htcli/commands/node/prompts.py +346 -0
  48. src/htcli/commands/overwatch/__init__.py +219 -0
  49. src/htcli/commands/overwatch/display.py +396 -0
  50. src/htcli/commands/overwatch/error_handling.py +276 -0
  51. src/htcli/commands/overwatch/handlers.py +443 -0
  52. src/htcli/commands/overwatch/prompts.py +359 -0
  53. src/htcli/commands/stake/__init__.py +736 -0
  54. src/htcli/commands/stake/display.py +1103 -0
  55. src/htcli/commands/stake/error_handling.py +425 -0
  56. src/htcli/commands/stake/handlers.py +1902 -0
  57. src/htcli/commands/stake/prompts.py +1080 -0
  58. src/htcli/commands/subnet/__init__.py +639 -0
  59. src/htcli/commands/subnet/display.py +801 -0
  60. src/htcli/commands/subnet/error_handling.py +524 -0
  61. src/htcli/commands/subnet/handlers.py +2855 -0
  62. src/htcli/commands/subnet/prompts.py +1225 -0
  63. src/htcli/commands/validator/__init__.py +192 -0
  64. src/htcli/commands/validator/display.py +54 -0
  65. src/htcli/commands/validator/handlers.py +340 -0
  66. src/htcli/commands/wallet/__init__.py +546 -0
  67. src/htcli/commands/wallet/display.py +806 -0
  68. src/htcli/commands/wallet/error_handling.py +210 -0
  69. src/htcli/commands/wallet/handlers.py +3040 -0
  70. src/htcli/commands/wallet/prompts.py +1518 -0
  71. src/htcli/config.py +184 -0
  72. src/htcli/dependencies.py +186 -0
  73. src/htcli/errors/__init__.py +63 -0
  74. src/htcli/errors/base.py +141 -0
  75. src/htcli/errors/display.py +20 -0
  76. src/htcli/errors/handlers.py +710 -0
  77. src/htcli/main.py +343 -0
  78. src/htcli/models/__init__.py +21 -0
  79. src/htcli/models/enums/enum_types.py +35 -0
  80. src/htcli/models/errors.py +103 -0
  81. src/htcli/models/requests/__init__.py +197 -0
  82. src/htcli/models/requests/config.py +70 -0
  83. src/htcli/models/requests/consensus.py +19 -0
  84. src/htcli/models/requests/governance.py +38 -0
  85. src/htcli/models/requests/identity.py +51 -0
  86. src/htcli/models/requests/key.py +22 -0
  87. src/htcli/models/requests/node.py +91 -0
  88. src/htcli/models/requests/overwatch.py +64 -0
  89. src/htcli/models/requests/staking.py +580 -0
  90. src/htcli/models/requests/subnet.py +195 -0
  91. src/htcli/models/requests/validator.py +139 -0
  92. src/htcli/models/requests/wallet.py +118 -0
  93. src/htcli/models/responses/__init__.py +147 -0
  94. src/htcli/models/responses/base.py +18 -0
  95. src/htcli/models/responses/chain.py +39 -0
  96. src/htcli/models/responses/config.py +58 -0
  97. src/htcli/models/responses/identity.py +102 -0
  98. src/htcli/models/responses/overwatch.py +51 -0
  99. src/htcli/models/responses/staking.py +502 -0
  100. src/htcli/models/responses/subnet.py +856 -0
  101. src/htcli/models/responses/wallet.py +185 -0
  102. src/htcli/ui/__init__.py +87 -0
  103. src/htcli/ui/colors.py +309 -0
  104. src/htcli/ui/components/__init__.py +60 -0
  105. src/htcli/ui/components/panels.py +174 -0
  106. src/htcli/ui/components/progress.py +166 -0
  107. src/htcli/ui/components/spinners.py +92 -0
  108. src/htcli/ui/components/tables.py +809 -0
  109. src/htcli/ui/components/trees.py +721 -0
  110. src/htcli/ui/display.py +336 -0
  111. src/htcli/ui/prompts.py +870 -0
  112. src/htcli/utils/__init__.py +76 -0
  113. src/htcli/utils/blockchain/__init__.py +75 -0
  114. src/htcli/utils/blockchain/formatting.py +368 -0
  115. src/htcli/utils/blockchain/patches.py +286 -0
  116. src/htcli/utils/blockchain/peer_id.py +186 -0
  117. src/htcli/utils/blockchain/staking.py +448 -0
  118. src/htcli/utils/blockchain/type_registry.py +1373 -0
  119. src/htcli/utils/blockchain/validation.py +179 -0
  120. src/htcli/utils/cache.py +613 -0
  121. src/htcli/utils/constants.py +38 -0
  122. src/htcli/utils/legacy/__init__.py +12 -0
  123. src/htcli/utils/legacy/colors.py +311 -0
  124. src/htcli/utils/legacy/crypto.py +1176 -0
  125. src/htcli/utils/legacy/formatting.py +452 -0
  126. src/htcli/utils/legacy/interactive.py +306 -0
  127. src/htcli/utils/legacy/subnet_manifest.py +265 -0
  128. src/htcli/utils/legacy/validation.py +488 -0
  129. src/htcli/utils/logging.py +183 -0
  130. src/htcli/utils/network/__init__.py +20 -0
  131. src/htcli/utils/network/subnet.py +344 -0
  132. src/htcli/utils/prompts.py +27 -0
  133. src/htcli/utils/scale_codec.py +155 -0
  134. src/htcli/utils/validation/__init__.py +57 -0
  135. src/htcli/utils/validation/prompt_validators.py +267 -0
  136. src/htcli/utils/wallet/__init__.py +65 -0
  137. src/htcli/utils/wallet/auth.py +151 -0
  138. src/htcli/utils/wallet/core.py +1069 -0
  139. src/htcli/utils/wallet/crypto.py +1615 -0
  140. src/htcli/utils/wallet/migration.py +159 -0
@@ -0,0 +1,541 @@
1
+ """
2
+ Off-chain configuration management operations.
3
+ Handles local configuration files, network settings, and CLI preferences.
4
+ """
5
+
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Any, Optional
9
+
10
+ import yaml
11
+
12
+ from ...models.requests import (
13
+ ConfigGetRequest,
14
+ ConfigInitRequest,
15
+ ConfigSetRequest,
16
+ ConfigShowRequest,
17
+ ConfigValidateRequest,
18
+ )
19
+ from ...utils.logging import get_logger
20
+
21
+ logger = get_logger(__name__)
22
+
23
+
24
+ class ConfigManager:
25
+ """Manager for off-chain configuration operations."""
26
+
27
+ def __init__(self, config_dir: Optional[str] = None):
28
+ self.config_dir = Path(config_dir) if config_dir else Path.home() / ".htcli"
29
+ self.config_dir.mkdir(parents=True, exist_ok=True)
30
+ self.config_file = self.config_dir / "config.yaml"
31
+ self.default_config = {
32
+ "network": {
33
+ "endpoint": "wss://rpc.htcli.io/",
34
+ "ws_endpoint": "wss://rpc.htcli.io/",
35
+ "timeout": 30,
36
+ "retry_attempts": 3,
37
+ "chain": "hypertensor", # Chain name (optional, can be auto-detected)
38
+ },
39
+ "wallet": {
40
+ "path": "~/.htcli/wallets",
41
+ "default_name": "default",
42
+ "encryption_enabled": True,
43
+ },
44
+ "output": {
45
+ "format": "table",
46
+ "verbose": False,
47
+ "color": True,
48
+ },
49
+ }
50
+
51
+ def load_config(self) -> dict:
52
+ """Load configuration from file or create default if not exists."""
53
+ try:
54
+ if self.config_file.exists():
55
+ with open(self.config_file) as f:
56
+ if self.config_file.suffix.lower() == ".json":
57
+ config = json.load(f)
58
+ else:
59
+ config = yaml.safe_load(f)
60
+
61
+ # Validate and clean config to only include fields from default_config
62
+ validated_config = self._validate_config(config)
63
+ return {
64
+ "success": True,
65
+ "message": "Configuration loaded successfully",
66
+ "data": validated_config,
67
+ }
68
+ else:
69
+ # Create default config
70
+ return self.save_config(self.default_config)
71
+ except Exception as e:
72
+ logger.error(f"Failed to load config: {str(e)}")
73
+ raise
74
+
75
+ def save_config(self, config: dict) -> dict:
76
+ """Save configuration to file."""
77
+ try:
78
+ # Validate config structure
79
+ validated_config = self._validate_config(config)
80
+
81
+ with open(self.config_file, "w") as f:
82
+ if self.config_file.suffix.lower() == ".json":
83
+ json.dump(validated_config, f, indent=2)
84
+ else:
85
+ yaml.dump(
86
+ validated_config,
87
+ f,
88
+ indent=2,
89
+ default_flow_style=False,
90
+ sort_keys=False,
91
+ )
92
+
93
+ return {
94
+ "success": True,
95
+ "message": "Configuration saved successfully",
96
+ "data": validated_config,
97
+ }
98
+ except Exception as e:
99
+ logger.error(f"Failed to save config: {str(e)}")
100
+ raise
101
+
102
+ def get_setting(self, key_path: str) -> Any:
103
+ """Get a specific setting using dot notation (e.g., 'network.endpoint')."""
104
+ try:
105
+ config_result = self.load_config()
106
+ config = config_result["data"]
107
+
108
+ keys = key_path.split(".")
109
+ value = config
110
+
111
+ for key in keys:
112
+ if isinstance(value, dict) and key in value:
113
+ value = value[key]
114
+ else:
115
+ raise KeyError(f"Setting '{key_path}' not found")
116
+
117
+ return {
118
+ "success": True,
119
+ "message": f"Setting '{key_path}' retrieved successfully",
120
+ "data": value,
121
+ }
122
+ except Exception as e:
123
+ logger.error(f"Failed to get setting: {str(e)}")
124
+ raise
125
+
126
+ def set_setting(self, key_path: str, value: Any) -> dict:
127
+ """Set a specific setting using dot notation.
128
+
129
+ Only allows setting fields that are in default_config.
130
+ """
131
+ try:
132
+ # Validate that the key path is in default_config
133
+ keys = key_path.split(".")
134
+ if len(keys) < 2:
135
+ raise ValueError(
136
+ f"Invalid key path: {key_path}. Must be in format 'section.field'"
137
+ )
138
+
139
+ section_name = keys[0]
140
+ field_name = keys[-1]
141
+
142
+ # Check if section exists in default_config
143
+ if section_name not in self.default_config:
144
+ raise ValueError(
145
+ f"Invalid section '{section_name}'. "
146
+ f"Available sections: {', '.join(self.default_config.keys())}"
147
+ )
148
+
149
+ # Check if field exists in default_config section
150
+ if field_name not in self.default_config[section_name]:
151
+ raise ValueError(
152
+ f"Invalid field '{field_name}' in section '{section_name}'. "
153
+ f"Available fields: {', '.join(self.default_config[section_name].keys())}"
154
+ )
155
+
156
+ # Load current config
157
+ config_result = self.load_config()
158
+ config = config_result["data"]
159
+
160
+ # Navigate to the parent of the target key
161
+ current = config
162
+ for key in keys[:-1]:
163
+ if key not in current:
164
+ current[key] = {}
165
+ current = current[key]
166
+
167
+ # Set the value
168
+ current[keys[-1]] = value
169
+
170
+ # Save updated config (validation will ensure only valid fields are saved)
171
+ self.save_config(config)
172
+ return {
173
+ "success": True,
174
+ "message": f"Setting '{key_path}' updated successfully",
175
+ "data": {"key": key_path, "value": value},
176
+ }
177
+ except Exception as e:
178
+ logger.error(f"Failed to set setting: {str(e)}")
179
+ raise
180
+
181
+ def reset_config(self, confirm: bool = False) -> dict:
182
+ """Reset configuration to defaults."""
183
+ try:
184
+ if not confirm:
185
+ raise ValueError("Config reset requires explicit confirmation")
186
+
187
+ self.save_config(self.default_config)
188
+ return {
189
+ "success": True,
190
+ "message": "Configuration reset to defaults successfully",
191
+ "data": self.default_config,
192
+ }
193
+ except Exception as e:
194
+ logger.error(f"Failed to reset config: {str(e)}")
195
+ raise
196
+
197
+ def backup_config(self, backup_path: Optional[str] = None) -> dict:
198
+ """Create a backup of current configuration."""
199
+ try:
200
+ if backup_path is None:
201
+ backup_dir = self.config_dir / "backups"
202
+ backup_dir.mkdir(exist_ok=True)
203
+ backup_path = (
204
+ backup_dir / f"config_backup_{int(Path().stat().st_ctime)}.json"
205
+ )
206
+
207
+ backup_file = Path(backup_path)
208
+ backup_file.parent.mkdir(parents=True, exist_ok=True)
209
+
210
+ if self.config_file.exists():
211
+ with (
212
+ open(self.config_file) as src,
213
+ open(backup_file, "w") as dst,
214
+ ):
215
+ dst.write(src.read())
216
+
217
+ return {
218
+ "success": True,
219
+ "message": "Configuration backed up successfully",
220
+ "data": {
221
+ "backup_path": str(backup_file),
222
+ "original_path": str(self.config_file),
223
+ },
224
+ }
225
+ else:
226
+ raise ValueError("No configuration file to backup")
227
+ except Exception as e:
228
+ logger.error(f"Failed to backup config: {str(e)}")
229
+ raise
230
+
231
+ def restore_config(self, backup_path: str) -> dict:
232
+ """Restore configuration from backup."""
233
+ try:
234
+ backup_file = Path(backup_path)
235
+
236
+ if not backup_file.exists():
237
+ raise ValueError(f"Backup file '{backup_path}' not found")
238
+
239
+ # Try YAML first, then JSON for backward compatibility
240
+ with open(backup_file) as f:
241
+ if backup_path.endswith('.yaml') or backup_path.endswith('.yml'):
242
+ config = yaml.safe_load(f)
243
+ else:
244
+ # Try YAML first, fall back to JSON if it fails
245
+ try:
246
+ f.seek(0)
247
+ config = yaml.safe_load(f)
248
+ except Exception:
249
+ import json
250
+ f.seek(0)
251
+ config = json.load(f)
252
+
253
+ self.save_config(config)
254
+ return {
255
+ "success": True,
256
+ "message": "Configuration restored from backup successfully",
257
+ "data": {"backup_path": backup_path, "config": config},
258
+ }
259
+ except Exception as e:
260
+ logger.error(f"Failed to restore config: {str(e)}")
261
+ raise
262
+
263
+ def list_network_profiles(self) -> dict:
264
+ """List available network profiles."""
265
+ try:
266
+ profiles = {
267
+ "mainnet": {
268
+ "endpoint": "wss://rpc.htcli.io/",
269
+ "ws_endpoint": "wss://rpc.htcli.io/",
270
+ "chain": "hypertensor",
271
+ "ss58_format": 0,
272
+ },
273
+ "testnet": {
274
+ "endpoint": "wss://testnet-rpc.hypertensor.io",
275
+ "ws_endpoint": "wss://testnet-ws.hypertensor.io",
276
+ "chain": "hypertensor-testnet",
277
+ "ss58_format": 0,
278
+ },
279
+ "local": {
280
+ "endpoint": "ws://127.0.0.1:9944",
281
+ "ws_endpoint": "ws://127.0.0.1:9944",
282
+ "chain": "hypertensor-local",
283
+ "ss58_format": 0,
284
+ },
285
+ }
286
+
287
+ return {
288
+ "success": True,
289
+ "message": "Network profiles retrieved successfully",
290
+ "data": profiles,
291
+ }
292
+ except Exception as e:
293
+ logger.error(f"Failed to list network profiles: {str(e)}")
294
+ raise
295
+
296
+ def switch_network(self, profile: str) -> dict:
297
+ """Switch to a different network profile."""
298
+ try:
299
+ profiles_result = self.list_network_profiles()
300
+ profiles = profiles_result["data"]
301
+
302
+ if profile not in profiles:
303
+ raise ValueError(f"Unknown network profile: {profile}")
304
+
305
+ network_config = profiles[profile]
306
+
307
+ # Update network settings
308
+ for key, value in network_config.items():
309
+ self.set_setting(f"network.{key}", value)
310
+
311
+ return {
312
+ "success": True,
313
+ "message": f"Switched to {profile} network successfully",
314
+ "data": {"profile": profile, "network_config": network_config},
315
+ }
316
+ except Exception as e:
317
+ logger.error(f"Failed to switch network: {str(e)}")
318
+ raise
319
+
320
+ def _merge_configs(self, default: dict, user: dict) -> dict:
321
+ """Recursively merge user config with defaults."""
322
+ result = default.copy()
323
+ for key, value in user.items():
324
+ if (
325
+ key in result
326
+ and isinstance(result[key], dict)
327
+ and isinstance(value, dict)
328
+ ):
329
+ result[key] = self._merge_configs(result[key], value)
330
+ else:
331
+ result[key] = value
332
+ return result
333
+
334
+ def _validate_config(self, config: dict) -> dict:
335
+ """Validate configuration structure and values.
336
+
337
+ Only keeps fields that are in default_config, removing any extra fields.
338
+ """
339
+ # Start with default config to ensure only valid fields are present
340
+ validated_config = {}
341
+
342
+ # Only include sections that are in default_config
343
+ for section_name, default_section in self.default_config.items():
344
+ if section_name in config:
345
+ # Only include fields that are in default_config
346
+ validated_section = {}
347
+ section_data = config[section_name]
348
+ for field_name in default_section.keys():
349
+ if field_name in section_data:
350
+ validated_section[field_name] = section_data[field_name]
351
+ else:
352
+ # Use default value if field is missing
353
+ validated_section[field_name] = default_section[field_name]
354
+ validated_config[section_name] = validated_section
355
+ else:
356
+ # Use default section if section is missing
357
+ validated_config[section_name] = default_section.copy()
358
+
359
+ # Validate network endpoint format
360
+ endpoint = validated_config.get("network", {}).get("endpoint", "")
361
+ if endpoint and not (
362
+ endpoint.startswith("ws://") or endpoint.startswith("wss://")
363
+ ):
364
+ raise ValueError("Network endpoint must start with ws:// or wss://")
365
+
366
+ return validated_config
367
+
368
+
369
+ # --- Client Functions ---
370
+
371
+ # Default instance for client functions
372
+ _manager = ConfigManager()
373
+
374
+
375
+ def get_config_path(config_file: Optional[str] = None) -> Path:
376
+ """Gets the active configuration file path."""
377
+ if config_file:
378
+ return Path(config_file).expanduser()
379
+ return _manager.config_file
380
+
381
+
382
+ def initialize_config(request: ConfigInitRequest) -> dict:
383
+ """Initializes and saves a new configuration."""
384
+ path = get_config_path(request.config_file)
385
+ # Use a temporary manager for non-default paths
386
+ manager = ConfigManager(path.parent) if request.config_file else _manager
387
+ # Ensure we write to the exact requested file path (not just default name)
388
+ if request.config_file:
389
+ manager.config_file = path
390
+
391
+ # Map request fields to the nested config structure
392
+ new_config = manager.default_config.copy()
393
+ new_config["network"]["endpoint"] = request.endpoint
394
+ new_config["network"]["ws_endpoint"] = request.ws_endpoint or request.endpoint
395
+ new_config["network"]["timeout"] = request.timeout
396
+ new_config["network"]["retry_attempts"] = request.retry_attempts
397
+ new_config["wallet"]["path"] = request.wallet_path
398
+ new_config["wallet"]["default_name"] = request.default_wallet or "default"
399
+ new_config["wallet"]["encryption_enabled"] = request.encryption_enabled
400
+ new_config["output"]["format"] = request.output_format
401
+ new_config["output"]["color"] = request.color
402
+ new_config["output"]["verbose"] = request.verbose
403
+
404
+ result = manager.save_config(new_config)
405
+
406
+ return {
407
+ "config_path": manager.config_file,
408
+ "created": True,
409
+ "message": result["message"],
410
+ }
411
+
412
+
413
+ def load_config(request: ConfigShowRequest) -> dict:
414
+ """Loads configuration from disk."""
415
+ cfg_path = get_config_path(request.config_file)
416
+ manager = ConfigManager(cfg_path.parent) if request.config_file else _manager
417
+ if request.config_file:
418
+ manager.config_file = cfg_path
419
+ result = manager.load_config()
420
+ return {
421
+ "config_path": manager.config_file,
422
+ "config_data": result["data"],
423
+ "exists": result["success"],
424
+ }
425
+
426
+
427
+ def save_config(config: dict, config_file: Optional[str] = None) -> dict:
428
+ """Saves configuration to disk."""
429
+ cfg_path = get_config_path(config_file)
430
+ manager = ConfigManager(cfg_path.parent) if config_file else _manager
431
+ if config_file:
432
+ manager.config_file = cfg_path
433
+ result = manager.save_config(config)
434
+ return {
435
+ "config_path": manager.config_file,
436
+ "message": result["message"],
437
+ }
438
+
439
+
440
+ def set_config_value(request: ConfigSetRequest) -> dict:
441
+ """Sets a specific configuration value."""
442
+ cfg_path = get_config_path(request.config_file)
443
+ manager = ConfigManager(cfg_path.parent) if request.config_file else _manager
444
+ if request.config_file:
445
+ manager.config_file = cfg_path
446
+
447
+ try:
448
+ old_value_result = manager.get_setting(request.key)
449
+ old_value = old_value_result["data"]
450
+ except KeyError:
451
+ old_value = None
452
+
453
+ # Normalize values for comparison (convert to string, handle None, case-insensitive)
454
+ def normalize_value(val: Any) -> str:
455
+ if val is None:
456
+ return ""
457
+ # Convert to string and strip whitespace
458
+ val_str = str(val).strip()
459
+ # Handle boolean values (both actual bools and string representations)
460
+ if isinstance(val, bool):
461
+ return str(val).lower()
462
+ val_str_lower = val_str.lower()
463
+ if val_str_lower in ("true", "false"):
464
+ return val_str_lower
465
+ # Handle numeric values - normalize to avoid "30" vs "30.0" vs 30 mismatches
466
+ try:
467
+ # Try to convert to float first to handle decimals
468
+ num_val = float(val_str)
469
+ # If it's a whole number, normalize to int string representation
470
+ # This handles: 30 == "30" == "30.0" == 30.0
471
+ if num_val == int(num_val):
472
+ return str(int(num_val))
473
+ # For decimals, return the normalized float string
474
+ return str(num_val)
475
+ except (ValueError, TypeError):
476
+ # Not a number, return as lowercase string for case-insensitive comparison
477
+ return val_str_lower
478
+
479
+ old_value_normalized = normalize_value(old_value)
480
+ new_value_normalized = normalize_value(request.value)
481
+
482
+ # Early exit if values are the same
483
+ if old_value_normalized == new_value_normalized:
484
+ return {
485
+ "config_path": manager.config_file,
486
+ "key": request.key,
487
+ "old_value": old_value,
488
+ "new_value": request.value,
489
+ "message": "No change needed - value is already set to the same value",
490
+ "unchanged": True,
491
+ }
492
+
493
+ result = manager.set_setting(request.key, request.value)
494
+
495
+ return {
496
+ "config_path": manager.config_file,
497
+ "key": request.key,
498
+ "old_value": old_value,
499
+ "new_value": request.value,
500
+ "message": result["message"],
501
+ "unchanged": False,
502
+ }
503
+
504
+
505
+ def get_config_value(request: ConfigGetRequest) -> dict:
506
+ """Gets a specific configuration value."""
507
+ cfg_path = get_config_path(request.config_file)
508
+ manager = ConfigManager(cfg_path.parent) if request.config_file else _manager
509
+ if request.config_file:
510
+ manager.config_file = cfg_path
511
+ result = manager.get_setting(request.key)
512
+ value = result["data"]
513
+
514
+ return {
515
+ "key": request.key,
516
+ "value": value,
517
+ "value_type": str(type(value).__name__),
518
+ }
519
+
520
+
521
+ def validate_config(request: ConfigValidateRequest) -> dict:
522
+ """Validates the configuration file."""
523
+ cfg_path = get_config_path(request.config_file)
524
+ manager = ConfigManager(cfg_path.parent) if request.config_file else _manager
525
+ if request.config_file:
526
+ manager.config_file = cfg_path
527
+ try:
528
+ config_result = manager.load_config()
529
+ manager._validate_config(config_result["data"])
530
+ return {
531
+ "config_path": manager.config_file,
532
+ "valid": True,
533
+ "message": "Configuration is valid.",
534
+ }
535
+ except Exception as e:
536
+ return {
537
+ "config_path": manager.config_file,
538
+ "valid": False,
539
+ "errors": [str(e)],
540
+ "message": "Configuration validation failed.",
541
+ }