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,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)