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