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.
- ai_config/__init__.py +3 -0
- ai_config/__main__.py +6 -0
- ai_config/adapters/__init__.py +1 -0
- ai_config/adapters/claude.py +353 -0
- ai_config/cli.py +729 -0
- ai_config/cli_render.py +525 -0
- ai_config/cli_theme.py +44 -0
- ai_config/config.py +260 -0
- ai_config/init.py +763 -0
- ai_config/operations.py +357 -0
- ai_config/scaffold.py +87 -0
- ai_config/settings.py +63 -0
- ai_config/types.py +143 -0
- ai_config/validators/__init__.py +149 -0
- ai_config/validators/base.py +48 -0
- ai_config/validators/component/__init__.py +1 -0
- ai_config/validators/component/hook.py +366 -0
- ai_config/validators/component/mcp.py +230 -0
- ai_config/validators/component/skill.py +411 -0
- ai_config/validators/context.py +69 -0
- ai_config/validators/marketplace/__init__.py +1 -0
- ai_config/validators/marketplace/validators.py +433 -0
- ai_config/validators/plugin/__init__.py +1 -0
- ai_config/validators/plugin/validators.py +336 -0
- ai_config/validators/target/__init__.py +1 -0
- ai_config/validators/target/claude.py +154 -0
- ai_config/watch.py +279 -0
- ai_config_cli-0.1.0.dist-info/METADATA +235 -0
- ai_config_cli-0.1.0.dist-info/RECORD +32 -0
- ai_config_cli-0.1.0.dist-info/WHEEL +4 -0
- ai_config_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ai_config_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|