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,336 @@
|
|
|
1
|
+
"""Plugin validators for ai-config.
|
|
2
|
+
|
|
3
|
+
Validates plugin.json manifests per the official Claude Code schema:
|
|
4
|
+
https://code.claude.com/docs/en/plugins-reference
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
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
|
+
|
|
18
|
+
# Pattern for valid kebab-case names (lowercase letters, numbers, hyphens)
|
|
19
|
+
KEBAB_CASE_PATTERN = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def is_kebab_case(name: str) -> bool:
|
|
23
|
+
"""Check if a name is valid kebab-case.
|
|
24
|
+
|
|
25
|
+
Valid kebab-case:
|
|
26
|
+
- Lowercase letters and numbers only
|
|
27
|
+
- Words separated by single hyphens
|
|
28
|
+
- No spaces, underscores, or other special characters
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
name: The name to validate.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
True if valid kebab-case, False otherwise.
|
|
35
|
+
"""
|
|
36
|
+
if not name:
|
|
37
|
+
return False
|
|
38
|
+
return bool(KEBAB_CASE_PATTERN.match(name))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PluginInstalledValidator:
|
|
42
|
+
"""Validates that configured plugins are installed in Claude."""
|
|
43
|
+
|
|
44
|
+
name = "plugin_installed"
|
|
45
|
+
description = "Validates that plugins in config are installed"
|
|
46
|
+
|
|
47
|
+
async def validate(self, context: "ValidationContext") -> list[ValidationResult]:
|
|
48
|
+
"""Validate plugin installation status.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
context: The validation context.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
List of validation results.
|
|
55
|
+
"""
|
|
56
|
+
results: list[ValidationResult] = []
|
|
57
|
+
|
|
58
|
+
# Get installed plugin IDs
|
|
59
|
+
installed_ids = {p.id for p in context.installed_plugins}
|
|
60
|
+
|
|
61
|
+
for target in context.config.targets:
|
|
62
|
+
if target.type != "claude":
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
for plugin in target.config.plugins:
|
|
66
|
+
if plugin.id in installed_ids:
|
|
67
|
+
results.append(
|
|
68
|
+
ValidationResult(
|
|
69
|
+
check_name="plugin_installed",
|
|
70
|
+
status="pass",
|
|
71
|
+
message=f"Plugin '{plugin.id}' is installed",
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
else:
|
|
75
|
+
results.append(
|
|
76
|
+
ValidationResult(
|
|
77
|
+
check_name="plugin_installed",
|
|
78
|
+
status="fail",
|
|
79
|
+
message=f"Plugin '{plugin.id}' is not installed",
|
|
80
|
+
fix_hint="Run: ai-config sync",
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
return results
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class PluginStateValidator:
|
|
88
|
+
"""Validates that plugin enabled/disabled state matches config."""
|
|
89
|
+
|
|
90
|
+
name = "plugin_state"
|
|
91
|
+
description = "Validates plugin enabled/disabled state matches configuration"
|
|
92
|
+
|
|
93
|
+
async def validate(self, context: "ValidationContext") -> list[ValidationResult]:
|
|
94
|
+
"""Validate plugin state.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
context: The validation context.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
List of validation results.
|
|
101
|
+
"""
|
|
102
|
+
results: list[ValidationResult] = []
|
|
103
|
+
|
|
104
|
+
# Create lookup map for installed plugins
|
|
105
|
+
installed_map = {p.id: p for p in context.installed_plugins}
|
|
106
|
+
|
|
107
|
+
for target in context.config.targets:
|
|
108
|
+
if target.type != "claude":
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
for plugin in target.config.plugins:
|
|
112
|
+
installed = installed_map.get(plugin.id)
|
|
113
|
+
if not installed:
|
|
114
|
+
# Plugin not installed, skip state check
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
if plugin.enabled and not installed.enabled:
|
|
118
|
+
results.append(
|
|
119
|
+
ValidationResult(
|
|
120
|
+
check_name="plugin_enabled_state",
|
|
121
|
+
status="fail",
|
|
122
|
+
message=f"Plugin '{plugin.id}' should be enabled but is disabled",
|
|
123
|
+
fix_hint=f"Run: claude plugin enable {plugin.id}",
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
elif not plugin.enabled and installed.enabled:
|
|
127
|
+
results.append(
|
|
128
|
+
ValidationResult(
|
|
129
|
+
check_name="plugin_enabled_state",
|
|
130
|
+
status="fail",
|
|
131
|
+
message=f"Plugin '{plugin.id}' should be disabled but is enabled",
|
|
132
|
+
fix_hint=f"Run: claude plugin disable {plugin.id}",
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
else:
|
|
136
|
+
results.append(
|
|
137
|
+
ValidationResult(
|
|
138
|
+
check_name="plugin_enabled_state",
|
|
139
|
+
status="pass",
|
|
140
|
+
message=f"Plugin '{plugin.id}' state matches config",
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
return results
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class PluginManifestValidator:
|
|
148
|
+
"""Validates that plugins have valid plugin.json manifests.
|
|
149
|
+
|
|
150
|
+
Per the official Claude Code plugin schema:
|
|
151
|
+
- name: Required, must be kebab-case (no spaces, no uppercase)
|
|
152
|
+
- version: Optional (semantic version)
|
|
153
|
+
- All paths must start with ./ if specified
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
name = "plugin_manifest"
|
|
157
|
+
description = "Validates plugin.json manifest files"
|
|
158
|
+
|
|
159
|
+
async def validate(self, context: "ValidationContext") -> list[ValidationResult]:
|
|
160
|
+
"""Validate plugin manifest files.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
context: The validation context.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
List of validation results.
|
|
167
|
+
"""
|
|
168
|
+
results: list[ValidationResult] = []
|
|
169
|
+
|
|
170
|
+
# Create lookup map for installed plugins
|
|
171
|
+
installed_map = {p.id: p for p in context.installed_plugins}
|
|
172
|
+
|
|
173
|
+
for target in context.config.targets:
|
|
174
|
+
if target.type != "claude":
|
|
175
|
+
continue
|
|
176
|
+
|
|
177
|
+
for plugin in target.config.plugins:
|
|
178
|
+
installed = installed_map.get(plugin.id)
|
|
179
|
+
if not installed:
|
|
180
|
+
# Plugin not installed, skip manifest check
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
install_path = Path(installed.install_path)
|
|
184
|
+
if not install_path.exists():
|
|
185
|
+
results.append(
|
|
186
|
+
ValidationResult(
|
|
187
|
+
check_name="plugin_install_path_exists",
|
|
188
|
+
status="fail",
|
|
189
|
+
message=f"Plugin '{plugin.id}' install path missing",
|
|
190
|
+
details=f"Path: {install_path}",
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
continue
|
|
194
|
+
|
|
195
|
+
manifest_path = install_path / ".claude-plugin" / "plugin.json"
|
|
196
|
+
if not manifest_path.exists():
|
|
197
|
+
results.append(
|
|
198
|
+
ValidationResult(
|
|
199
|
+
check_name="plugin_manifest_exists",
|
|
200
|
+
status="fail",
|
|
201
|
+
message=f"Plugin '{plugin.id}' is missing plugin.json",
|
|
202
|
+
details=f"Expected at: {manifest_path}",
|
|
203
|
+
fix_hint="Create a plugin.json manifest file",
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
# Validate manifest JSON
|
|
209
|
+
try:
|
|
210
|
+
with open(manifest_path) as f:
|
|
211
|
+
manifest = json.load(f)
|
|
212
|
+
|
|
213
|
+
manifest_results = self._validate_manifest(plugin.id, manifest)
|
|
214
|
+
results.extend(manifest_results)
|
|
215
|
+
|
|
216
|
+
if not any(r.status == "fail" for r in manifest_results):
|
|
217
|
+
results.append(
|
|
218
|
+
ValidationResult(
|
|
219
|
+
check_name="plugin_manifest_valid",
|
|
220
|
+
status="pass",
|
|
221
|
+
message=f"Plugin '{plugin.id}' manifest is valid",
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
except json.JSONDecodeError as e:
|
|
226
|
+
results.append(
|
|
227
|
+
ValidationResult(
|
|
228
|
+
check_name="plugin_manifest_valid",
|
|
229
|
+
status="fail",
|
|
230
|
+
message=f"Plugin '{plugin.id}' has invalid JSON in plugin.json",
|
|
231
|
+
details=str(e),
|
|
232
|
+
fix_hint="Fix the JSON syntax in plugin.json",
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
except OSError as e:
|
|
236
|
+
results.append(
|
|
237
|
+
ValidationResult(
|
|
238
|
+
check_name="plugin_manifest_readable",
|
|
239
|
+
status="fail",
|
|
240
|
+
message=f"Failed to read plugin.json for '{plugin.id}'",
|
|
241
|
+
details=str(e),
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
return results
|
|
246
|
+
|
|
247
|
+
def _validate_manifest(self, plugin_id: str, manifest: dict) -> list[ValidationResult]:
|
|
248
|
+
"""Validate plugin manifest content.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
plugin_id: The plugin identifier.
|
|
252
|
+
manifest: The parsed plugin.json content.
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
List of validation results.
|
|
256
|
+
"""
|
|
257
|
+
results: list[ValidationResult] = []
|
|
258
|
+
|
|
259
|
+
if not isinstance(manifest, dict):
|
|
260
|
+
results.append(
|
|
261
|
+
ValidationResult(
|
|
262
|
+
check_name="plugin_manifest_valid",
|
|
263
|
+
status="fail",
|
|
264
|
+
message=f"Plugin '{plugin_id}' manifest is not a JSON object",
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
return results
|
|
268
|
+
|
|
269
|
+
# Check required name field
|
|
270
|
+
name = manifest.get("name")
|
|
271
|
+
if not name:
|
|
272
|
+
results.append(
|
|
273
|
+
ValidationResult(
|
|
274
|
+
check_name="plugin_manifest_name_required",
|
|
275
|
+
status="fail",
|
|
276
|
+
message=f"Plugin '{plugin_id}' manifest is missing required 'name' field",
|
|
277
|
+
fix_hint="Add 'name' field to plugin.json",
|
|
278
|
+
)
|
|
279
|
+
)
|
|
280
|
+
elif not isinstance(name, str):
|
|
281
|
+
results.append(
|
|
282
|
+
ValidationResult(
|
|
283
|
+
check_name="plugin_manifest_name_type",
|
|
284
|
+
status="fail",
|
|
285
|
+
message=f"Plugin '{plugin_id}' manifest 'name' must be a string",
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
elif not is_kebab_case(name):
|
|
289
|
+
results.append(
|
|
290
|
+
ValidationResult(
|
|
291
|
+
check_name="plugin_manifest_name_format",
|
|
292
|
+
status="fail",
|
|
293
|
+
message=f"Plugin '{plugin_id}' manifest 'name' must be kebab-case",
|
|
294
|
+
details=f"Got: '{name}'. Use lowercase letters, numbers, and hyphens only.",
|
|
295
|
+
fix_hint="Rename to use kebab-case (e.g., 'my-plugin' not 'My-Plugin')",
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
# Note: version is optional per official schema, so we don't require it
|
|
300
|
+
|
|
301
|
+
# Validate component paths if present (should start with ./)
|
|
302
|
+
path_fields = [
|
|
303
|
+
"commands",
|
|
304
|
+
"agents",
|
|
305
|
+
"skills",
|
|
306
|
+
"hooks",
|
|
307
|
+
"mcpServers",
|
|
308
|
+
"outputStyles",
|
|
309
|
+
"lspServers",
|
|
310
|
+
]
|
|
311
|
+
for field in path_fields:
|
|
312
|
+
value = manifest.get(field)
|
|
313
|
+
if value is not None:
|
|
314
|
+
if isinstance(value, str):
|
|
315
|
+
paths = [value]
|
|
316
|
+
elif isinstance(value, list):
|
|
317
|
+
paths = value
|
|
318
|
+
else:
|
|
319
|
+
paths = []
|
|
320
|
+
for path in paths:
|
|
321
|
+
is_relative = path.startswith("./")
|
|
322
|
+
is_variable = path.startswith("${")
|
|
323
|
+
if isinstance(path, str) and not is_relative and not is_variable:
|
|
324
|
+
results.append(
|
|
325
|
+
ValidationResult(
|
|
326
|
+
check_name="plugin_manifest_path_format",
|
|
327
|
+
status="warn",
|
|
328
|
+
message=(
|
|
329
|
+
f"Plugin '{plugin_id}' manifest '{field}' "
|
|
330
|
+
"paths should start with './'"
|
|
331
|
+
),
|
|
332
|
+
details=f"Got: '{path}'",
|
|
333
|
+
)
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
return results
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Target validators for ai-config."""
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Claude CLI target validators for ai-config."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from ai_config.validators.base import ValidationResult
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from ai_config.validators.context import ValidationContext
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ClaudeCLIValidator:
|
|
13
|
+
"""Validates that Claude CLI is available and functioning."""
|
|
14
|
+
|
|
15
|
+
name = "claude_cli"
|
|
16
|
+
description = "Validates Claude CLI availability"
|
|
17
|
+
|
|
18
|
+
async def validate(self, context: "ValidationContext") -> list[ValidationResult]:
|
|
19
|
+
"""Validate Claude CLI is available.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
context: The validation context.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
List of validation results.
|
|
26
|
+
"""
|
|
27
|
+
results: list[ValidationResult] = []
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
result = subprocess.run(
|
|
31
|
+
["claude", "--version"],
|
|
32
|
+
capture_output=True,
|
|
33
|
+
text=True,
|
|
34
|
+
timeout=10,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if result.returncode == 0:
|
|
38
|
+
version = result.stdout.strip()
|
|
39
|
+
results.append(
|
|
40
|
+
ValidationResult(
|
|
41
|
+
check_name="claude_cli_available",
|
|
42
|
+
status="pass",
|
|
43
|
+
message=f"Claude CLI available ({version})",
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
else:
|
|
47
|
+
results.append(
|
|
48
|
+
ValidationResult(
|
|
49
|
+
check_name="claude_cli_available",
|
|
50
|
+
status="fail",
|
|
51
|
+
message="Claude CLI returned error",
|
|
52
|
+
details=result.stderr,
|
|
53
|
+
fix_hint="Reinstall Claude Code: npm install -g @anthropic-ai/claude-code",
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
except FileNotFoundError:
|
|
58
|
+
results.append(
|
|
59
|
+
ValidationResult(
|
|
60
|
+
check_name="claude_cli_available",
|
|
61
|
+
status="fail",
|
|
62
|
+
message="Claude CLI not found",
|
|
63
|
+
fix_hint="Install Claude Code: npm install -g @anthropic-ai/claude-code",
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
except subprocess.TimeoutExpired:
|
|
67
|
+
results.append(
|
|
68
|
+
ValidationResult(
|
|
69
|
+
check_name="claude_cli_available",
|
|
70
|
+
status="fail",
|
|
71
|
+
message="Claude CLI timed out",
|
|
72
|
+
details="The claude --version command took too long to respond",
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
except OSError as e:
|
|
76
|
+
results.append(
|
|
77
|
+
ValidationResult(
|
|
78
|
+
check_name="claude_cli_available",
|
|
79
|
+
status="fail",
|
|
80
|
+
message="Failed to run Claude CLI",
|
|
81
|
+
details=str(e),
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
return results
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ClaudeCLIResponseValidator:
|
|
89
|
+
"""Validates that Claude CLI responds to commands."""
|
|
90
|
+
|
|
91
|
+
name = "claude_cli_response"
|
|
92
|
+
description = "Validates Claude CLI responds to plugin commands"
|
|
93
|
+
|
|
94
|
+
async def validate(self, context: "ValidationContext") -> list[ValidationResult]:
|
|
95
|
+
"""Validate Claude CLI responds to commands.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
context: The validation context.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
List of validation results.
|
|
102
|
+
"""
|
|
103
|
+
results: list[ValidationResult] = []
|
|
104
|
+
|
|
105
|
+
# Try a simple plugin list command to verify CLI is working
|
|
106
|
+
try:
|
|
107
|
+
result = subprocess.run(
|
|
108
|
+
["claude", "plugin", "list", "--json"],
|
|
109
|
+
capture_output=True,
|
|
110
|
+
text=True,
|
|
111
|
+
timeout=30,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if result.returncode == 0:
|
|
115
|
+
results.append(
|
|
116
|
+
ValidationResult(
|
|
117
|
+
check_name="claude_cli_responds",
|
|
118
|
+
status="pass",
|
|
119
|
+
message="Claude CLI responds to commands",
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
else:
|
|
123
|
+
results.append(
|
|
124
|
+
ValidationResult(
|
|
125
|
+
check_name="claude_cli_responds",
|
|
126
|
+
status="fail",
|
|
127
|
+
message="Claude CLI plugin command failed",
|
|
128
|
+
details=result.stderr,
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
except FileNotFoundError:
|
|
133
|
+
# Already caught by ClaudeCLIValidator
|
|
134
|
+
pass
|
|
135
|
+
except subprocess.TimeoutExpired:
|
|
136
|
+
results.append(
|
|
137
|
+
ValidationResult(
|
|
138
|
+
check_name="claude_cli_responds",
|
|
139
|
+
status="fail",
|
|
140
|
+
message="Claude CLI command timed out",
|
|
141
|
+
details="The plugin list command took too long",
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
except OSError as e:
|
|
145
|
+
results.append(
|
|
146
|
+
ValidationResult(
|
|
147
|
+
check_name="claude_cli_responds",
|
|
148
|
+
status="fail",
|
|
149
|
+
message="Failed to run Claude CLI command",
|
|
150
|
+
details=str(e),
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
return results
|