detectkit 0.2.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.
Files changed (51) hide show
  1. detectkit/__init__.py +17 -0
  2. detectkit/alerting/__init__.py +13 -0
  3. detectkit/alerting/channels/__init__.py +21 -0
  4. detectkit/alerting/channels/base.py +193 -0
  5. detectkit/alerting/channels/email.py +146 -0
  6. detectkit/alerting/channels/factory.py +193 -0
  7. detectkit/alerting/channels/mattermost.py +53 -0
  8. detectkit/alerting/channels/slack.py +55 -0
  9. detectkit/alerting/channels/telegram.py +110 -0
  10. detectkit/alerting/channels/webhook.py +139 -0
  11. detectkit/alerting/orchestrator.py +369 -0
  12. detectkit/cli/__init__.py +1 -0
  13. detectkit/cli/commands/__init__.py +1 -0
  14. detectkit/cli/commands/init.py +282 -0
  15. detectkit/cli/commands/run.py +486 -0
  16. detectkit/cli/commands/test_alert.py +184 -0
  17. detectkit/cli/main.py +186 -0
  18. detectkit/config/__init__.py +30 -0
  19. detectkit/config/metric_config.py +499 -0
  20. detectkit/config/profile.py +285 -0
  21. detectkit/config/project_config.py +164 -0
  22. detectkit/config/validator.py +124 -0
  23. detectkit/core/__init__.py +6 -0
  24. detectkit/core/interval.py +132 -0
  25. detectkit/core/models.py +106 -0
  26. detectkit/database/__init__.py +27 -0
  27. detectkit/database/clickhouse_manager.py +393 -0
  28. detectkit/database/internal_tables.py +724 -0
  29. detectkit/database/manager.py +324 -0
  30. detectkit/database/tables.py +138 -0
  31. detectkit/detectors/__init__.py +6 -0
  32. detectkit/detectors/base.py +441 -0
  33. detectkit/detectors/factory.py +138 -0
  34. detectkit/detectors/statistical/__init__.py +8 -0
  35. detectkit/detectors/statistical/iqr.py +508 -0
  36. detectkit/detectors/statistical/mad.py +478 -0
  37. detectkit/detectors/statistical/manual_bounds.py +206 -0
  38. detectkit/detectors/statistical/zscore.py +491 -0
  39. detectkit/loaders/__init__.py +6 -0
  40. detectkit/loaders/metric_loader.py +470 -0
  41. detectkit/loaders/query_template.py +164 -0
  42. detectkit/orchestration/__init__.py +9 -0
  43. detectkit/orchestration/task_manager.py +746 -0
  44. detectkit/utils/__init__.py +17 -0
  45. detectkit/utils/stats.py +196 -0
  46. detectkit-0.2.4.dist-info/METADATA +237 -0
  47. detectkit-0.2.4.dist-info/RECORD +51 -0
  48. detectkit-0.2.4.dist-info/WHEEL +5 -0
  49. detectkit-0.2.4.dist-info/entry_points.txt +2 -0
  50. detectkit-0.2.4.dist-info/licenses/LICENSE +21 -0
  51. detectkit-0.2.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,110 @@
