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
ai_config/cli_render.py
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
"""Rendering helpers for ai-config doctor output.
|
|
2
|
+
|
|
3
|
+
This module groups validation results by entity (plugin, marketplace, skill, etc.)
|
|
4
|
+
and renders them in a human-friendly format for the CLI.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import TYPE_CHECKING, Literal
|
|
10
|
+
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from ai_config.cli_theme import SYMBOLS
|
|
14
|
+
from ai_config.types import AIConfig, PluginSource
|
|
15
|
+
from ai_config.validators.base import ValidationReport, ValidationResult
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
|
|
20
|
+
EntityType = Literal["target", "marketplace", "plugin", "skill", "hook", "mcp"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class EntityResult:
|
|
25
|
+
"""Grouped validation results for a single entity."""
|
|
26
|
+
|
|
27
|
+
entity_type: EntityType
|
|
28
|
+
entity_name: str
|
|
29
|
+
passed: list[ValidationResult] = field(default_factory=list)
|
|
30
|
+
warnings: list[ValidationResult] = field(default_factory=list)
|
|
31
|
+
failures: list[ValidationResult] = field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def has_issues(self) -> bool:
|
|
35
|
+
"""Return True if there are warnings or failures."""
|
|
36
|
+
return bool(self.warnings or self.failures)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def status_symbol(self) -> str:
|
|
40
|
+
"""Return status symbol for display."""
|
|
41
|
+
if self.failures:
|
|
42
|
+
return f"[red]{SYMBOLS['fail']}[/red]"
|
|
43
|
+
if self.warnings:
|
|
44
|
+
return f"[yellow]{SYMBOLS['warn']}[/yellow]"
|
|
45
|
+
return f"[green]{SYMBOLS['pass']}[/green]"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def extract_entity_from_result(result: ValidationResult) -> tuple[EntityType, str] | None:
|
|
49
|
+
"""Extract entity type and name from a ValidationResult message.
|
|
50
|
+
|
|
51
|
+
Parses patterns like:
|
|
52
|
+
- "Plugin 'alex-ai@dots-plugins' is installed"
|
|
53
|
+
- "Marketplace 'dots-plugins' path exists"
|
|
54
|
+
- "Skill name 'python-core' is valid"
|
|
55
|
+
- "Claude CLI available (claude 2.1.29)"
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
result: The validation result to parse.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Tuple of (entity_type, entity_name) or None if not extractable.
|
|
62
|
+
"""
|
|
63
|
+
message = result.message
|
|
64
|
+
check_name = result.check_name
|
|
65
|
+
|
|
66
|
+
# Target/Claude CLI checks
|
|
67
|
+
if check_name.startswith("claude_cli"):
|
|
68
|
+
# Extract version if present: "Claude CLI available (claude 2.1.29)"
|
|
69
|
+
version_match = re.search(r"\((.*?)\)", message)
|
|
70
|
+
version = version_match.group(1) if version_match else "claude"
|
|
71
|
+
return ("target", version)
|
|
72
|
+
|
|
73
|
+
# Hook checks - check before plugin since hooks also reference plugins
|
|
74
|
+
# Check check_name first to properly categorize
|
|
75
|
+
if "hook" in check_name.lower():
|
|
76
|
+
plugin_match = re.search(r"Plugin '([^']+)'", message)
|
|
77
|
+
if plugin_match:
|
|
78
|
+
return ("hook", plugin_match.group(1))
|
|
79
|
+
|
|
80
|
+
# MCP checks - check before plugin since MCPs also reference plugins
|
|
81
|
+
if "mcp" in check_name.lower():
|
|
82
|
+
# Try server name first: "MCP server 'server-name'"
|
|
83
|
+
server_match = re.search(r"MCP server '([^']+)'", message)
|
|
84
|
+
if server_match:
|
|
85
|
+
return ("mcp", server_match.group(1))
|
|
86
|
+
# Fall back to plugin ID
|
|
87
|
+
plugin_match = re.search(r"Plugin '([^']+)'", message)
|
|
88
|
+
if plugin_match:
|
|
89
|
+
return ("mcp", plugin_match.group(1))
|
|
90
|
+
|
|
91
|
+
# Plugin checks - look for plugin ID in quotes
|
|
92
|
+
if "plugin" in check_name.lower() or "Plugin '" in message:
|
|
93
|
+
plugin_match = re.search(r"Plugin '([^']+)'", message)
|
|
94
|
+
if plugin_match:
|
|
95
|
+
return ("plugin", plugin_match.group(1))
|
|
96
|
+
|
|
97
|
+
# Marketplace checks - look for marketplace name in quotes
|
|
98
|
+
if "marketplace" in check_name.lower() or "Marketplace '" in message:
|
|
99
|
+
mp_match = re.search(r"Marketplace '([^']+)'", message)
|
|
100
|
+
if mp_match:
|
|
101
|
+
return ("marketplace", mp_match.group(1))
|
|
102
|
+
|
|
103
|
+
# Skill checks - look for skill name
|
|
104
|
+
if "skill" in check_name.lower() or "Skill" in message:
|
|
105
|
+
# Patterns: "Skill name 'foo'", "SKILL.md not found in /path/skills/foo"
|
|
106
|
+
skill_match = re.search(r"Skill name '([^']+)'", message)
|
|
107
|
+
if skill_match:
|
|
108
|
+
return ("skill", skill_match.group(1))
|
|
109
|
+
# Fallback: extract from path like "/path/skills/skill-name"
|
|
110
|
+
path_match = re.search(r"/skills/([^/]+)", message)
|
|
111
|
+
if path_match:
|
|
112
|
+
return ("skill", path_match.group(1))
|
|
113
|
+
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def group_results_by_entity(
|
|
118
|
+
results: list[ValidationResult],
|
|
119
|
+
) -> dict[EntityType, dict[str, EntityResult]]:
|
|
120
|
+
"""Group validation results by entity type and name.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
results: List of validation results to group.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
Nested dict: entity_type -> entity_name -> EntityResult
|
|
127
|
+
"""
|
|
128
|
+
grouped: dict[EntityType, dict[str, EntityResult]] = {}
|
|
129
|
+
|
|
130
|
+
for result in results:
|
|
131
|
+
entity_info = extract_entity_from_result(result)
|
|
132
|
+
if entity_info is None:
|
|
133
|
+
# Skip results we can't categorize
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
entity_type, entity_name = entity_info
|
|
137
|
+
|
|
138
|
+
if entity_type not in grouped:
|
|
139
|
+
grouped[entity_type] = {}
|
|
140
|
+
|
|
141
|
+
if entity_name not in grouped[entity_type]:
|
|
142
|
+
grouped[entity_type][entity_name] = EntityResult(
|
|
143
|
+
entity_type=entity_type,
|
|
144
|
+
entity_name=entity_name,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
entity_result = grouped[entity_type][entity_name]
|
|
148
|
+
if result.status == "pass":
|
|
149
|
+
entity_result.passed.append(result)
|
|
150
|
+
elif result.status == "warn":
|
|
151
|
+
entity_result.warnings.append(result)
|
|
152
|
+
else: # fail
|
|
153
|
+
entity_result.failures.append(result)
|
|
154
|
+
|
|
155
|
+
return grouped
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def extract_claude_version(reports: dict[str, ValidationReport]) -> str | None:
|
|
159
|
+
"""Extract Claude CLI version from target validation results.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
reports: Dict of category -> ValidationReport.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
Version string like "2.1.29 (Claude Code)" or None if not found.
|
|
166
|
+
"""
|
|
167
|
+
target_report = reports.get("target")
|
|
168
|
+
if not target_report:
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
for result in target_report.results:
|
|
172
|
+
if result.check_name == "claude_cli_available" and result.status == "pass":
|
|
173
|
+
# Parse "Claude CLI available (2.1.29 (Claude Code))"
|
|
174
|
+
# Use greedy match to handle nested parentheses
|
|
175
|
+
match = re.search(r"\((.+)\)", result.message)
|
|
176
|
+
if match:
|
|
177
|
+
return match.group(1)
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def count_by_status(
|
|
182
|
+
results: list[ValidationResult],
|
|
183
|
+
) -> tuple[int, int, int]:
|
|
184
|
+
"""Count results by status.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
results: List of validation results.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
Tuple of (pass_count, warn_count, fail_count).
|
|
191
|
+
"""
|
|
192
|
+
pass_count = sum(1 for r in results if r.status == "pass")
|
|
193
|
+
warn_count = sum(1 for r in results if r.status == "warn")
|
|
194
|
+
fail_count = sum(1 for r in results if r.status == "fail")
|
|
195
|
+
return pass_count, warn_count, fail_count
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def render_doctor_output(
|
|
199
|
+
reports: dict[str, ValidationReport],
|
|
200
|
+
config: AIConfig,
|
|
201
|
+
console: "Console",
|
|
202
|
+
verbose: bool = False,
|
|
203
|
+
) -> tuple[int, int, int]:
|
|
204
|
+
"""Render the improved doctor output.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
reports: Dict of category -> ValidationReport from validators.
|
|
208
|
+
config: The loaded AIConfig.
|
|
209
|
+
console: Rich Console instance for output.
|
|
210
|
+
verbose: If True, show all individual checks.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
Tuple of (pass_count, warn_count, fail_count).
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
total_pass = 0
|
|
217
|
+
total_warn = 0
|
|
218
|
+
total_fail = 0
|
|
219
|
+
|
|
220
|
+
# Collect all results for grouping
|
|
221
|
+
all_results: list[ValidationResult] = []
|
|
222
|
+
for report in reports.values():
|
|
223
|
+
all_results.extend(report.results)
|
|
224
|
+
|
|
225
|
+
# Count totals
|
|
226
|
+
total_pass, total_warn, total_fail = count_by_status(all_results)
|
|
227
|
+
|
|
228
|
+
# Group results by entity
|
|
229
|
+
grouped = group_results_by_entity(all_results)
|
|
230
|
+
|
|
231
|
+
# === Target Section ===
|
|
232
|
+
console.print("[bold]Target:[/bold] claude")
|
|
233
|
+
claude_version = extract_claude_version(reports)
|
|
234
|
+
if claude_version:
|
|
235
|
+
console.print(f" Claude CLI {claude_version}")
|
|
236
|
+
else:
|
|
237
|
+
# Check for failure
|
|
238
|
+
target_entities = grouped.get("target", {})
|
|
239
|
+
for _entity_name, entity_result in target_entities.items():
|
|
240
|
+
if entity_result.failures:
|
|
241
|
+
for failure in entity_result.failures:
|
|
242
|
+
console.print(f" [red]{SYMBOLS['fail']}[/red] {failure.message}")
|
|
243
|
+
if failure.fix_hint:
|
|
244
|
+
console.print(f" [hint]Fix:[/hint] {failure.fix_hint}")
|
|
245
|
+
console.print()
|
|
246
|
+
|
|
247
|
+
# === Marketplaces Section ===
|
|
248
|
+
_render_marketplaces_section(console, config, grouped, verbose)
|
|
249
|
+
|
|
250
|
+
# === Plugins Section ===
|
|
251
|
+
_render_plugins_section(console, config, grouped, verbose)
|
|
252
|
+
|
|
253
|
+
# === Components Section ===
|
|
254
|
+
_render_components_section(console, grouped, verbose)
|
|
255
|
+
|
|
256
|
+
# === Summary ===
|
|
257
|
+
console.print("[bold]Summary:[/bold]", end=" ")
|
|
258
|
+
parts = []
|
|
259
|
+
if total_fail > 0:
|
|
260
|
+
parts.append(f"[red]{total_fail} error{'s' if total_fail != 1 else ''}[/red]")
|
|
261
|
+
if total_warn > 0:
|
|
262
|
+
parts.append(f"[yellow]{total_warn} warning{'s' if total_warn != 1 else ''}[/yellow]")
|
|
263
|
+
if total_pass > 0:
|
|
264
|
+
parts.append(f"[green]{total_pass} passed[/green]")
|
|
265
|
+
|
|
266
|
+
if parts:
|
|
267
|
+
console.print(", ".join(parts))
|
|
268
|
+
else:
|
|
269
|
+
console.print(f"[green]{SYMBOLS['pass']} All checks passed[/green]")
|
|
270
|
+
|
|
271
|
+
return total_pass, total_warn, total_fail
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _render_marketplaces_section(
|
|
275
|
+
console: "Console",
|
|
276
|
+
config: AIConfig,
|
|
277
|
+
grouped: dict[EntityType, dict[str, EntityResult]],
|
|
278
|
+
verbose: bool,
|
|
279
|
+
) -> None:
|
|
280
|
+
"""Render the marketplaces section."""
|
|
281
|
+
# Get configured marketplaces
|
|
282
|
+
marketplaces_config: dict[str, tuple[str, str]] = {} # name -> (source, path/repo)
|
|
283
|
+
for target in config.targets:
|
|
284
|
+
if target.type == "claude":
|
|
285
|
+
for mp_name, mp_config in target.config.marketplaces.items():
|
|
286
|
+
if mp_config.source == PluginSource.LOCAL:
|
|
287
|
+
marketplaces_config[mp_name] = ("local", mp_config.path)
|
|
288
|
+
else:
|
|
289
|
+
marketplaces_config[mp_name] = ("github", mp_config.repo)
|
|
290
|
+
|
|
291
|
+
mp_count = len(marketplaces_config)
|
|
292
|
+
if mp_count == 0:
|
|
293
|
+
console.print("[bold]Marketplaces:[/bold] None configured")
|
|
294
|
+
console.print()
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
console.print(f"[bold]Marketplaces ({mp_count}):[/bold]")
|
|
298
|
+
|
|
299
|
+
marketplace_entities = grouped.get("marketplace", {})
|
|
300
|
+
|
|
301
|
+
# Build table for marketplaces
|
|
302
|
+
table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
|
|
303
|
+
table.add_column("", width=1) # Status column - no header needed
|
|
304
|
+
table.add_column("Name", style="cyan")
|
|
305
|
+
table.add_column("Source")
|
|
306
|
+
table.add_column("Location", style="dim")
|
|
307
|
+
|
|
308
|
+
issues_to_show: list[tuple[str, EntityResult]] = []
|
|
309
|
+
|
|
310
|
+
for mp_name, (source, location) in marketplaces_config.items():
|
|
311
|
+
entity_result = marketplace_entities.get(mp_name)
|
|
312
|
+
|
|
313
|
+
# Truncate long paths
|
|
314
|
+
display_location = _truncate_path(location, max_len=40)
|
|
315
|
+
|
|
316
|
+
if entity_result and entity_result.has_issues:
|
|
317
|
+
status = f"[red]{SYMBOLS['fail']}[/red]"
|
|
318
|
+
issues_to_show.append((mp_name, entity_result))
|
|
319
|
+
else:
|
|
320
|
+
status = f"[green]{SYMBOLS['pass']}[/green]"
|
|
321
|
+
|
|
322
|
+
table.add_row(status, mp_name, source, display_location)
|
|
323
|
+
|
|
324
|
+
console.print(table)
|
|
325
|
+
|
|
326
|
+
# Show issues below the table
|
|
327
|
+
for mp_name, entity_result in issues_to_show:
|
|
328
|
+
for failure in entity_result.failures:
|
|
329
|
+
console.print(f" {SYMBOLS['arrow']} [red]{mp_name}:[/red] {failure.message}")
|
|
330
|
+
if failure.fix_hint:
|
|
331
|
+
console.print(f" [hint]Fix:[/hint] {failure.fix_hint}")
|
|
332
|
+
for warning in entity_result.warnings:
|
|
333
|
+
console.print(f" {SYMBOLS['arrow']} [yellow]{mp_name}:[/yellow] {warning.message}")
|
|
334
|
+
if warning.fix_hint:
|
|
335
|
+
console.print(f" [hint]Fix:[/hint] {warning.fix_hint}")
|
|
336
|
+
|
|
337
|
+
if verbose:
|
|
338
|
+
for mp_name in marketplaces_config:
|
|
339
|
+
entity_result = marketplace_entities.get(mp_name)
|
|
340
|
+
if entity_result:
|
|
341
|
+
for passed in entity_result.passed:
|
|
342
|
+
console.print(f" [green]{SYMBOLS['pass']}[/green] {passed.message}")
|
|
343
|
+
|
|
344
|
+
console.print()
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _render_plugins_section(
|
|
348
|
+
console: "Console",
|
|
349
|
+
config: AIConfig,
|
|
350
|
+
grouped: dict[EntityType, dict[str, EntityResult]],
|
|
351
|
+
verbose: bool,
|
|
352
|
+
) -> None:
|
|
353
|
+
"""Render the plugins section."""
|
|
354
|
+
# Get configured plugins
|
|
355
|
+
plugins_config: list[tuple[str, bool]] = [] # (id, enabled)
|
|
356
|
+
for target in config.targets:
|
|
357
|
+
if target.type == "claude":
|
|
358
|
+
for plugin in target.config.plugins:
|
|
359
|
+
plugins_config.append((plugin.id, plugin.enabled))
|
|
360
|
+
|
|
361
|
+
plugin_count = len(plugins_config)
|
|
362
|
+
if plugin_count == 0:
|
|
363
|
+
console.print("[bold]Plugins:[/bold] None configured")
|
|
364
|
+
console.print()
|
|
365
|
+
return
|
|
366
|
+
|
|
367
|
+
console.print(f"[bold]Plugins ({plugin_count}):[/bold]")
|
|
368
|
+
|
|
369
|
+
plugin_entities = grouped.get("plugin", {})
|
|
370
|
+
|
|
371
|
+
# Build table for plugins
|
|
372
|
+
table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
|
|
373
|
+
table.add_column("", width=1) # Status column - no header needed
|
|
374
|
+
table.add_column("Plugin ID", style="cyan")
|
|
375
|
+
table.add_column("State")
|
|
376
|
+
|
|
377
|
+
issues_to_show: list[tuple[str, EntityResult]] = []
|
|
378
|
+
|
|
379
|
+
for plugin_id, enabled in plugins_config:
|
|
380
|
+
entity_result = plugin_entities.get(plugin_id)
|
|
381
|
+
|
|
382
|
+
# Truncate long plugin IDs
|
|
383
|
+
display_id = _truncate_string(plugin_id, max_len=45)
|
|
384
|
+
enabled_str = "[green]enabled[/green]" if enabled else "[dim]disabled[/dim]"
|
|
385
|
+
|
|
386
|
+
if entity_result and entity_result.has_issues:
|
|
387
|
+
status = f"[red]{SYMBOLS['fail']}[/red]"
|
|
388
|
+
issues_to_show.append((plugin_id, entity_result))
|
|
389
|
+
else:
|
|
390
|
+
status = f"[green]{SYMBOLS['pass']}[/green]"
|
|
391
|
+
|
|
392
|
+
table.add_row(status, display_id, enabled_str)
|
|
393
|
+
|
|
394
|
+
console.print(table)
|
|
395
|
+
|
|
396
|
+
# Show issues below the table
|
|
397
|
+
for plugin_id, entity_result in issues_to_show:
|
|
398
|
+
for failure in entity_result.failures:
|
|
399
|
+
console.print(f" {SYMBOLS['arrow']} [red]{plugin_id}:[/red] {failure.message}")
|
|
400
|
+
if failure.fix_hint:
|
|
401
|
+
console.print(f" [hint]Fix:[/hint] {failure.fix_hint}")
|
|
402
|
+
for warning in entity_result.warnings:
|
|
403
|
+
console.print(f" {SYMBOLS['arrow']} [yellow]{plugin_id}:[/yellow] {warning.message}")
|
|
404
|
+
|
|
405
|
+
if verbose:
|
|
406
|
+
for plugin_id, _ in plugins_config:
|
|
407
|
+
entity_result = plugin_entities.get(plugin_id)
|
|
408
|
+
if entity_result:
|
|
409
|
+
for passed in entity_result.passed:
|
|
410
|
+
console.print(f" [green]{SYMBOLS['pass']}[/green] {passed.message}")
|
|
411
|
+
|
|
412
|
+
console.print()
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _render_components_section(
|
|
416
|
+
console: "Console",
|
|
417
|
+
grouped: dict[EntityType, dict[str, EntityResult]],
|
|
418
|
+
verbose: bool,
|
|
419
|
+
) -> None:
|
|
420
|
+
"""Render the components section (skills, hooks, MCPs)."""
|
|
421
|
+
# Aggregate component counts
|
|
422
|
+
component_types: list[tuple[EntityType, str]] = [
|
|
423
|
+
("skill", "Skills"),
|
|
424
|
+
("hook", "Hooks"),
|
|
425
|
+
("mcp", "MCPs"),
|
|
426
|
+
]
|
|
427
|
+
|
|
428
|
+
has_any_components = any(entity_type in grouped for entity_type, _ in component_types)
|
|
429
|
+
|
|
430
|
+
if not has_any_components:
|
|
431
|
+
# No component validation results at all
|
|
432
|
+
return
|
|
433
|
+
|
|
434
|
+
console.print("[bold]Components:[/bold]")
|
|
435
|
+
|
|
436
|
+
# Build summary table
|
|
437
|
+
summary_table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
|
|
438
|
+
summary_table.add_column("Type")
|
|
439
|
+
summary_table.add_column("Valid", justify="right", style="green")
|
|
440
|
+
summary_table.add_column("Warnings", justify="right", style="yellow")
|
|
441
|
+
summary_table.add_column("Errors", justify="right", style="red")
|
|
442
|
+
|
|
443
|
+
# Collect issues for display after table
|
|
444
|
+
all_issues: list[tuple[str, str, EntityResult]] = [] # (type_name, entity_name, result)
|
|
445
|
+
|
|
446
|
+
for entity_type, display_name in component_types:
|
|
447
|
+
entities = grouped.get(entity_type, {})
|
|
448
|
+
if not entities:
|
|
449
|
+
continue
|
|
450
|
+
|
|
451
|
+
# Count valid vs errors
|
|
452
|
+
valid_count = sum(1 for e in entities.values() if not e.has_issues)
|
|
453
|
+
error_count = sum(1 for e in entities.values() if e.failures)
|
|
454
|
+
warn_count = sum(1 for e in entities.values() if e.warnings and not e.failures)
|
|
455
|
+
|
|
456
|
+
summary_table.add_row(
|
|
457
|
+
display_name,
|
|
458
|
+
str(valid_count) if valid_count else "-",
|
|
459
|
+
str(warn_count) if warn_count else "-",
|
|
460
|
+
str(error_count) if error_count else "-",
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
# Collect entities with issues
|
|
464
|
+
for entity_name, entity_result in entities.items():
|
|
465
|
+
if entity_result.has_issues:
|
|
466
|
+
all_issues.append((display_name, entity_name, entity_result))
|
|
467
|
+
|
|
468
|
+
console.print(summary_table)
|
|
469
|
+
|
|
470
|
+
# Show issues below the summary table
|
|
471
|
+
if all_issues:
|
|
472
|
+
console.print()
|
|
473
|
+
for type_name, entity_name, entity_result in all_issues:
|
|
474
|
+
console.print(f" [dim]{type_name}:[/dim] {entity_name}")
|
|
475
|
+
for failure in entity_result.failures:
|
|
476
|
+
console.print(f" [red]{SYMBOLS['fail']}[/red] {failure.message}")
|
|
477
|
+
if failure.fix_hint:
|
|
478
|
+
console.print(f" [hint]Fix:[/hint] {failure.fix_hint}")
|
|
479
|
+
for warning in entity_result.warnings:
|
|
480
|
+
console.print(f" [yellow]{SYMBOLS['warn']}[/yellow] {warning.message}")
|
|
481
|
+
|
|
482
|
+
# In verbose mode, show all individual components
|
|
483
|
+
if verbose:
|
|
484
|
+
console.print()
|
|
485
|
+
detail_table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
|
|
486
|
+
detail_table.add_column("", width=1)
|
|
487
|
+
detail_table.add_column("Type", style="dim")
|
|
488
|
+
detail_table.add_column("Name", style="cyan")
|
|
489
|
+
|
|
490
|
+
for entity_type, display_name in component_types:
|
|
491
|
+
entities = grouped.get(entity_type, {})
|
|
492
|
+
for entity_name, entity_result in entities.items():
|
|
493
|
+
if entity_result.failures:
|
|
494
|
+
status = f"[red]{SYMBOLS['fail']}[/red]"
|
|
495
|
+
elif entity_result.warnings:
|
|
496
|
+
status = f"[yellow]{SYMBOLS['warn']}[/yellow]"
|
|
497
|
+
else:
|
|
498
|
+
status = f"[green]{SYMBOLS['pass']}[/green]"
|
|
499
|
+
detail_table.add_row(status, display_name, entity_name)
|
|
500
|
+
|
|
501
|
+
console.print(detail_table)
|
|
502
|
+
|
|
503
|
+
console.print()
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _truncate_path(path: str, max_len: int = 40) -> str:
|
|
507
|
+
"""Truncate a path for display, replacing home directory with ~."""
|
|
508
|
+
import os
|
|
509
|
+
|
|
510
|
+
home = os.path.expanduser("~")
|
|
511
|
+
if path.startswith(home):
|
|
512
|
+
path = "~" + path[len(home) :]
|
|
513
|
+
|
|
514
|
+
if len(path) <= max_len:
|
|
515
|
+
return path
|
|
516
|
+
|
|
517
|
+
# Keep the last portion
|
|
518
|
+
return "..." + path[-(max_len - 3) :]
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _truncate_string(s: str, max_len: int = 45) -> str:
|
|
522
|
+
"""Truncate a string with ellipsis if too long."""
|
|
523
|
+
if len(s) <= max_len:
|
|
524
|
+
return s
|
|
525
|
+
return s[: max_len - 3] + "..."
|
ai_config/cli_theme.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Theme and styling constants for ai-config CLI.
|
|
2
|
+
|
|
3
|
+
This module centralizes all Rich styling to ensure consistent appearance
|
|
4
|
+
across commands and to keep rendering logic separate from business logic.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.theme import Theme
|
|
9
|
+
|
|
10
|
+
# Unified theme for ai-config CLI
|
|
11
|
+
AI_CONFIG_THEME = Theme(
|
|
12
|
+
{
|
|
13
|
+
"header": "bold cyan",
|
|
14
|
+
"subheader": "bold",
|
|
15
|
+
"success": "green",
|
|
16
|
+
"warning": "yellow",
|
|
17
|
+
"error": "red bold",
|
|
18
|
+
"info": "dim",
|
|
19
|
+
"hint": "cyan italic",
|
|
20
|
+
"key": "cyan",
|
|
21
|
+
"value": "white",
|
|
22
|
+
}
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Status symbols for consistent display
|
|
26
|
+
SYMBOLS = {
|
|
27
|
+
"pass": "\u2713", # ✓
|
|
28
|
+
"fail": "\u2717", # ✗
|
|
29
|
+
"warn": "\u26a0", # ⚠
|
|
30
|
+
"arrow": "\u2192", # →
|
|
31
|
+
"bullet": "\u2022", # •
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def create_console(stderr: bool = False) -> Console:
|
|
36
|
+
"""Create a themed console instance.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
stderr: If True, write to stderr instead of stdout.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Configured Console with AI_CONFIG_THEME applied.
|
|
43
|
+
"""
|
|
44
|
+
return Console(theme=AI_CONFIG_THEME, stderr=stderr)
|