crackerjack 0.30.3__py3-none-any.whl → 0.31.4__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.

Potentially problematic release.


This version of crackerjack might be problematic. Click here for more details.

Files changed (155) hide show
  1. crackerjack/CLAUDE.md +1005 -0
  2. crackerjack/RULES.md +380 -0
  3. crackerjack/__init__.py +42 -13
  4. crackerjack/__main__.py +225 -299
  5. crackerjack/agents/__init__.py +41 -0
  6. crackerjack/agents/architect_agent.py +281 -0
  7. crackerjack/agents/base.py +169 -0
  8. crackerjack/agents/coordinator.py +512 -0
  9. crackerjack/agents/documentation_agent.py +498 -0
  10. crackerjack/agents/dry_agent.py +388 -0
  11. crackerjack/agents/formatting_agent.py +245 -0
  12. crackerjack/agents/import_optimization_agent.py +281 -0
  13. crackerjack/agents/performance_agent.py +669 -0
  14. crackerjack/agents/proactive_agent.py +104 -0
  15. crackerjack/agents/refactoring_agent.py +788 -0
  16. crackerjack/agents/security_agent.py +529 -0
  17. crackerjack/agents/test_creation_agent.py +652 -0
  18. crackerjack/agents/test_specialist_agent.py +486 -0
  19. crackerjack/agents/tracker.py +212 -0
  20. crackerjack/api.py +560 -0
  21. crackerjack/cli/__init__.py +24 -0
  22. crackerjack/cli/facade.py +104 -0
  23. crackerjack/cli/handlers.py +267 -0
  24. crackerjack/cli/interactive.py +471 -0
  25. crackerjack/cli/options.py +401 -0
  26. crackerjack/cli/utils.py +18 -0
  27. crackerjack/code_cleaner.py +618 -928
  28. crackerjack/config/__init__.py +19 -0
  29. crackerjack/config/hooks.py +218 -0
  30. crackerjack/core/__init__.py +0 -0
  31. crackerjack/core/async_workflow_orchestrator.py +406 -0
  32. crackerjack/core/autofix_coordinator.py +200 -0
  33. crackerjack/core/container.py +104 -0
  34. crackerjack/core/enhanced_container.py +542 -0
  35. crackerjack/core/performance.py +243 -0
  36. crackerjack/core/phase_coordinator.py +561 -0
  37. crackerjack/core/proactive_workflow.py +316 -0
  38. crackerjack/core/session_coordinator.py +289 -0
  39. crackerjack/core/workflow_orchestrator.py +640 -0
  40. crackerjack/dynamic_config.py +94 -103
  41. crackerjack/errors.py +263 -41
  42. crackerjack/executors/__init__.py +11 -0
  43. crackerjack/executors/async_hook_executor.py +431 -0
  44. crackerjack/executors/cached_hook_executor.py +242 -0
  45. crackerjack/executors/hook_executor.py +345 -0
  46. crackerjack/executors/individual_hook_executor.py +669 -0
  47. crackerjack/intelligence/__init__.py +44 -0
  48. crackerjack/intelligence/adaptive_learning.py +751 -0
  49. crackerjack/intelligence/agent_orchestrator.py +551 -0
  50. crackerjack/intelligence/agent_registry.py +414 -0
  51. crackerjack/intelligence/agent_selector.py +502 -0
  52. crackerjack/intelligence/integration.py +290 -0
  53. crackerjack/interactive.py +576 -315
  54. crackerjack/managers/__init__.py +11 -0
  55. crackerjack/managers/async_hook_manager.py +135 -0
  56. crackerjack/managers/hook_manager.py +137 -0
  57. crackerjack/managers/publish_manager.py +411 -0
  58. crackerjack/managers/test_command_builder.py +151 -0
  59. crackerjack/managers/test_executor.py +435 -0
  60. crackerjack/managers/test_manager.py +258 -0
  61. crackerjack/managers/test_manager_backup.py +1124 -0
  62. crackerjack/managers/test_progress.py +144 -0
  63. crackerjack/mcp/__init__.py +0 -0
  64. crackerjack/mcp/cache.py +336 -0
  65. crackerjack/mcp/client_runner.py +104 -0
  66. crackerjack/mcp/context.py +615 -0
  67. crackerjack/mcp/dashboard.py +636 -0
  68. crackerjack/mcp/enhanced_progress_monitor.py +479 -0
  69. crackerjack/mcp/file_monitor.py +336 -0
  70. crackerjack/mcp/progress_components.py +569 -0
  71. crackerjack/mcp/progress_monitor.py +949 -0
  72. crackerjack/mcp/rate_limiter.py +332 -0
  73. crackerjack/mcp/server.py +22 -0
  74. crackerjack/mcp/server_core.py +244 -0
  75. crackerjack/mcp/service_watchdog.py +501 -0
  76. crackerjack/mcp/state.py +395 -0
  77. crackerjack/mcp/task_manager.py +257 -0
  78. crackerjack/mcp/tools/__init__.py +17 -0
  79. crackerjack/mcp/tools/core_tools.py +249 -0
  80. crackerjack/mcp/tools/error_analyzer.py +308 -0
  81. crackerjack/mcp/tools/execution_tools.py +370 -0
  82. crackerjack/mcp/tools/execution_tools_backup.py +1097 -0
  83. crackerjack/mcp/tools/intelligence_tool_registry.py +80 -0
  84. crackerjack/mcp/tools/intelligence_tools.py +314 -0
  85. crackerjack/mcp/tools/monitoring_tools.py +502 -0
  86. crackerjack/mcp/tools/proactive_tools.py +384 -0
  87. crackerjack/mcp/tools/progress_tools.py +141 -0
  88. crackerjack/mcp/tools/utility_tools.py +341 -0
  89. crackerjack/mcp/tools/workflow_executor.py +360 -0
  90. crackerjack/mcp/websocket/__init__.py +14 -0
  91. crackerjack/mcp/websocket/app.py +39 -0
  92. crackerjack/mcp/websocket/endpoints.py +559 -0
  93. crackerjack/mcp/websocket/jobs.py +253 -0
  94. crackerjack/mcp/websocket/server.py +116 -0
  95. crackerjack/mcp/websocket/websocket_handler.py +78 -0
  96. crackerjack/mcp/websocket_server.py +10 -0
  97. crackerjack/models/__init__.py +31 -0
  98. crackerjack/models/config.py +93 -0
  99. crackerjack/models/config_adapter.py +230 -0
  100. crackerjack/models/protocols.py +118 -0
  101. crackerjack/models/task.py +154 -0
  102. crackerjack/monitoring/ai_agent_watchdog.py +450 -0
  103. crackerjack/monitoring/regression_prevention.py +638 -0
  104. crackerjack/orchestration/__init__.py +0 -0
  105. crackerjack/orchestration/advanced_orchestrator.py +970 -0
  106. crackerjack/orchestration/execution_strategies.py +341 -0
  107. crackerjack/orchestration/test_progress_streamer.py +636 -0
  108. crackerjack/plugins/__init__.py +15 -0
  109. crackerjack/plugins/base.py +200 -0
  110. crackerjack/plugins/hooks.py +246 -0
  111. crackerjack/plugins/loader.py +335 -0
  112. crackerjack/plugins/managers.py +259 -0
  113. crackerjack/py313.py +8 -3
  114. crackerjack/services/__init__.py +22 -0
  115. crackerjack/services/cache.py +314 -0
  116. crackerjack/services/config.py +347 -0
  117. crackerjack/services/config_integrity.py +99 -0
  118. crackerjack/services/contextual_ai_assistant.py +516 -0
  119. crackerjack/services/coverage_ratchet.py +347 -0
  120. crackerjack/services/debug.py +736 -0
  121. crackerjack/services/dependency_monitor.py +617 -0
  122. crackerjack/services/enhanced_filesystem.py +439 -0
  123. crackerjack/services/file_hasher.py +151 -0
  124. crackerjack/services/filesystem.py +395 -0
  125. crackerjack/services/git.py +165 -0
  126. crackerjack/services/health_metrics.py +611 -0
  127. crackerjack/services/initialization.py +847 -0
  128. crackerjack/services/log_manager.py +286 -0
  129. crackerjack/services/logging.py +174 -0
  130. crackerjack/services/metrics.py +578 -0
  131. crackerjack/services/pattern_cache.py +362 -0
  132. crackerjack/services/pattern_detector.py +515 -0
  133. crackerjack/services/performance_benchmarks.py +653 -0
  134. crackerjack/services/security.py +163 -0
  135. crackerjack/services/server_manager.py +234 -0
  136. crackerjack/services/smart_scheduling.py +144 -0
  137. crackerjack/services/tool_version_service.py +61 -0
  138. crackerjack/services/unified_config.py +437 -0
  139. crackerjack/services/version_checker.py +248 -0
  140. crackerjack/slash_commands/__init__.py +14 -0
  141. crackerjack/slash_commands/init.md +122 -0
  142. crackerjack/slash_commands/run.md +163 -0
  143. crackerjack/slash_commands/status.md +127 -0
  144. crackerjack-0.31.4.dist-info/METADATA +742 -0
  145. crackerjack-0.31.4.dist-info/RECORD +148 -0
  146. crackerjack-0.31.4.dist-info/entry_points.txt +2 -0
  147. crackerjack/.gitignore +0 -34
  148. crackerjack/.libcst.codemod.yaml +0 -18
  149. crackerjack/.pdm.toml +0 -1
  150. crackerjack/crackerjack.py +0 -3805
  151. crackerjack/pyproject.toml +0 -286
  152. crackerjack-0.30.3.dist-info/METADATA +0 -1290
  153. crackerjack-0.30.3.dist-info/RECORD +0 -16
  154. {crackerjack-0.30.3.dist-info → crackerjack-0.31.4.dist-info}/WHEEL +0 -0
  155. {crackerjack-0.30.3.dist-info → crackerjack-0.31.4.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,437 @@
1
+ import json
2
+ import os
3
+ import typing as t
4
+ from contextlib import suppress
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import yaml
9
+ from pydantic import BaseModel, Field, field_validator
10
+ from rich.console import Console
11
+
12
+ from crackerjack.errors import ValidationError
13
+ from crackerjack.models.protocols import OptionsProtocol
14
+ from crackerjack.services.logging import LoggingContext, get_logger
15
+
16
+
17
+ class CrackerjackConfig(BaseModel):
18
+ package_path: Path = Field(default_factory=Path.cwd)
19
+ cache_enabled: bool = True
20
+ cache_size: int = 1000
21
+ cache_ttl: float = 300.0
22
+
23
+ hook_batch_size: int = 10
24
+ hook_timeout: int = 300
25
+ max_concurrent_hooks: int = 4
26
+ enable_async_hooks: bool = True
27
+
28
+ test_timeout: int = 300
29
+ test_workers: int = Field(default_factory=lambda: os.cpu_count() or 1)
30
+ min_coverage: float = 10.11 # Baseline from coverage ratchet system
31
+
32
+ log_level: str = "INFO"
33
+ log_json: bool = False
34
+ log_file: Path | None = None
35
+ enable_correlation_ids: bool = True
36
+
37
+ autofix: bool = True
38
+ skip_hooks: bool = False
39
+ experimental_hooks: bool = False
40
+
41
+ performance_tracking: bool = True
42
+ benchmark_mode: bool = False
43
+
44
+ publish_enabled: bool = False
45
+ keyring_provider: str = "subprocess"
46
+
47
+ batch_file_operations: bool = True
48
+ file_operation_batch_size: int = 10
49
+
50
+ precommit_mode: str = "comprehensive"
51
+
52
+ @field_validator("package_path", mode="before")
53
+ @classmethod
54
+ def validate_package_path(cls, v: Any) -> Path:
55
+ if isinstance(v, str):
56
+ v = Path(v)
57
+ return v.resolve()
58
+
59
+ @field_validator("log_file", mode="before")
60
+ @classmethod
61
+ def validate_log_file(cls, v: Any) -> Path | None:
62
+ if v is None:
63
+ return v
64
+ if isinstance(v, str):
65
+ v = Path(v)
66
+ return v
67
+
68
+ @field_validator("test_workers")
69
+ @classmethod
70
+ def validate_test_workers(cls, v: int) -> int:
71
+ return max(1, min(v, 16))
72
+
73
+ @field_validator("min_coverage")
74
+ @classmethod
75
+ def validate_min_coverage(cls, v: float) -> float:
76
+ return max(0.0, min(v, 100.0))
77
+
78
+ @field_validator("log_level")
79
+ @classmethod
80
+ def validate_log_level(cls, v: str) -> str:
81
+ valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
82
+ if v.upper() not in valid_levels:
83
+ msg = f"Invalid log level: {v}. Must be one of {valid_levels}"
84
+ raise ValueError(msg)
85
+ return v.upper()
86
+
87
+ class Config:
88
+ extra = "allow"
89
+
90
+ use_enum_values = True
91
+
92
+
93
+ class ConfigSource:
94
+ def __init__(self, priority: int = 0) -> None:
95
+ self.priority = priority
96
+ self.logger = get_logger("crackerjack.config.source")
97
+
98
+ def load(self) -> dict[str, Any]:
99
+ raise NotImplementedError
100
+
101
+ def is_available(self) -> bool:
102
+ return True
103
+
104
+
105
+ class EnvironmentConfigSource(ConfigSource):
106
+ ENV_PREFIX = "CRACKERJACK_"
107
+
108
+ def __init__(self, priority: int = 100) -> None:
109
+ super().__init__(priority)
110
+
111
+ def load(self) -> dict[str, Any]:
112
+ config: dict[str, Any] = {}
113
+
114
+ for key, value in os.environ.items():
115
+ if key.startswith(self.ENV_PREFIX):
116
+ config_key = key[len(self.ENV_PREFIX) :].lower()
117
+
118
+ config[config_key] = self._convert_value(value)
119
+
120
+ self.logger.debug("Loaded environment config", keys=list(config.keys()))
121
+ return config
122
+
123
+ def _convert_value(self, value: str) -> Any:
124
+ if value.lower() in ("true", "1", "yes", "on"):
125
+ return True
126
+ if value.lower() in ("false", "0", "no", "off"):
127
+ return False
128
+
129
+ with suppress(ValueError):
130
+ return int(value)
131
+
132
+ with suppress(ValueError):
133
+ return float(value)
134
+
135
+ return value
136
+
137
+
138
+ class FileConfigSource(ConfigSource):
139
+ def __init__(self, file_path: Path, priority: int = 50) -> None:
140
+ super().__init__(priority)
141
+ self.file_path = file_path
142
+
143
+ def is_available(self) -> bool:
144
+ return self.file_path.exists() and self.file_path.is_file()
145
+
146
+ def load(self) -> dict[str, Any]:
147
+ if not self.is_available():
148
+ return {}
149
+
150
+ try:
151
+ content = self.file_path.read_text()
152
+
153
+ if self.file_path.suffix.lower() in (".yml", ".yaml"):
154
+ yaml_result: t.Any = yaml.safe_load(content)
155
+ config = (
156
+ t.cast("dict[str, Any]", yaml_result)
157
+ if yaml_result is not None
158
+ else {}
159
+ )
160
+ elif self.file_path.suffix.lower() == ".json":
161
+ json_result = json.loads(content)
162
+ config = (
163
+ t.cast("dict[str, Any]", json_result)
164
+ if json_result is not None
165
+ else {}
166
+ )
167
+ else:
168
+ self.logger.warning(
169
+ "Unknown config file format",
170
+ path=str(self.file_path),
171
+ )
172
+ return {}
173
+
174
+ self.logger.debug(
175
+ "Loaded file config",
176
+ path=str(self.file_path),
177
+ keys=list(config.keys()),
178
+ )
179
+ return config
180
+
181
+ except Exception as e:
182
+ self.logger.exception(
183
+ "Failed to load config file",
184
+ path=str(self.file_path),
185
+ error=str(e),
186
+ )
187
+ return {}
188
+
189
+
190
+ class PyprojectConfigSource(ConfigSource):
191
+ def __init__(self, pyproject_path: Path, priority: int = 25) -> None:
192
+ super().__init__(priority)
193
+ self.pyproject_path = pyproject_path
194
+
195
+ def is_available(self) -> bool:
196
+ return self.pyproject_path.exists() and self.pyproject_path.is_file()
197
+
198
+ def load(self) -> dict[str, Any]:
199
+ if not self.is_available():
200
+ return {}
201
+
202
+ try:
203
+ import tomllib
204
+
205
+ with self.pyproject_path.open("rb") as f:
206
+ pyproject_data = tomllib.load(f)
207
+
208
+ config = pyproject_data.get("tool", {}).get("crackerjack", {})
209
+
210
+ self.logger.debug("Loaded pyproject config", keys=list(config.keys()))
211
+ return config
212
+
213
+ except ImportError:
214
+ try:
215
+ import tomllib
216
+
217
+ with self.pyproject_path.open("rb") as f:
218
+ pyproject_data = tomllib.load(f)
219
+ config = pyproject_data.get("tool", {}).get("crackerjack", {})
220
+ self.logger.debug("Loaded pyproject config", keys=list(config.keys()))
221
+ return config
222
+ except ImportError:
223
+ self.logger.warning(
224
+ "No TOML library available for pyproject.toml parsing",
225
+ )
226
+ return {}
227
+ except Exception as e:
228
+ self.logger.exception("Failed to load pyproject.toml", error=str(e))
229
+ return {}
230
+
231
+
232
+ class OptionsConfigSource(ConfigSource):
233
+ def __init__(self, options: OptionsProtocol, priority: int = 200) -> None:
234
+ super().__init__(priority)
235
+ self.options = options
236
+
237
+ def load(self) -> dict[str, Any]:
238
+ config: dict[str, Any] = {}
239
+
240
+ option_mappings = {
241
+ "testing": "test_mode",
242
+ "autofix": "autofix",
243
+ "skip_hooks": "skip_hooks",
244
+ "experimental_hooks": "experimental_hooks",
245
+ "test_timeout": "test_timeout",
246
+ "test_workers": "test_workers",
247
+ "benchmark": "benchmark_mode",
248
+ "publish": "publish_enabled",
249
+ "log_level": "log_level",
250
+ }
251
+
252
+ for option_attr, config_key in option_mappings.items():
253
+ if hasattr(self.options, option_attr):
254
+ value = getattr(self.options, option_attr)
255
+ if value is not None:
256
+ config[config_key] = value
257
+
258
+ self.logger.debug("Loaded options config", keys=list(config.keys()))
259
+ return config
260
+
261
+
262
+ class UnifiedConfigurationService:
263
+ def __init__(
264
+ self,
265
+ console: Console,
266
+ pkg_path: Path,
267
+ options: OptionsProtocol | None = None,
268
+ ) -> None:
269
+ self.console = console
270
+ self.pkg_path = pkg_path
271
+ self.logger = get_logger("crackerjack.config.unified")
272
+
273
+ self.sources: list[ConfigSource] = []
274
+
275
+ pyproject_path = pkg_path / "pyproject.toml"
276
+ self.sources.extend(
277
+ (
278
+ self._create_default_source(),
279
+ PyprojectConfigSource(pyproject_path),
280
+ ),
281
+ )
282
+
283
+ # .crackerjack.* config files are no longer supported
284
+ # Configuration should be done through pyproject.toml
285
+
286
+ self.sources.append(EnvironmentConfigSource())
287
+
288
+ if options:
289
+ self.sources.append(OptionsConfigSource(options))
290
+
291
+ self._config: CrackerjackConfig | None = None
292
+
293
+ def _create_default_source(self) -> ConfigSource:
294
+ pkg_path = self.pkg_path
295
+
296
+ class DefaultConfigSource(ConfigSource):
297
+ def load(self) -> dict[str, Any]:
298
+ return {
299
+ "package_path": pkg_path,
300
+ "cache_enabled": True,
301
+ "autofix": True,
302
+ "log_level": "INFO",
303
+ }
304
+
305
+ return DefaultConfigSource(priority=0)
306
+
307
+ def get_config(self, reload: bool = False) -> CrackerjackConfig:
308
+ if self._config is None or reload:
309
+ with LoggingContext("load_unified_config", source_count=len(self.sources)):
310
+ self._config = self._load_unified_config()
311
+
312
+ return self._config
313
+
314
+ def _load_unified_config(self) -> CrackerjackConfig:
315
+ merged_config: dict[str, Any] = {}
316
+
317
+ sorted_sources = sorted(self.sources, key=lambda s: s.priority)
318
+
319
+ for source in sorted_sources:
320
+ if source.is_available():
321
+ try:
322
+ source_config = source.load()
323
+ if source_config:
324
+ merged_config.update(source_config)
325
+ self.logger.debug(
326
+ "Merged config from source",
327
+ source_type=type(source).__name__,
328
+ priority=source.priority,
329
+ keys=list(source_config.keys()),
330
+ )
331
+ except Exception as e:
332
+ self.logger.exception(
333
+ "Failed to load config from source",
334
+ source_type=type(source).__name__,
335
+ error=str(e),
336
+ )
337
+
338
+ try:
339
+ config = CrackerjackConfig(**merged_config)
340
+
341
+ self.logger.info(
342
+ "Unified configuration loaded",
343
+ package_path=str(config.package_path),
344
+ cache_enabled=config.cache_enabled,
345
+ autofix=config.autofix,
346
+ async_hooks=config.enable_async_hooks,
347
+ test_workers=config.test_workers,
348
+ log_level=config.log_level,
349
+ )
350
+
351
+ return config
352
+
353
+ except Exception as e:
354
+ self.logger.exception("Configuration validation failed", error=str(e))
355
+ raise ValidationError(
356
+ message="Invalid configuration",
357
+ details=str(e),
358
+ recovery="Check configuration files and environment variables",
359
+ ) from e
360
+
361
+ def get_precommit_config_mode(self) -> str:
362
+ config = self.get_config()
363
+
364
+ if config.experimental_hooks:
365
+ return "experimental"
366
+ if hasattr(config, "test") and getattr(config, "test", False):
367
+ return "comprehensive"
368
+ return config.precommit_mode
369
+
370
+ def get_logging_config(self) -> dict[str, Any]:
371
+ config = self.get_config()
372
+
373
+ return {
374
+ "level": config.log_level,
375
+ "json_output": config.log_json,
376
+ "log_file": config.log_file,
377
+ "enable_correlation_ids": config.enable_correlation_ids,
378
+ }
379
+
380
+ def get_hook_execution_config(self) -> dict[str, Any]:
381
+ config = self.get_config()
382
+
383
+ return {
384
+ "batch_size": config.hook_batch_size,
385
+ "timeout": config.hook_timeout,
386
+ "max_concurrent": config.max_concurrent_hooks,
387
+ "enable_async": config.enable_async_hooks,
388
+ "autofix": config.autofix,
389
+ "skip_hooks": config.skip_hooks,
390
+ }
391
+
392
+ def get_testing_config(self) -> dict[str, Any]:
393
+ config = self.get_config()
394
+
395
+ return {
396
+ "timeout": config.test_timeout,
397
+ "workers": config.test_workers,
398
+ "min_coverage": config.min_coverage,
399
+ }
400
+
401
+ def get_cache_config(self) -> dict[str, Any]:
402
+ config = self.get_config()
403
+
404
+ return {
405
+ "enabled": config.cache_enabled,
406
+ "size": config.cache_size,
407
+ "ttl": config.cache_ttl,
408
+ "batch_operations": config.batch_file_operations,
409
+ "batch_size": config.file_operation_batch_size,
410
+ }
411
+
412
+ def validate_current_config(self) -> bool:
413
+ try:
414
+ config = self.get_config()
415
+
416
+ validation_errors: list[str] = []
417
+
418
+ if config.test_workers <= 0:
419
+ validation_errors.append("test_workers must be positive")
420
+
421
+ if config.min_coverage < 0 or config.min_coverage > 100:
422
+ validation_errors.append("min_coverage must be between 0 and 100")
423
+
424
+ if config.cache_size <= 0:
425
+ validation_errors.append("cache_size must be positive")
426
+
427
+ if validation_errors:
428
+ for error in validation_errors:
429
+ self.logger.error("Configuration validation error", error=error)
430
+ return False
431
+
432
+ self.logger.info("Configuration validation passed")
433
+ return True
434
+
435
+ except Exception as e:
436
+ self.logger.exception("Configuration validation failed", error=str(e))
437
+ return False
@@ -0,0 +1,248 @@
1
+ """Core version checking and comparison functionality.
2
+
3
+ This module handles tool version detection, comparison, and update notifications.
4
+ Split from tool_version_service.py to follow single responsibility principle.
5
+ """
6
+
7
+ import subprocess
8
+ import typing as t
9
+ from dataclasses import dataclass
10
+
11
+ import aiohttp
12
+ from rich.console import Console
13
+
14
+
15
+ @dataclass
16
+ class VersionInfo:
17
+ """Information about a tool's version and update status."""
18
+
19
+ tool_name: str
20
+ current_version: str
21
+ latest_version: str | None = None
22
+ update_available: bool = False
23
+ error: str | None = None
24
+
25
+
26
+ class VersionChecker:
27
+ """Service for checking tool versions and updates."""
28
+
29
+ def __init__(self, console: Console) -> None:
30
+ self.console = console
31
+ self.tools_to_check = {
32
+ "ruff": self._get_ruff_version,
33
+ "pyright": self._get_pyright_version,
34
+ "pre-commit": self._get_precommit_version,
35
+ "uv": self._get_uv_version,
36
+ }
37
+
38
+ async def check_tool_updates(self) -> dict[str, VersionInfo]:
39
+ """Check updates for all registered tools."""
40
+ results = {}
41
+ for tool_name, version_getter in self.tools_to_check.items():
42
+ results[tool_name] = await self._check_single_tool(
43
+ tool_name, version_getter
44
+ )
45
+ return results
46
+
47
+ async def _check_single_tool(
48
+ self, tool_name: str, version_getter: t.Callable[[], str | None]
49
+ ) -> VersionInfo:
50
+ """Check updates for a single tool."""
51
+ try:
52
+ current_version = version_getter()
53
+ if current_version:
54
+ latest_version = await self._fetch_latest_version(tool_name)
55
+ return self._create_installed_version_info(
56
+ tool_name, current_version, latest_version
57
+ )
58
+ else:
59
+ return self._create_missing_tool_info(tool_name)
60
+ except Exception as e:
61
+ return self._create_error_version_info(tool_name, e)
62
+
63
+ def _create_installed_version_info(
64
+ self, tool_name: str, current_version: str, latest_version: str | None
65
+ ) -> VersionInfo:
66
+ """Create version info for installed tool."""
67
+ update_available = (
68
+ latest_version is not None
69
+ and self._version_compare(current_version, latest_version) < 0
70
+ )
71
+
72
+ if update_available:
73
+ self.console.print(
74
+ f"[yellow]🔄 {tool_name} update available: "
75
+ f"{current_version} → {latest_version}[/yellow]"
76
+ )
77
+
78
+ return VersionInfo(
79
+ tool_name=tool_name,
80
+ current_version=current_version,
81
+ latest_version=latest_version,
82
+ update_available=update_available,
83
+ )
84
+
85
+ def _create_missing_tool_info(self, tool_name: str) -> VersionInfo:
86
+ """Create version info for missing tool."""
87
+ self.console.print(f"[red]⚠️ {tool_name} not installed[/red]")
88
+ return VersionInfo(
89
+ tool_name=tool_name,
90
+ current_version="not installed",
91
+ error=f"{tool_name} not found or not installed",
92
+ )
93
+
94
+ def _create_error_version_info(
95
+ self, tool_name: str, error: Exception
96
+ ) -> VersionInfo:
97
+ """Create version info for tool with error."""
98
+ self.console.print(f"[red]❌ Error checking {tool_name}: {error}[/red]")
99
+ return VersionInfo(
100
+ tool_name=tool_name,
101
+ current_version="unknown",
102
+ error=str(error),
103
+ )
104
+
105
+ def _get_ruff_version(self) -> str | None:
106
+ """Get currently installed Ruff version."""
107
+ return self._get_tool_version("ruff")
108
+
109
+ def _get_pyright_version(self) -> str | None:
110
+ """Get currently installed Pyright version."""
111
+ return self._get_tool_version("pyright")
112
+
113
+ def _get_precommit_version(self) -> str | None:
114
+ """Get currently installed pre-commit version."""
115
+ return self._get_tool_version("pre-commit")
116
+
117
+ def _get_uv_version(self) -> str | None:
118
+ """Get currently installed UV version."""
119
+ return self._get_tool_version("uv")
120
+
121
+ def _get_tool_version(self, tool_name: str) -> str | None:
122
+ """Generic method to get tool version via subprocess."""
123
+ try:
124
+ result = subprocess.run(
125
+ [tool_name, "--version"],
126
+ capture_output=True,
127
+ text=True,
128
+ timeout=10,
129
+ check=False,
130
+ )
131
+ if result.returncode == 0:
132
+ version_line = result.stdout.strip()
133
+ return version_line.split()[-1] if version_line else None
134
+ except (FileNotFoundError, subprocess.TimeoutExpired):
135
+ pass
136
+ return None
137
+
138
+ async def _fetch_latest_version(self, tool_name: str) -> str | None:
139
+ """Fetch latest version from PyPI."""
140
+ try:
141
+ pypi_urls = {
142
+ "ruff": "https://pypi.org/pypi/ruff/json",
143
+ "pyright": "https://pypi.org/pypi/pyright/json",
144
+ "pre-commit": "https://pypi.org/pypi/pre-commit/json",
145
+ "uv": "https://pypi.org/pypi/uv/json",
146
+ }
147
+
148
+ url = pypi_urls.get(tool_name)
149
+ if not url:
150
+ return None
151
+
152
+ timeout = aiohttp.ClientTimeout(total=10.0)
153
+ async with aiohttp.ClientSession(timeout=timeout) as session:
154
+ async with session.get(url) as response:
155
+ response.raise_for_status()
156
+ data = await response.json()
157
+ return data.get("info", {}).get("version")
158
+
159
+ except Exception:
160
+ return None
161
+
162
+ def _version_compare(self, current: str, latest: str) -> int:
163
+ """Compare two version strings. Returns -1 if current < latest, 0 if equal, 1 if current > latest."""
164
+ try:
165
+ current_parts, current_len = self._parse_version_parts(current)
166
+ latest_parts, latest_len = self._parse_version_parts(latest)
167
+
168
+ # Normalize lengths
169
+ normalized_current, normalized_latest = self._normalize_version_parts(
170
+ current_parts, latest_parts
171
+ )
172
+
173
+ # Compare numeric values
174
+ numeric_result = self._compare_numeric_parts(
175
+ normalized_current, normalized_latest
176
+ )
177
+ if numeric_result != 0:
178
+ return numeric_result
179
+
180
+ # Handle length differences when numeric values are equal
181
+ return self._handle_length_differences(
182
+ current_len, latest_len, normalized_current, normalized_latest
183
+ )
184
+
185
+ except (ValueError, AttributeError):
186
+ return 0
187
+
188
+ def _parse_version_parts(self, version: str) -> tuple[list[int], int]:
189
+ """Parse version string into integer parts and return original length."""
190
+ parts = [int(x) for x in version.split(".")]
191
+ return parts, len(parts)
192
+
193
+ def _normalize_version_parts(
194
+ self, current_parts: list[int], latest_parts: list[int]
195
+ ) -> tuple[list[int], list[int]]:
196
+ """Extend version parts to same length with zeros."""
197
+ max_len = max(len(current_parts), len(latest_parts))
198
+ current_normalized = current_parts + [0] * (max_len - len(current_parts))
199
+ latest_normalized = latest_parts + [0] * (max_len - len(latest_parts))
200
+ return current_normalized, latest_normalized
201
+
202
+ def _compare_numeric_parts(
203
+ self, current_parts: list[int], latest_parts: list[int]
204
+ ) -> int:
205
+ """Compare version parts numerically."""
206
+ for current_part, latest_part in zip(current_parts, latest_parts):
207
+ if current_part < latest_part:
208
+ return -1
209
+ if current_part > latest_part:
210
+ return 1
211
+ return 0
212
+
213
+ def _handle_length_differences(
214
+ self,
215
+ current_len: int,
216
+ latest_len: int,
217
+ current_parts: list[int],
218
+ latest_parts: list[int],
219
+ ) -> int:
220
+ """Handle version comparison when lengths differ but numeric values are equal."""
221
+ if current_len == latest_len:
222
+ return 0
223
+
224
+ if current_len < latest_len:
225
+ return self._compare_when_current_shorter(
226
+ current_len, latest_len, latest_parts
227
+ )
228
+ return self._compare_when_latest_shorter(latest_len, current_len, current_parts)
229
+
230
+ def _compare_when_current_shorter(
231
+ self, current_len: int, latest_len: int, latest_parts: list[int]
232
+ ) -> int:
233
+ """Compare when current version has fewer parts than latest."""
234
+ extra_parts = latest_parts[current_len:]
235
+ if any(part != 0 for part in extra_parts):
236
+ return -1
237
+ # "1.0" vs "1.0.0" should return -1, but "1" vs "1.0" should return 0
238
+ return -1 if current_len > 1 else 0
239
+
240
+ def _compare_when_latest_shorter(
241
+ self, latest_len: int, current_len: int, current_parts: list[int]
242
+ ) -> int:
243
+ """Compare when latest version has fewer parts than current."""
244
+ extra_parts = current_parts[latest_len:]
245
+ if any(part != 0 for part in extra_parts):
246
+ return 1
247
+ # "1.0.0" vs "1.0" should return 1, but "1.0" vs "1" should return 0
248
+ return 1 if latest_len > 1 else 0
@@ -0,0 +1,14 @@
1
+ from pathlib import Path
2
+
3
+ SLASH_COMMANDS_DIR = Path(__file__).parent
4
+
5
+
6
+ def get_slash_command_path(command_name: str) -> Path:
7
+ return SLASH_COMMANDS_DIR / f"{command_name}.md"
8
+
9
+
10
+ def list_available_commands() -> list[str]:
11
+ return [f.stem for f in SLASH_COMMANDS_DIR.glob("*.md")]
12
+
13
+
14
+ __all__ = ["SLASH_COMMANDS_DIR", "get_slash_command_path", "list_available_commands"]