1
+ """
2
+ Telegram alert channel implementation.
3
+
4
+ Sends anomaly alerts via Telegram Bot API.
5
+ """
6
+
7
+ from typing import Any, Dict, Optional
8
+
9
+ import requests
10
+
11
+ from detectkit.alerting.channels.base import AlertData, BaseAlertChannel
12
+
13
+
14
+ class TelegramChannel(BaseAlertChannel):
15
+ """
16
+ Telegram alert channel using Bot API.
17
+
18
+ Sends formatted messages to Telegram chat using bot token.
19
+
20
+ Attributes:
21
+ bot_token: Telegram bot token (from @BotFather)
22
+ chat_id: Target chat ID (user, group, or channel)
23
+ parse_mode: Message parse mode ("Markdown", "HTML", or None)
24
+ disable_notification: Send silently without notification
25
+
26
+ Example:
27
+ >>> channel = TelegramChannel(
28
+ ... bot_token="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11",
29
+ ... chat_id="-1001234567890"
30
+ ... )
31
+ >>> alert = AlertData(
32
+ ... metric_name="cpu_usage",
33
+ ... timestamp=np.datetime64("2024-01-01T10:00:00"),
34
+ ... value=95.0,
35
+ ... is_anomaly=True
36
+ ... )
37
+ >>> channel.send(alert)
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ bot_token: str,
43
+ chat_id: str,
44
+ parse_mode: str = "Markdown",
45
+ disable_notification: bool = False,
46
+ template: Optional[str] = None,
47
+ **kwargs,
48
+ ):
49
+ """
50
+ Initialize Telegram channel.
51
+
52
+ Args:
53
+ bot_token: Telegram bot token from @BotFather
54
+ chat_id: Target chat ID (can be user_id, @channel_name, or group ID)
55
+ parse_mode: Message formatting ("Markdown", "HTML", or None)
56
+ disable_notification: Send silently without notification sound
57
+ template: Custom message template (optional)
58
+ **kwargs: Additional parameters (ignored)
59
+
60
+ Raises:
61
+ ValueError: If bot_token or chat_id is missing
62
+ """
63
+ if not bot_token:
64
+ raise ValueError("bot_token is required for TelegramChannel")
65
+ if not chat_id:
66
+ raise ValueError("chat_id is required for TelegramChannel")
67
+
68
+ self.bot_token = bot_token
69
+ self.chat_id = chat_id
70
+ self.parse_mode = parse_mode
71
+ self.disable_notification = disable_notification
72
+ self.template = template
73
+
74
+ def send(self, alert_data: AlertData) -> None:
75
+ """
76
+ Send alert to Telegram.
77
+
78
+ Args:
79
+ alert_data: Alert information to send
80
+
81
+ Raises:
82
+ requests.RequestException: If request fails
83
+ requests.HTTPError: If Telegram API returns error
84
+
85
+ Example:
86
+ >>> channel.send(alert_data)
87
+ """
88
+ message = self.format_message(alert_data, self.template)
89
+
90
+ # Telegram Bot API URL
91
+ url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage"
92
+
93
+ payload = {
94
+ "chat_id": self.chat_id,
95
+ "text": message,
96
+ "disable_notification": self.disable_notification,
97
+ }
98
+
99
+ if self.parse_mode:
100
+ payload["parse_mode"] = self.parse_mode
101
+
102
+ try:
103
+ response = requests.post(url, json=payload, timeout=10)
104
+ response.raise_for_status()
105
+ except requests.RequestException as e:
106
+ raise requests.RequestException(f"Failed to send Telegram alert: {e}")
107
+
108
+ def __repr__(self) -> str:
109
+ """String representation."""
110
+ return f"TelegramChannel(chat_id={self.chat_id})"
@@ -0,0 +1,139 @@
1
+ """
2
+ Generic webhook alert channel.
3
+
4
+ Sends alerts to any webhook endpoint that accepts JSON payload.
5
+ Compatible with Mattermost, Slack, and other webhook-based systems.
6
+ """
7
+
8
+ from typing import Dict, Optional
9
+
10
+ import requests
11
+
12
+ from detectkit.alerting.channels.base import AlertData, BaseAlertChannel
13
+
14
+
15
+ class WebhookChannel(BaseAlertChannel):
16
+ """
17
+ Generic webhook alert channel.
18
+
19
+ Sends formatted alert messages to any webhook URL with JSON payload.
20
+ Compatible with:
21
+ - Mattermost incoming webhooks
22
+ - Slack incoming webhooks
23
+ - Custom webhook endpoints
24
+
25
+ The payload format is compatible with Mattermost/Slack:
26
+ {
27
+ "text": "message",
28
+ "username": "bot_name",
29
+ "icon_emoji": ":emoji:",
30
+ "channel": "#channel" (optional)
31
+ }
32
+
33
+ Parameters:
34
+ webhook_url (str): Webhook URL to send alerts to
35
+ username (str): Bot username to display (default: "detectk")
36
+ icon_emoji (str): Bot emoji icon (default: ":warning:")
37
+ channel (str): Target channel (optional, for Slack/Mattermost)
38
+ timeout (int): Request timeout in seconds (default: 10)
39
+ extra_headers (dict): Additional HTTP headers (optional)
40
+
41
+ Example:
42
+ >>> # Mattermost
43
+ >>> channel = WebhookChannel(
44
+ ... webhook_url="https://mattermost.example.com/hooks/xxx"
45
+ ... )
46
+ >>>
47
+ >>> # Slack
48
+ >>> channel = WebhookChannel(
49
+ ... webhook_url="https://hooks.slack.com/services/xxx",
50
+ ... channel="#alerts"
51
+ ... )
52
+ >>>
53
+ >>> # Custom webhook
54
+ >>> channel = WebhookChannel(
55
+ ... webhook_url="https://custom.example.com/webhook",
56
+ ... extra_headers={"Authorization": "Bearer token"}
57
+ ... )
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ webhook_url: str,
63
+ username: str = "detectk",
64
+ icon_emoji: str = ":warning:",
65
+ channel: Optional[str] = None,
66
+ timeout: int = 10,
67
+ extra_headers: Optional[Dict[str, str]] = None,
68
+ ):
69
+ """Initialize webhook channel."""
70
+ if not webhook_url:
71
+ raise ValueError("webhook_url is required")
72
+
73
+ self.webhook_url = webhook_url
74
+ self.username = username
75
+ self.icon_emoji = icon_emoji
76
+ self.channel = channel
77
+ self.timeout = timeout
78
+ self.extra_headers = extra_headers or {}
79
+
80
+ def send(
81
+ self,
82
+ alert_data: AlertData,
83
+ template: Optional[str] = None,
84
+ ) -> bool:
85
+ """
86
+ Send alert to webhook.
87
+
88
+ Args:
89
+ alert_data: Alert data to send
90
+ template: Optional custom message template
91
+
92
+ Returns:
93
+ True if sent successfully, False otherwise
94
+
95
+ Raises:
96
+ requests.RequestException: If request fails critically
97
+
98
+ Example:
99
+ >>> channel = WebhookChannel(webhook_url="https://...")
100
+ >>> success = channel.send(alert_data)
101
+ """
102
+ # Format message
103
+ message = self.format_message(alert_data, template)
104
+
105
+ # Prepare payload (Mattermost/Slack compatible format)
106
+ payload = {
107
+ "text": message,
108
+ "username": self.username,
109
+ "icon_emoji": self.icon_emoji,
110
+ }
111
+
112
+ # Add channel if specified (for Slack)
113
+ if self.channel:
114
+ payload["channel"] = self.channel
115
+
116
+ # Prepare headers
117
+ headers = {"Content-Type": "application/json"}
118
+ headers.update(self.extra_headers)
119
+
120
+ # Send to webhook
121
+ try:
122
+ response = requests.post(
123
+ self.webhook_url,
124
+ json=payload,
125
+ headers=headers,
126
+ timeout=self.timeout,
127
+ )
128
+ response.raise_for_status()
129
+ return True
130
+ except requests.RequestException as e:
131
+ # Log error but don't crash
132
+ print(f"Failed to send webhook alert: {e}")
133
+ return False
134
+
135
+ def __repr__(self) -> str:
136
+ """String representation."""
137
+ url_preview = self.webhook_url[:30] + "..." if len(self.webhook_url) > 30 else self.webhook_url
138
+ channel_info = f", channel='{self.channel}'" if self.channel else ""
139
+ return f"WebhookChannel(url='{url_preview}', username='{self.username}'{channel_info})"
@@ -0,0 +1,369 @@
1
+ """
2
+ Alert orchestrator for coordinating detection and alerting.
3
+
4
+ Handles:
5
+ - Checking consecutive anomaly logic
6
+ - Direction matching
7
+ - Multiple detector aggregation (min_detectors)
8
+ - Loading recent detection results
9
+ - Coordinating alert sending
10
+ """
11
+
12
+ from dataclasses import dataclass
13
+ from datetime import datetime, timezone
14
+ from typing import Dict, List, Optional
15
+
16
+ import numpy as np
17
+
18
+ from detectkit.alerting.channels.base import AlertData, BaseAlertChannel
19
+ from detectkit.core.interval import Interval
20
+
21
+
22
+ @dataclass
23
+ class AlertConditions:
24
+ """Alert conditions configuration."""
25
+
26
+ min_detectors: int = 1 # Minimum detectors needed for alert
27
+ direction: str = "any" # "any", "same", "up", "down"
28
+ consecutive_anomalies: int = 1 # Number of consecutive anomalies required
29
+
30
+
31
+ @dataclass
32
+ class DetectionRecord:
33
+ """Record of a detection result from database."""
34
+
35
+ timestamp: np.datetime64
36
+ detector_name: str
37
+ detector_id: str
38
+ detector_params: str # JSON string with detector parameters
39
+ value: float
40
+ is_anomaly: bool
41
+ confidence_lower: Optional[float]
42
+ confidence_upper: Optional[float]
43
+ direction: str # "up", "down", "none"
44
+ severity: float
45
+ detection_metadata: Dict
46
+
47
+
48
+ class AlertOrchestrator:
49
+ """
50
+ Orchestrates the alert decision and sending process.
51
+
52
+ Responsibilities:
53
+ - Load recent detection results from database
54
+ - Check consecutive anomaly conditions
55
+ - Check direction matching
56
+ - Aggregate multiple detectors (min_detectors)
57
+ - Send alerts through configured channels
58
+
59
+ Example:
60
+ >>> orchestrator = AlertOrchestrator(
61
+ ... metric_name="cpu_usage",
62
+ ... interval=Interval.parse("10min"),
63
+ ... conditions=AlertConditions(consecutive_anomalies=3, direction="same")
64
+ ... )
65
+ >>> should_alert, alert_data = orchestrator.should_alert(recent_detections)
66
+ >>> if should_alert:
67
+ ... orchestrator.send_alerts(alert_data, channels)
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ metric_name: str,
73
+ interval: Interval,
74
+ conditions: Optional[AlertConditions] = None,
75
+ timezone_display: str = "UTC",
76
+ ):
77
+ """
78
+ Initialize alert orchestrator.
79
+
80
+ Args:
81
+ metric_name: Name of the metric
82
+ interval: Metric interval
83
+ conditions: Alert conditions (defaults to AlertConditions())
84
+ timezone_display: Timezone for alert display (default: UTC)
85
+ """
86
+ self.metric_name = metric_name
87
+ self.interval = interval
88
+ self.conditions = conditions or AlertConditions()
89
+ self.timezone_display = timezone_display
90
+
91
+ def should_alert(
92
+ self,
93
+ recent_detections: List[DetectionRecord],
94
+ ) -> tuple[bool, Optional[AlertData]]:
95
+ """
96
+ Determine if alert should be sent based on recent detections.
97
+
98
+ Args:
99
+ recent_detections: List of recent detection records (sorted by time, newest first)
100
+
101
+ Returns:
102
+ Tuple of (should_alert, alert_data)
103
+ - should_alert: True if alert should be sent
104
+ - alert_data: AlertData if should_alert=True, None otherwise
105
+
106
+ Logic:
107
+ 1. Check if enough detectors triggered (min_detectors)
108
+ 2. Check consecutive anomalies with direction matching
109
+ 3. Return decision and formatted AlertData
110
+ """
111
+ if not recent_detections:
112
+ return False, None
113
+
114
+ # Group detections by timestamp
115
+ detections_by_time = self._group_by_timestamp(recent_detections)
116
+
117
+ # Check from newest to oldest
118
+ timestamps_sorted = sorted(detections_by_time.keys(), reverse=True)
119
+
120
+ # Check min_detectors for the latest point
121
+ latest_timestamp = timestamps_sorted[0]
122
+ latest_detections = detections_by_time[latest_timestamp]
123
+
124
+ # Filter anomalies
125
+ latest_anomalies = [d for d in latest_detections if d.is_anomaly]
126
+
127
+ if len(latest_anomalies) < self.conditions.min_detectors:
128
+ return False, None
129
+
130
+ # Check consecutive anomalies
131
+ consecutive_count = self._count_consecutive_anomalies(
132
+ detections_by_time, timestamps_sorted
133
+ )
134
+
135
+ if consecutive_count < self.conditions.consecutive_anomalies:
136
+ return False, None
137
+
138
+ # Build AlertData from latest anomalies
139
+ # If multiple detectors, aggregate them
140
+ alert_data = self._build_alert_data(
141
+ latest_anomalies, consecutive_count
142
+ )
143
+
144
+ return True, alert_data
145
+
146
+ def _group_by_timestamp(
147
+ self, detections: List[DetectionRecord]
148
+ ) -> Dict[np.datetime64, List[DetectionRecord]]:
149
+ """Group detection records by timestamp."""
150
+ grouped = {}
151
+ for detection in detections:
152
+ if detection.timestamp not in grouped:
153
+ grouped[detection.timestamp] = []
154
+ grouped[detection.timestamp].append(detection)
155
+ return grouped
156
+
157
+ def _count_consecutive_anomalies(
158
+ self,
159
+ detections_by_time: Dict[np.datetime64, List[DetectionRecord]],
160
+ timestamps_sorted: List[np.datetime64],
161
+ ) -> int:
162
+ """
163
+ Count consecutive anomalies matching direction condition.
164
+
165
+ Args:
166
+ detections_by_time: Detections grouped by timestamp
167
+ timestamps_sorted: Timestamps in descending order (newest first)
168
+
169
+ Returns:
170
+ Number of consecutive anomalies
171
+
172
+ Logic:
173
+ - direction="any": Count any anomalies
174
+ - direction="same": Count anomalies in same direction (resets on change)
175
+ - direction="up": Count only "up" anomalies
176
+ - direction="down": Count only "down" anomalies
177
+ """
178
+ direction_condition = self.conditions.direction
179
+ consecutive = 0
180
+ prev_direction = None
181
+
182
+ for timestamp in timestamps_sorted:
183
+ detections = detections_by_time[timestamp]
184
+
185
+ # Check if enough detectors found anomaly
186
+ anomalies = [d for d in detections if d.is_anomaly]
187
+ if len(anomalies) < self.conditions.min_detectors:
188
+ break
189
+
190
+ # Determine dominant direction (use first detector's direction)
191
+ current_direction = anomalies[0].direction
192
+
193
+ # Check direction matching
194
+ if direction_condition == "any":
195
+ consecutive += 1
196
+ elif direction_condition == "same":
197
+ if prev_direction is None:
198
+ consecutive = 1
199
+ prev_direction = current_direction
200
+ elif current_direction == prev_direction:
201
+ consecutive += 1
202
+ else:
203
+ # Direction changed, stop counting
204
+ break
205
+ elif direction_condition == "up":
206
+ if current_direction == "up":
207
+ consecutive += 1
208
+ else:
209
+ break
210
+ elif direction_condition == "down":
211
+ if current_direction == "down":
212
+ consecutive += 1
213
+ else:
214
+ break
215
+ else:
216
+ # Unknown direction condition
217
+ consecutive += 1
218
+
219
+ return consecutive
220
+
221
+ def _build_alert_data(
222
+ self,
223
+ anomalies: List[DetectionRecord],
224
+ consecutive_count: int,
225
+ ) -> AlertData:
226
+ """
227
+ Build AlertData from anomalous detections.
228
+
229
+ Args:
230
+ anomalies: List of anomalous detections for the latest point
231
+ consecutive_count: Number of consecutive anomalies
232
+
233
+ Returns:
234
+ AlertData for sending
235
+ """
236
+ # Use first detector for primary info (if multiple, we'll note it)
237
+ primary = anomalies[0]
238
+
239
+ # If multiple detectors, aggregate info
240
+ if len(anomalies) > 1:
241
+ # Take the worst severity
242
+ max_severity = max(d.severity for d in anomalies)
243
+ detector_names = [d.detector_name for d in anomalies]
244
+ detector_name = f"{len(anomalies)} detectors"
245
+ detector_params_list = [
246
+ f"{d.detector_name}: {d.detector_params}" for d in anomalies
247
+ ]
248
+ detector_params = "; ".join(detector_params_list)
249
+
250
+ # Combine metadata
251
+ combined_metadata = {
252
+ "detectors": detector_names,
253
+ "count": len(anomalies),
254
+ }
255
+ for i, d in enumerate(anomalies):
256
+ combined_metadata[f"detector_{i}_metadata"] = d.detection_metadata
257
+ else:
258
+ max_severity = primary.severity
259
+ detector_name = primary.detector_name
260
+ detector_params = primary.detector_params
261
+ combined_metadata = primary.detection_metadata
262
+
263
+ # Convert numpy timestamp for AlertData
264
+ timestamp = primary.timestamp
265
+
266
+ return AlertData(
267
+ metric_name=self.metric_name,
268
+ timestamp=timestamp,
269
+ timezone=self.timezone_display,
270
+ value=primary.value,
271
+ confidence_lower=primary.confidence_lower,
272
+ confidence_upper=primary.confidence_upper,
273
+ detector_name=detector_name,
274
+ detector_params=detector_params,
275
+ direction=primary.direction,
276
+ severity=max_severity,
277
+ detection_metadata=combined_metadata,
278
+ consecutive_count=consecutive_count,
279
+ )
280
+
281
+ def send_alerts(
282
+ self,
283
+ alert_data: AlertData,
284
+ channels: List[BaseAlertChannel],
285
+ template: Optional[str] = None,
286
+ ) -> Dict[str, bool]:
287
+ """
288
+ Send alerts through all configured channels.
289
+
290
+ Args:
291
+ alert_data: Alert data to send
292
+ channels: List of alert channels
293
+ template: Optional custom message template
294
+
295
+ Returns:
296
+ Dict mapping channel name to success status
297
+
298
+ Example:
299
+ >>> results = orchestrator.send_alerts(
300
+ ... alert_data,
301
+ ... channels=[mattermost, slack],
302
+ ... template="ALERT: {metric_name} = {value}"
303
+ ... )
304
+ >>> print(results)
305
+ {'MattermostChannel': True, 'SlackChannel': True}
306
+ """
307
+ results = {}
308
+
309
+ for channel in channels:
310
+ try:
311
+ success = channel.send(alert_data, template)
312
+ channel_name = channel.__class__.__name__
313
+ results[channel_name] = success
314
+ except Exception as e:
315
+ channel_name = channel.__class__.__name__
316
+ print(f"Error sending alert via {channel_name}: {e}")
317
+ results[channel_name] = False
318
+
319
+ return results
320
+
321
+ def get_last_complete_point(self, now: Optional[datetime] = None) -> datetime:
322
+ """
323
+ Determine the last complete time point for the metric.
324
+
325
+ Args:
326
+ now: Current time (default: datetime.now(timezone.utc))
327
+
328
+ Returns:
329
+ Last complete timestamp
330
+
331
+ Logic:
332
+ - Floor current time to interval boundary
333
+ - Subtract one interval to get last complete point
334
+ - Example: now=13:23, interval=10min -> 13:10
335
+
336
+ Example:
337
+ >>> orchestrator = AlertOrchestrator("metric", Interval.parse("10min"))
338
+ >>> now = datetime(2024, 1, 1, 13, 23, 0, tzinfo=timezone.utc)
339
+ >>> last_point = orchestrator.get_last_complete_point(now)
340
+ >>> print(last_point)
341
+ 2024-01-01 13:10:00+00:00
342
+ """
343
+ if now is None:
344
+ now = datetime.now(timezone.utc)
345
+
346
+ # Ensure UTC
347
+ if now.tzinfo is None:
348
+ now = now.replace(tzinfo=timezone.utc)
349
+
350
+ # Floor to interval
351
+ interval_seconds = self.interval.seconds
352
+ timestamp_seconds = int(now.timestamp())
353
+ floored_seconds = (timestamp_seconds // interval_seconds) * interval_seconds
354
+
355
+ # Subtract one interval to get last complete point
356
+ last_complete_seconds = floored_seconds - interval_seconds
357
+
358
+ return datetime.fromtimestamp(last_complete_seconds, tz=timezone.utc)
359
+
360
+ def __repr__(self) -> str:
361
+ """String representation."""
362
+ return (
363
+ f"AlertOrchestrator("
364
+ f"metric='{self.metric_name}', "
365
+ f"interval={self.interval}, "
366
+ f"min_detectors={self.conditions.min_detectors}, "
367
+ f"direction='{self.conditions.direction}', "
368
+ f"consecutive={self.conditions.consecutive_anomalies})"
369
+ )
@@ -0,0 +1 @@
1
+ """Command-line interface for detectk."""
@@ -0,0 +1 @@
1
+ """CLI command implementations."""