ai-agent-rules 0.11.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.

Potentially problematic release.


This version of ai-agent-rules might be problematic. Click here for more details.

Files changed (42) hide show
  1. ai_agent_rules-0.11.0.dist-info/METADATA +390 -0
  2. ai_agent_rules-0.11.0.dist-info/RECORD +42 -0
  3. ai_agent_rules-0.11.0.dist-info/WHEEL +5 -0
  4. ai_agent_rules-0.11.0.dist-info/entry_points.txt +3 -0
  5. ai_agent_rules-0.11.0.dist-info/licenses/LICENSE +22 -0
  6. ai_agent_rules-0.11.0.dist-info/top_level.txt +1 -0
  7. ai_rules/__init__.py +8 -0
  8. ai_rules/agents/__init__.py +1 -0
  9. ai_rules/agents/base.py +68 -0
  10. ai_rules/agents/claude.py +121 -0
  11. ai_rules/agents/goose.py +44 -0
  12. ai_rules/agents/shared.py +35 -0
  13. ai_rules/bootstrap/__init__.py +75 -0
  14. ai_rules/bootstrap/config.py +261 -0
  15. ai_rules/bootstrap/installer.py +249 -0
  16. ai_rules/bootstrap/updater.py +221 -0
  17. ai_rules/bootstrap/version.py +52 -0
  18. ai_rules/cli.py +2292 -0
  19. ai_rules/completions.py +194 -0
  20. ai_rules/config/AGENTS.md +249 -0
  21. ai_rules/config/chat_agent_hints.md +1 -0
  22. ai_rules/config/claude/agents/code-reviewer.md +121 -0
  23. ai_rules/config/claude/commands/annotate-changelog.md +191 -0
  24. ai_rules/config/claude/commands/comment-cleanup.md +161 -0
  25. ai_rules/config/claude/commands/continue-crash.md +38 -0
  26. ai_rules/config/claude/commands/dev-docs.md +169 -0
  27. ai_rules/config/claude/commands/pr-creator.md +247 -0
  28. ai_rules/config/claude/commands/test-cleanup.md +244 -0
  29. ai_rules/config/claude/commands/update-docs.md +324 -0
  30. ai_rules/config/claude/hooks/subagentStop.py +92 -0
  31. ai_rules/config/claude/mcps.json +1 -0
  32. ai_rules/config/claude/settings.json +116 -0
  33. ai_rules/config/claude/skills/doc-writer/SKILL.md +293 -0
  34. ai_rules/config/claude/skills/doc-writer/resources/templates.md +495 -0
  35. ai_rules/config/claude/skills/prompt-engineer/SKILL.md +272 -0
  36. ai_rules/config/claude/skills/prompt-engineer/resources/prompt_engineering_guide_2025.md +855 -0
  37. ai_rules/config/claude/skills/prompt-engineer/resources/templates.md +232 -0
  38. ai_rules/config/goose/config.yaml +55 -0
  39. ai_rules/config.py +635 -0
  40. ai_rules/display.py +40 -0
  41. ai_rules/mcp.py +370 -0
  42. ai_rules/symlinks.py +207 -0
