ai-config-cli 0.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.
@@ -0,0 +1,149 @@
1
+ """Validator framework for ai-config doctor command."""
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+ from typing import TYPE_CHECKING
6
+
7
+ from ai_config.validators.base import ValidationReport, ValidationResult, Validator
8
+ from ai_config.validators.component.hook import HookValidator
9
+ from ai_config.validators.component.mcp import MCPValidator
10
+ from ai_config.validators.component.skill import SkillValidator
11
+ from ai_config.validators.context import ValidationContext
12
+ from ai_config.validators.marketplace.validators import (
13
+ MarketplaceManifestValidator,
14
+ MarketplacePathValidator,
15
+ PathDriftValidator,
16
+ )
17
+ from ai_config.validators.plugin.validators import (
18
+ PluginInstalledValidator,
19
+ PluginManifestValidator,
20
+ PluginStateValidator,
21
+ )
22
+ from ai_config.validators.target.claude import (
23
+ ClaudeCLIResponseValidator,
24
+ ClaudeCLIValidator,
25
+ )
26
+
27
+ if TYPE_CHECKING:
28
+ from ai_config.types import AIConfig
29
+
30
+ __all__ = [
31
+ "ValidationContext",
32
+ "ValidationReport",
33
+ "ValidationResult",
34
+ "Validator",
35
+ "run_validators",
36
+ "VALIDATORS",
37
+ ]
38
+
39
+ # Registry of validators by category
40
+ VALIDATORS: dict[str, list[type]] = {
41
+ "target": [ClaudeCLIValidator, ClaudeCLIResponseValidator],
42
+ "marketplace": [
43
+ MarketplacePathValidator,
44
+ MarketplaceManifestValidator,
45
+ PathDriftValidator,
46
+ ],
47
+ "plugin": [
48
+ PluginInstalledValidator,
49
+ PluginStateValidator,
50
+ PluginManifestValidator,
51
+ ],
52
+ "component": [SkillValidator, HookValidator, MCPValidator],
53
+ }
54
+
55
+
56
+ async def _run_validator(
57
+ validator_cls: type, context: ValidationContext
58
+ ) -> tuple[str, list[ValidationResult]]:
59
+ """Run a single validator and return results with validator name.
60
+
61
+ Args:
62
+ validator_cls: The validator class to instantiate and run.
63
+ context: The validation context.
64
+
65
+ Returns:
66
+ Tuple of (validator_name, results).
67
+ """
68
+ validator = validator_cls()
69
+ try:
70
+ results = await validator.validate(context)
71
+ return validator.name, results
72
+ except Exception as e:
73
+ return validator.name, [
74
+ ValidationResult(
75
+ check_name=f"{validator.name}_error",
76
+ status="fail",
77
+ message=f"Validator {validator.name} raised an exception",
78
+ details=str(e),
79
+ )
80
+ ]
81
+
82
+
83
+ async def run_validators(
84
+ config: "AIConfig",
85
+ config_path: Path,
86
+ categories: list[str] | None = None,
87
+ target_type: str = "claude",
88
+ ) -> dict[str, ValidationReport]:
89
+ """Run validators for specified categories.
90
+
91
+ Args:
92
+ config: The loaded AIConfig.
93
+ config_path: Path to the config file.
94
+ categories: List of categories to run, or None for all.
95
+ target_type: The target type to validate.
96
+
97
+ Returns:
98
+ Dict mapping category names to ValidationReports.
99
+ """
100
+ context = ValidationContext(
101
+ config=config,
102
+ config_path=config_path,
103
+ target_type=target_type,
104
+ )
105
+
106
+ categories_to_run = categories or list(VALIDATORS.keys())
107
+ reports: dict[str, ValidationReport] = {}
108
+
109
+ for category in categories_to_run:
110
+ if category not in VALIDATORS:
111
+ continue
112
+
113
+ validator_classes = VALIDATORS[category]
114
+
115
+ # Run all validators in this category concurrently
116
+ tasks = [_run_validator(validator_cls, context) for validator_cls in validator_classes]
117
+ validator_results = await asyncio.gather(*tasks)
118
+
119
+ # Collect all results for this category
120
+ all_results: list[ValidationResult] = []
121
+ for _validator_name, results in validator_results:
122
+ all_results.extend(results)
123
+
124
+ reports[category] = ValidationReport(
125
+ target=f"{target_type}:{category}",
126
+ results=all_results,
127
+ )
128
+
129
+ return reports
130
+
131
+
132
+ def run_validators_sync(
133
+ config: "AIConfig",
134
+ config_path: Path,
135
+ categories: list[str] | None = None,
136
+ target_type: str = "claude",
137
+ ) -> dict[str, ValidationReport]:
138
+ """Synchronous wrapper for run_validators.
139
+
140
+ Args:
141
+ config: The loaded AIConfig.
142
+ config_path: Path to the config file.
143
+ categories: List of categories to run, or None for all.
144
+ target_type: The target type to validate.
145
+
146
+ Returns:
147
+ Dict mapping category names to ValidationReports.
148
+ """
149
+ return asyncio.run(run_validators(config, config_path, categories, target_type))
@@ -0,0 +1,48 @@
1
+ """Base types and protocol for ai-config validators."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING, Literal, Protocol, runtime_checkable
5
+
6
+ if TYPE_CHECKING:
7
+ from ai_config.validators.context import ValidationContext
8
+
9
+
10
+ @dataclass
11
+ class ValidationResult:
12
+ """Single validation check result."""
13
+
14
+ check_name: str
15
+ status: Literal["pass", "warn", "fail"]
16
+ message: str
17
+ details: str | None = None
18
+ fix_hint: str | None = None
19
+
20
+
21
+ @dataclass
22
+ class ValidationReport:
23
+ """Aggregated results from multiple validators."""
24
+
25
+ target: str
26
+ results: list[ValidationResult]
27
+
28
+ @property
29
+ def passed(self) -> bool:
30
+ """Return True if no failures in results."""
31
+ return not any(r.status == "fail" for r in self.results)
32
+
33
+ @property
34
+ def has_warnings(self) -> bool:
35
+ """Return True if any warnings in results."""
36
+ return any(r.status == "warn" for r in self.results)
37
+
38
+
39
+ @runtime_checkable
40
+ class Validator(Protocol):
41
+ """Base protocol for all validators."""
42
+
43
+ name: str
44
+ description: str
45
+
46
+ async def validate(self, context: "ValidationContext") -> list[ValidationResult]:
47
+ """Run validation checks and return results."""
48
+ ...
@@ -0,0 +1 @@
1
+ """Component validators for ai-config (skills, hooks, MCPs)."""
@@ -0,0 +1,366 @@
1
+ """Hook validators for ai-config.
2
+
3
+ Validates hooks.json configuration per the official Claude Code schema:
4
+ https://code.claude.com/docs/en/hooks
5
+ """
6
+
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+
12
+ from ai_config.validators.base import ValidationResult
13
+
14
+ if TYPE_CHECKING:
15
+ from ai_config.validators.context import ValidationContext
16
+
17
+ # Valid event names per official Claude Code hooks documentation
18
+ VALID_EVENTS = frozenset(
19
+ [
20
+ "SessionStart",
21
+ "UserPromptSubmit",
22
+ "PreToolUse",
23
+ "PostToolUse",
24
+ "PostToolUseFailure",
25
+ "PermissionRequest",
26
+ "Notification",
27
+ "SubagentStart",
28
+ "SubagentStop",
29
+ "Stop",
30
+ "PreCompact",
31
+ "SessionEnd",
32
+ ]
33
+ )
34
+
35
+ # Valid hook types
36
+ VALID_HOOK_TYPES = frozenset(["command", "prompt", "agent"])
37
+
38
+
39
+ class HookValidator:
40
+ """Validates hooks.json configuration for plugins."""
41
+
42
+ name = "hook_validator"
43
+ description = "Validates hooks.json files per official Claude Code schema"
44
+
45
+ async def validate(self, context: "ValidationContext") -> list[ValidationResult]:
46
+ """Validate hooks for all configured plugins.
47
+
48
+ Args:
49
+ context: The validation context.
50
+
51
+ Returns:
52
+ List of validation results.
53
+ """
54
+ results: list[ValidationResult] = []
55
+
56
+ # Create lookup map for installed plugins
57
+ installed_map = {p.id: p for p in context.installed_plugins}
58
+
59
+ for target in context.config.targets:
60
+ if target.type != "claude":
61
+ continue
62
+
63
+ for plugin in target.config.plugins:
64
+ installed = installed_map.get(plugin.id)
65
+ if not installed:
66
+ continue
67
+
68
+ install_path = Path(installed.install_path)
69
+ if not install_path.exists():
70
+ continue
71
+
72
+ # Check for hooks directory and hooks.json
73
+ hooks_dir = install_path / "hooks"
74
+ hooks_json = hooks_dir / "hooks.json"
75
+
76
+ if not hooks_dir.exists():
77
+ # No hooks directory is fine
78
+ continue
79
+
80
+ if not hooks_json.exists():
81
+ # Hooks directory exists but no hooks.json
82
+ results.append(
83
+ ValidationResult(
84
+ check_name="hooks_json_exists",
85
+ status="warn",
86
+ message=f"Plugin '{plugin.id}' has hooks directory but no hooks.json",
87
+ details=f"Expected at: {hooks_json}",
88
+ )
89
+ )
90
+ continue
91
+
92
+ # Validate hooks.json
93
+ try:
94
+ with open(hooks_json) as f:
95
+ hooks_config = json.load(f)
96
+ except json.JSONDecodeError as e:
97
+ results.append(
98
+ ValidationResult(
99
+ check_name="hooks_json_valid",
100
+ status="fail",
101
+ message=f"Plugin '{plugin.id}' has invalid JSON in hooks.json",
102
+ details=str(e),
103
+ )
104
+ )
105
+ continue
106
+ except OSError as e:
107
+ results.append(
108
+ ValidationResult(
109
+ check_name="hooks_json_readable",
110
+ status="fail",
111
+ message=f"Failed to read hooks.json for '{plugin.id}'",
112
+ details=str(e),
113
+ )
114
+ )
115
+ continue
116
+
117
+ if not isinstance(hooks_config, dict):
118
+ results.append(
119
+ ValidationResult(
120
+ check_name="hooks_json_valid",
121
+ status="fail",
122
+ message=f"Plugin '{plugin.id}' hooks.json is not a JSON object",
123
+ )
124
+ )
125
+ continue
126
+
127
+ # Validate hook entries
128
+ hook_results = self._validate_hooks(plugin.id, hooks_dir, hooks_config)
129
+ results.extend(hook_results)
130
+
131
+ if not any(r.status == "fail" for r in hook_results):
132
+ results.append(
133
+ ValidationResult(
134
+ check_name="hooks_valid",
135
+ status="pass",
136
+ message=f"Plugin '{plugin.id}' hooks are valid",
137
+ )
138
+ )
139
+
140
+ return results
141
+
142
+ def _validate_hooks(
143
+ self, plugin_id: str, hooks_dir: Path, hooks_config: dict
144
+ ) -> list[ValidationResult]:
145
+ """Validate hook configuration per official Claude Code schema.
146
+
147
+ Official schema structure:
148
+ {
149
+ "hooks": {
150
+ "EventName": [
151
+ {
152
+ "matcher": "regex-pattern", // optional
153
+ "hooks": [
154
+ { "type": "command|prompt|agent", ... }
155
+ ]
156
+ }
157
+ ]
158
+ }
159
+ }
160
+
161
+ Args:
162
+ plugin_id: The plugin identifier.
163
+ hooks_dir: Path to the hooks directory.
164
+ hooks_config: The parsed hooks.json content.
165
+
166
+ Returns:
167
+ List of validation results.
168
+ """
169
+ results: list[ValidationResult] = []
170
+
171
+ # Check hooks field exists and is object
172
+ hooks_obj = hooks_config.get("hooks")
173
+ if hooks_obj is None:
174
+ # No hooks field is fine (empty config)
175
+ return results
176
+
177
+ if not isinstance(hooks_obj, dict):
178
+ results.append(
179
+ ValidationResult(
180
+ check_name="hooks_format",
181
+ status="fail",
182
+ message=f"Plugin '{plugin_id}' hooks.json 'hooks' must be an object (dict)",
183
+ details="The 'hooks' field should map event names to handler arrays",
184
+ fix_hint='Use format: {"hooks": {"EventName": [...]}}',
185
+ )
186
+ )
187
+ return results
188
+
189
+ # Validate each event
190
+ for event_name, handlers in hooks_obj.items():
191
+ # Validate event name
192
+ if event_name not in VALID_EVENTS:
193
+ results.append(
194
+ ValidationResult(
195
+ check_name="hooks_event_name",
196
+ status="fail",
197
+ message=f"Plugin '{plugin_id}' has invalid event name: '{event_name}'",
198
+ details=f"Valid events: {', '.join(sorted(VALID_EVENTS))}",
199
+ )
200
+ )
201
+ continue
202
+
203
+ # Validate handlers is array
204
+ if not isinstance(handlers, list):
205
+ results.append(
206
+ ValidationResult(
207
+ check_name="hooks_handlers_format",
208
+ status="fail",
209
+ message=(
210
+ f"Plugin '{plugin_id}' event '{event_name}' "
211
+ "handlers must be an array (list)"
212
+ ),
213
+ )
214
+ )
215
+ continue
216
+
217
+ # Validate each handler group
218
+ for i, handler_group in enumerate(handlers):
219
+ if not isinstance(handler_group, dict):
220
+ results.append(
221
+ ValidationResult(
222
+ check_name="hooks_handler_format",
223
+ status="fail",
224
+ message=f"Plugin '{plugin_id}' {event_name}[{i}] must be an object",
225
+ )
226
+ )
227
+ continue
228
+
229
+ # Validate matcher if present (optional string)
230
+ matcher = handler_group.get("matcher")
231
+ if matcher is not None and not isinstance(matcher, str):
232
+ results.append(
233
+ ValidationResult(
234
+ check_name="hooks_matcher_format",
235
+ status="fail",
236
+ message=(
237
+ f"Plugin '{plugin_id}' {event_name}[{i}] matcher must be a string"
238
+ ),
239
+ )
240
+ )
241
+
242
+ # Validate hooks array within handler group
243
+ hooks_array = handler_group.get("hooks", [])
244
+ if not isinstance(hooks_array, list):
245
+ results.append(
246
+ ValidationResult(
247
+ check_name="hooks_array_format",
248
+ status="fail",
249
+ message=(
250
+ f"Plugin '{plugin_id}' {event_name}[{i}].hooks must be an array"
251
+ ),
252
+ )
253
+ )
254
+ continue
255
+
256
+ # Validate each hook in the array
257
+ for j, hook in enumerate(hooks_array):
258
+ hook_results = self._validate_hook_entry(
259
+ plugin_id, event_name, i, j, hooks_dir, hook
260
+ )
261
+ results.extend(hook_results)
262
+
263
+ return results
264
+
265
+ def _validate_hook_entry(
266
+ self,
267
+ plugin_id: str,
268
+ event_name: str,
269
+ handler_idx: int,
270
+ hook_idx: int,
271
+ hooks_dir: Path,
272
+ hook: dict,
273
+ ) -> list[ValidationResult]:
274
+ """Validate a single hook entry.
275
+
276
+ Args:
277
+ plugin_id: The plugin identifier.
278
+ event_name: The event name.
279
+ handler_idx: Index of the handler group.
280
+ hook_idx: Index of the hook within hooks array.
281
+ hooks_dir: Path to the hooks directory.
282
+ hook: The hook configuration dict.
283
+
284
+ Returns:
285
+ List of validation results.
286
+ """
287
+ results: list[ValidationResult] = []
288
+ location = f"{event_name}[{handler_idx}].hooks[{hook_idx}]"
289
+
290
+ if not isinstance(hook, dict):
291
+ results.append(
292
+ ValidationResult(
293
+ check_name="hook_format",
294
+ status="fail",
295
+ message=f"Plugin '{plugin_id}' {location} must be an object",
296
+ )
297
+ )
298
+ return results
299
+
300
+ # Validate type field (required)
301
+ hook_type = hook.get("type")
302
+ if not hook_type:
303
+ results.append(
304
+ ValidationResult(
305
+ check_name="hook_type_required",
306
+ status="fail",
307
+ message=f"Plugin '{plugin_id}' {location} is missing required 'type' field",
308
+ fix_hint="Add 'type': 'command', 'prompt', or 'agent'",
309
+ )
310
+ )
311
+ return results
312
+
313
+ if hook_type not in VALID_HOOK_TYPES:
314
+ results.append(
315
+ ValidationResult(
316
+ check_name="hook_type_valid",
317
+ status="fail",
318
+ message=f"Plugin '{plugin_id}' {location} has invalid type: '{hook_type}'",
319
+ details=f"Valid types: {', '.join(sorted(VALID_HOOK_TYPES))}",
320
+ )
321
+ )
322
+ return results
323
+
324
+ # Validate required fields per type
325
+ if hook_type == "command":
326
+ command = hook.get("command")
327
+ if not command:
328
+ results.append(
329
+ ValidationResult(
330
+ check_name="hook_command_required",
331
+ status="fail",
332
+ message=(
333
+ f"Plugin '{plugin_id}' {location} command hook "
334
+ "is missing 'command' field"
335
+ ),
336
+ )
337
+ )
338
+ elif isinstance(command, str):
339
+ # Check if command is a relative path to a script
340
+ script_path = hooks_dir / command
341
+ if script_path.exists():
342
+ if not os.access(script_path, os.X_OK):
343
+ results.append(
344
+ ValidationResult(
345
+ check_name="hook_executable",
346
+ status="warn",
347
+ message=f"Plugin '{plugin_id}' hook script not executable",
348
+ details=f"Script: {command}",
349
+ fix_hint=f"Run: chmod +x {script_path}",
350
+ )
351
+ )
352
+ else: # prompt or agent
353
+ prompt = hook.get("prompt")
354
+ if not prompt:
355
+ results.append(
356
+ ValidationResult(
357
+ check_name="hook_prompt_required",
358
+ status="fail",
359
+ message=(
360
+ f"Plugin '{plugin_id}' {location} {hook_type} hook "
361
+ "is missing 'prompt' field"
362
+ ),
363
+ )
364
+ )
365
+
366
+ return results