ai_rules/config.py ADDED
@@ -0,0 +1,635 @@
1
+ """Configuration loading and management."""
2
+
3
+ import copy
4
+ import json
5
+ import re
6
+ import shutil
7
+
8
+ from fnmatch import fnmatch
9
+ from functools import lru_cache
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import yaml
14
+
15
+ __all__ = [
16
+ "Config",
17
+ "AGENT_CONFIG_METADATA",
18
+ "parse_setting_path",
19
+ "navigate_path",
20
+ "validate_override_path",
21
+ ]
22
+
23
+ AGENT_CONFIG_METADATA = {
24
+ "claude": {
25
+ "config_file": "settings.json",
26
+ "format": "json",
27
+ },
28
+ "goose": {
29
+ "config_file": "config.yaml",
30
+ "format": "yaml",
31
+ },
32
+ }
33
+
34
+
35
+ def parse_setting_path(path: str) -> list[str | int]:
36
+ """Parse a setting path with array indices into components.
37
+
38
+ Examples:
39
+ 'model' -> ['model']
40
+ 'hooks.SubagentStop[0].command' -> ['hooks', 'SubagentStop', 0, 'command']
41
+ 'env.SOME_VAR' -> ['env', 'SOME_VAR']
42
+ 'hooks.SubagentStop[0].hooks[0].command' -> ['hooks', 'SubagentStop', 0, 'hooks', 0, 'command']
43
+
44
+ Args:
45
+ path: Setting path string with optional array indices
46
+
47
+ Returns:
48
+ List of path components (str for keys, int for array indices)
49
+
50
+ Raises:
51
+ ValueError: If array indices are invalid or path is malformed
52
+ """
53
+ if not path:
54
+ raise ValueError("Path cannot be empty")
55
+
56
+ components = []
57
+ parts = path.split(".")
58
+
59
+ for part in parts:
60
+ if "[" in part:
61
+ match = re.match(r"^([^\[]+)(\[\d+\])+$", part)
62
+ if not match:
63
+ raise ValueError(
64
+ f"Invalid array notation in '{part}'. Use format: key[0] or key[0][1]"
65
+ )
66
+
67
+ key = match.group(1)
68
+ components.append(key)
69
+
70
+ indices = re.findall(r"\[(\d+)\]", part)
71
+ for idx in indices:
72
+ components.append(int(idx))
73
+ else:
74
+ components.append(part)
75
+
76
+ return components
77
+
78
+
79
+ def navigate_path(data: Any, path_components: list[str | int]) -> tuple[Any, bool, str]:
80
+ """Navigate a data structure using path components.
81
+
82
+ Args:
83
+ data: Data structure to navigate (dict, list, or primitive)
84
+ path_components: List of path components (str for keys, int for indices)
85
+
86
+ Returns:
87
+ Tuple of (value_at_path, success, error_message)
88
+ - If success: (value, True, '')
89
+ - If failure: (None, False, 'error description')
90
+ """
91
+ current = data
92
+
93
+ for i, component in enumerate(path_components):
94
+ if isinstance(component, int):
95
+ if not isinstance(current, list):
96
+ path_so_far = _format_path(path_components[:i])
97
+ return (
98
+ None,
99
+ False,
100
+ f"Expected array at '{path_so_far}' but found {type(current).__name__}",
101
+ )
102
+
103
+ if component >= len(current):
104
+ path_so_far = _format_path(path_components[:i])
105
+ return (
106
+ None,
107
+ False,
108
+ f"Array index {component} out of range at '{path_so_far}' (length: {len(current)})",
109
+ )
110
+
111
+ current = current[component]
112
+ else:
113
+ if not isinstance(current, dict):
114
+ path_so_far = _format_path(path_components[:i])
115
+ return (
116
+ None,
117
+ False,
118
+ f"Expected object at '{path_so_far}' but found {type(current).__name__}",
119
+ )
120
+
121
+ if component not in current:
122
+ path_so_far = _format_path(path_components[: i + 1])
123
+ list(current.keys()) if isinstance(current, dict) else []
124
+ return (
125
+ None,
126
+ False,
127
+ f"Key '{component}' not found at '{path_so_far}'",
128
+ )
129
+
130
+ current = current[component]
131
+
132
+ return (current, True, "")
133
+
134
+
135
+ def _format_path(components: list[str | int]) -> str:
136
+ """Format path components back into a string representation.
137
+
138
+ Args:
139
+ components: List of path components
140
+
141
+ Returns:
142
+ Formatted path string
143
+ """
144
+ if not components:
145
+ return ""
146
+
147
+ result = []
148
+ i = 0
149
+ while i < len(components):
150
+ component = components[i]
151
+ if isinstance(component, str):
152
+ result.append(component)
153
+ i += 1
154
+ else:
155
+ if result:
156
+ result[-1] += f"[{component}]"
157
+ i += 1
158
+
159
+ return ".".join(result)
160
+
161
+
162
+ def validate_override_path(
163
+ agent: str, setting: str, config_dir: Path
164
+ ) -> tuple[bool, str, str, list[str]]:
165
+ """Validate an override path against base settings.
166
+
167
+ Args:
168
+ agent: Agent name (e.g., 'claude', 'goose')
169
+ setting: Setting path (e.g., 'hooks.SubagentStop[0].command')
170
+ config_dir: Config directory path
171
+
172
+ Returns:
173
+ Tuple of (is_valid, error_message, warning_message, suggestions)
174
+ - If valid: (True, '', '', [])
175
+ - If invalid (hard error): (False, 'error message', '', ['suggestion1', 'suggestion2'])
176
+ - If valid with warning: (True, '', 'warning message', ['suggestion1', 'suggestion2'])
177
+ """
178
+ valid_agents = list(AGENT_CONFIG_METADATA.keys())
179
+ if agent not in valid_agents:
180
+ return (
181
+ False,
182
+ f"Unknown agent '{agent}'",
183
+ "",
184
+ valid_agents,
185
+ )
186
+
187
+ agent_config = AGENT_CONFIG_METADATA[agent]
188
+ config_file = agent_config["config_file"]
189
+ config_format = agent_config["format"]
190
+
191
+ settings_file = config_dir / agent / config_file
192
+ if not settings_file.exists():
193
+ return (
194
+ False,
195
+ f"No base settings file found for agent '{agent}' at {settings_file}",
196
+ "",
197
+ [],
198
+ )
199
+
200
+ try:
201
+ with open(settings_file) as f:
202
+ if config_format == "json":
203
+ base_settings = json.load(f)
204
+ elif config_format == "yaml":
205
+ base_settings = yaml.safe_load(f)
206
+ else:
207
+ return (False, f"Unsupported config format: {config_format}", "", [])
208
+ except (json.JSONDecodeError, yaml.YAMLError, OSError) as e:
209
+ return (False, f"Failed to load base settings: {e}", "", [])
210
+
211
+ try:
212
+ path_components = parse_setting_path(setting)
213
+ except ValueError as e:
214
+ return (False, str(e), "", [])
215
+
216
+ value, success, error_msg = navigate_path(base_settings, path_components)
217
+
218
+ if success:
219
+ return (True, "", "", [])
220
+
221
+ suggestions = []
222
+ if "not found" in error_msg.lower():
223
+ for i in range(len(path_components) - 1, -1, -1):
224
+ partial_path = path_components[:i]
225
+ partial_value, partial_success, _ = navigate_path(
226
+ base_settings, partial_path
227
+ )
228
+ if partial_success and isinstance(partial_value, dict):
229
+ suggestions = list(partial_value.keys())
230
+ break
231
+
232
+ error_msg = f"Path '{setting}' not found in base config."
233
+ if suggestions:
234
+ error_msg += f" Did you mean one of: {', '.join(suggestions[:5])}?"
235
+
236
+ return (False, error_msg, "", suggestions)
237
+
238
+
239
+ class Config:
240
+ """Configuration for ai-rules tool."""
241
+
242
+ def __init__(
243
+ self,
244
+ exclude_symlinks: list[str] | None = None,
245
+ settings_overrides: dict[str, dict[str, Any]] | None = None,
246
+ mcp_overrides: dict[str, dict[str, Any]] | None = None,
247
+ ):
248
+ self.exclude_symlinks = set(exclude_symlinks or [])
249
+ self.settings_overrides = settings_overrides or {}
250
+ self.mcp_overrides = mcp_overrides or {}
251
+
252
+ @classmethod
253
+ @lru_cache(maxsize=1)
254
+ def load(cls) -> "Config":
255
+ """Load configuration from ~/.ai-rules-config.yaml.
256
+
257
+ Cached to avoid redundant YAML parsing within a single CLI invocation.
258
+
259
+ Returns empty config if file doesn't exist.
260
+
261
+ Note: Repo-level config support was removed in v0.5.0.
262
+ All configuration is now user-specific.
263
+ """
264
+ user_config_path = Path.home() / ".ai-rules-config.yaml"
265
+
266
+ exclude_symlinks = []
267
+ settings_overrides = {}
268
+ mcp_overrides = {}
269
+
270
+ if user_config_path.exists():
271
+ with open(user_config_path) as f:
272
+ data = yaml.safe_load(f) or {}
273
+ exclude_symlinks = data.get("exclude_symlinks", [])
274
+ settings_overrides = data.get("settings_overrides", {})
275
+ mcp_overrides = data.get("mcp_overrides", {})
276
+
277
+ return cls(
278
+ exclude_symlinks=exclude_symlinks,
279
+ settings_overrides=settings_overrides,
280
+ mcp_overrides=mcp_overrides,
281
+ )
282
+
283
+ def is_excluded(self, symlink_target: str) -> bool:
284
+ """Check if a symlink target is globally excluded.
285
+
286
+ Supports both exact paths and glob patterns (e.g., ~/.claude/*.json).
287
+ """
288
+ normalized = Path(symlink_target).expanduser().as_posix()
289
+ for excl in self.exclude_symlinks:
290
+ excl_normalized = Path(excl).expanduser().as_posix()
291
+ if normalized == excl_normalized:
292
+ return True
293
+ if fnmatch(normalized, excl_normalized):
294
+ return True
295
+ return False
296
+
297
+ @staticmethod
298
+ def get_cache_dir() -> Path:
299
+ """Get the cache directory for merged settings."""
300
+ cache_dir = Path.home() / ".ai-rules" / "cache"
301
+ cache_dir.mkdir(parents=True, exist_ok=True)
302
+ return cache_dir
303
+
304
+ @staticmethod
305
+ def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
306
+ """Deep merge two dictionaries, with override values taking precedence.
307
+
308
+ Supports merging nested dictionaries and arrays. Arrays are merged element-by-element,
309
+ with dict elements being recursively merged.
310
+
311
+ Uses deep copy to prevent mutation of the base dictionary.
312
+ """
313
+ result = copy.deepcopy(base)
314
+ for key, value in override.items():
315
+ if key not in result:
316
+ result[key] = value
317
+ elif isinstance(result[key], dict) and isinstance(value, dict):
318
+ result[key] = Config._deep_merge(result[key], value)
319
+ elif isinstance(result[key], list) and isinstance(value, list):
320
+ merged_array = copy.deepcopy(result[key])
321
+ for i, item in enumerate(value):
322
+ if i < len(merged_array):
323
+ if isinstance(merged_array[i], dict) and isinstance(item, dict):
324
+ merged_array[i] = Config._deep_merge(merged_array[i], item)
325
+ else:
326
+ merged_array[i] = item
327
+ else:
328
+ merged_array.append(item)
329
+ result[key] = merged_array
330
+ else:
331
+ result[key] = value
332
+ return result
333
+
334
+ def merge_settings(
335
+ self, agent: str, base_settings: dict[str, Any]
336
+ ) -> dict[str, Any]:
337
+ """Merge base settings with overrides for a specific agent.
338
+
339
+ Args:
340
+ agent: Agent name (e.g., 'claude', 'goose')
341
+ base_settings: Base settings dictionary from repo
342
+
343
+ Returns:
344
+ Merged settings dictionary with overrides applied
345
+ """
346
+ if agent not in self.settings_overrides:
347
+ return base_settings
348
+
349
+ return self._deep_merge(base_settings, self.settings_overrides[agent])
350
+
351
+ def get_merged_settings_path(self, agent: str) -> Path | None:
352
+ """Get the path to cached merged settings for an agent.
353
+
354
+ Returns None if agent has no overrides (should use base file directly).
355
+
356
+ Args:
357
+ agent: Agent name (e.g., 'claude', 'goose')
358
+
359
+ Returns:
360
+ Path to cached merged settings file, or None if no overrides exist
361
+ """
362
+ if agent not in self.settings_overrides:
363
+ return None
364
+
365
+ agent_config = AGENT_CONFIG_METADATA.get(agent)
366
+ if not agent_config:
367
+ return None
368
+
369
+ cache_dir = self.get_cache_dir() / agent
370
+ cache_dir.mkdir(parents=True, exist_ok=True)
371
+ return cache_dir / agent_config["config_file"]
372
+
373
+ def get_settings_file_for_symlink(
374
+ self, agent: str, base_settings_path: Path
375
+ ) -> Path:
376
+ """Get the appropriate settings file to use for symlinking.
377
+
378
+ Returns cached merged settings if overrides exist and cache is valid,
379
+ otherwise returns the base settings file.
380
+
381
+ This method does NOT build the cache - use build_merged_settings for that.
382
+
383
+ Args:
384
+ agent: Agent name (e.g., 'claude', 'goose')
385
+ base_settings_path: Path to base settings file
386
+
387
+ Returns:
388
+ Path to settings file to use (either cached or base)
389
+ """
390
+ if agent not in self.settings_overrides:
391
+ return base_settings_path
392
+
393
+ cache_path = self.get_merged_settings_path(agent)
394
+ if cache_path and cache_path.exists():
395
+ return cache_path
396
+
397
+ return base_settings_path
398
+
399
+ def is_cache_stale(self, agent: str, base_settings_path: Path) -> bool:
400
+ """Check if cached merged settings are stale.
401
+
402
+ Cache is considered stale if:
403
+ - Cache file doesn't exist
404
+ - Base settings file is newer than cache
405
+ - User config is newer than cache (overrides changed)
406
+
407
+ Args:
408
+ agent: Agent name (e.g., 'claude', 'goose')
409
+ base_settings_path: Path to base settings file
410
+
411
+ Returns:
412
+ True if cache needs rebuilding, False otherwise
413
+ """
414
+ if agent not in self.settings_overrides:
415
+ return False
416
+
417
+ cache_path = self.get_merged_settings_path(agent)
418
+ if not cache_path or not cache_path.exists():
419
+ return True
420
+
421
+ cache_mtime = cache_path.stat().st_mtime
422
+
423
+ if base_settings_path.exists():
424
+ if base_settings_path.stat().st_mtime > cache_mtime:
425
+ return True
426
+
427
+ user_config_path = Path.home() / ".ai-rules-config.yaml"
428
+ if user_config_path.exists():
429
+ if user_config_path.stat().st_mtime > cache_mtime:
430
+ return True
431
+
432
+ return False
433
+
434
+ def get_cache_diff(self, agent: str, base_settings_path: Path) -> str | None:
435
+ """Get a unified diff between cached and expected merged settings.
436
+
437
+ Args:
438
+ agent: Agent name (e.g., 'claude', 'goose')
439
+ base_settings_path: Path to base settings in repo
440
+
441
+ Returns:
442
+ Formatted diff string with Rich markup, or None if no diff/cache doesn't exist
443
+ """
444
+ import difflib
445
+
446
+ if agent not in self.settings_overrides:
447
+ return None
448
+
449
+ cache_path = self.get_merged_settings_path(agent)
450
+ if not cache_path or not cache_path.exists():
451
+ return None
452
+
453
+ agent_config = AGENT_CONFIG_METADATA.get(agent)
454
+ if not agent_config:
455
+ return None
456
+
457
+ config_format = agent_config["format"]
458
+
459
+ try:
460
+ with open(cache_path) as f:
461
+ if config_format == "json":
462
+ cached_settings = json.load(f)
463
+ elif config_format == "yaml":
464
+ cached_settings = yaml.safe_load(f) or {}
465
+ else:
466
+ return None
467
+ except (json.JSONDecodeError, yaml.YAMLError, OSError):
468
+ return None
469
+
470
+ if not base_settings_path.exists():
471
+ base_settings = {}
472
+ else:
473
+ try:
474
+ with open(base_settings_path) as f:
475
+ if config_format == "json":
476
+ base_settings = json.load(f)
477
+ elif config_format == "yaml":
478
+ base_settings = yaml.safe_load(f) or {}
479
+ else:
480
+ return None
481
+ except (json.JSONDecodeError, yaml.YAMLError, OSError):
482
+ return None
483
+
484
+ expected_settings = self.merge_settings(agent, base_settings)
485
+
486
+ if cached_settings == expected_settings:
487
+ return None
488
+
489
+ if config_format == "json":
490
+ cached_text = json.dumps(cached_settings, indent=2)
491
+ expected_text = json.dumps(expected_settings, indent=2)
492
+ elif config_format == "yaml":
493
+ cached_text = yaml.dump(
494
+ cached_settings, default_flow_style=False, sort_keys=False
495
+ )
496
+ expected_text = yaml.dump(
497
+ expected_settings, default_flow_style=False, sort_keys=False
498
+ )
499
+ else:
500
+ return None
501
+
502
+ cached_lines = cached_text.splitlines(keepends=True)
503
+ expected_lines = expected_text.splitlines(keepends=True)
504
+
505
+ diff = difflib.unified_diff(
506
+ cached_lines,
507
+ expected_lines,
508
+ fromfile="Cached (current)",
509
+ tofile="Expected (merged)",
510
+ lineterm="",
511
+ )
512
+
513
+ diff_lines = []
514
+ for line in diff:
515
+ line = line.rstrip("\n")
516
+ if (
517
+ line.startswith("---")
518
+ or line.startswith("+++")
519
+ or line.startswith("@@")
520
+ ):
521
+ diff_lines.append(f"[dim] {line}[/dim]")
522
+ elif line.startswith("+"):
523
+ diff_lines.append(f"[green] {line}[/green]")
524
+ elif line.startswith("-"):
525
+ diff_lines.append(f"[red] {line}[/red]")
526
+ else:
527
+ diff_lines.append(f"[dim] {line}[/dim]")
528
+
529
+ if not diff_lines:
530
+ return None
531
+
532
+ return "\n".join(diff_lines)
533
+
534
+ def build_merged_settings(
535
+ self,
536
+ agent: str,
537
+ base_settings_path: Path,
538
+ force_rebuild: bool = False,
539
+ ) -> Path | None:
540
+ """Build merged settings file in cache if overrides exist.
541
+
542
+ Only rebuilds cache if:
543
+ - force_rebuild is True, OR
544
+ - Cache doesn't exist or is stale
545
+
546
+ Args:
547
+ agent: Agent name (e.g., 'claude', 'goose')
548
+ base_settings_path: Path to base config file
549
+ force_rebuild: Force rebuild even if cache exists and is fresh
550
+
551
+ Returns:
552
+ Path to merged settings file, or None if no overrides exist
553
+ """
554
+ if agent not in self.settings_overrides:
555
+ return None
556
+
557
+ cache_path = self.get_merged_settings_path(agent)
558
+
559
+ if not force_rebuild and cache_path and cache_path.exists():
560
+ if not self.is_cache_stale(agent, base_settings_path):
561
+ return cache_path
562
+
563
+ agent_config = AGENT_CONFIG_METADATA.get(agent)
564
+ if not agent_config:
565
+ return None
566
+
567
+ config_format = agent_config["format"]
568
+
569
+ if not base_settings_path.exists():
570
+ base_settings = {}
571
+ else:
572
+ with open(base_settings_path) as f:
573
+ if config_format == "json":
574
+ base_settings = json.load(f)
575
+ elif config_format == "yaml":
576
+ base_settings = yaml.safe_load(f) or {}
577
+ else:
578
+ return None
579
+
580
+ merged = self.merge_settings(agent, base_settings)
581
+ if cache_path:
582
+ with open(cache_path, "w") as f:
583
+ if config_format == "json":
584
+ json.dump(merged, f, indent=2)
585
+ elif config_format == "yaml":
586
+ yaml.safe_dump(merged, f, default_flow_style=False, sort_keys=False)
587
+
588
+ return cache_path
589
+
590
+ @staticmethod
591
+ def load_user_config() -> dict[str, Any]:
592
+ """Load user config file with defaults.
593
+
594
+ Returns:
595
+ Dictionary with user config data, or empty dict with version if file doesn't exist
596
+ """
597
+ user_config_path = Path.home() / ".ai-rules-config.yaml"
598
+
599
+ if user_config_path.exists():
600
+ with open(user_config_path) as f:
601
+ return yaml.safe_load(f) or {"version": 1}
602
+ return {"version": 1}
603
+
604
+ @staticmethod
605
+ def save_user_config(data: dict[str, Any]) -> None:
606
+ """Save user config file with consistent formatting.
607
+
608
+ Args:
609
+ data: Configuration dictionary to save
610
+ """
611
+ user_config_path = Path.home() / ".ai-rules-config.yaml"
612
+ user_config_path.parent.mkdir(parents=True, exist_ok=True)
613
+
614
+ with open(user_config_path, "w") as f:
615
+ yaml.dump(data, f, default_flow_style=False, sort_keys=False)
616
+
617
+ def cleanup_orphaned_cache(self) -> list[str]:
618
+ """Remove cache files for agents that no longer have overrides.
619
+
620
+ Returns:
621
+ List of agent IDs whose caches were removed
622
+ """
623
+ removed: list[str] = []
624
+ cache_dir = self.get_cache_dir()
625
+ if not cache_dir.exists():
626
+ return removed
627
+
628
+ for agent_dir in cache_dir.iterdir():
629
+ if agent_dir.is_dir():
630
+ agent_id = agent_dir.name
631
+ if agent_id not in self.settings_overrides:
632
+ shutil.rmtree(agent_dir)
633
+ removed.append(agent_id)
634
+
635
+ return removed
ai_rules/display.py ADDED
@@ -0,0 +1,40 @@
1
+ """Console display utilities for consistent formatting."""
2
+
3
+ from ai_rules.symlinks import console
4
+
5
+
6
+ def success(message: str, prefix: str = "") -> None:
7
+ """Display a success message with green checkmark."""
8
+ if prefix:
9
+ console.print(f" [green]✓[/green] {prefix} {message}")
10
+ else:
11
+ console.print(f" [green]✓[/green] {message}")
12
+
13
+
14
+ def error(message: str, prefix: str = "") -> None:
15
+ """Display an error message with red X."""
16
+ if prefix:
17
+ console.print(f" [red]✗[/red] {prefix} {message}")
18
+ else:
19
+ console.print(f" [red]✗[/red] {message}")
20
+
21
+
22
+ def warning(message: str, prefix: str = "") -> None:
23
+ """Display a warning message with yellow warning symbol."""
24
+ if prefix:
25
+ console.print(f" [yellow]⚠[/yellow] {prefix} {message}")
26
+ else:
27
+ console.print(f" [yellow]⚠[/yellow] {message}")
28
+
29
+
30
+ def info(message: str, prefix: str = "") -> None:
31
+ """Display an info message with dim circle."""
32
+ if prefix:
33
+ console.print(f" [dim]•[/dim] {prefix} {message}")
34
+ else:
35
+ console.print(f" [dim]•[/dim] {message}")
36
+
37
+
38
+ def dim(message: str) -> None:
39
+ """Display a dimmed message."""
40
+ console.print(f"[dim]{message}[/dim]")