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.
- ai_agent_rules-0.15.2.dist-info/METADATA +451 -0
- ai_agent_rules-0.15.2.dist-info/RECORD +52 -0
- ai_agent_rules-0.15.2.dist-info/WHEEL +5 -0
- ai_agent_rules-0.15.2.dist-info/entry_points.txt +3 -0
- ai_agent_rules-0.15.2.dist-info/licenses/LICENSE +22 -0
- ai_agent_rules-0.15.2.dist-info/top_level.txt +1 -0
- ai_rules/__init__.py +8 -0
- ai_rules/agents/__init__.py +1 -0
- ai_rules/agents/base.py +68 -0
- ai_rules/agents/claude.py +123 -0
- ai_rules/agents/cursor.py +70 -0
- ai_rules/agents/goose.py +47 -0
- ai_rules/agents/shared.py +35 -0
- ai_rules/bootstrap/__init__.py +75 -0
- ai_rules/bootstrap/config.py +261 -0
- ai_rules/bootstrap/installer.py +279 -0
- ai_rules/bootstrap/updater.py +344 -0
- ai_rules/bootstrap/version.py +52 -0
- ai_rules/cli.py +2434 -0
- ai_rules/completions.py +194 -0
- ai_rules/config/AGENTS.md +249 -0
- ai_rules/config/chat_agent_hints.md +1 -0
- ai_rules/config/claude/CLAUDE.md +1 -0
- ai_rules/config/claude/agents/code-reviewer.md +121 -0
- ai_rules/config/claude/commands/agents-md.md +422 -0
- ai_rules/config/claude/commands/annotate-changelog.md +191 -0
- ai_rules/config/claude/commands/comment-cleanup.md +161 -0
- ai_rules/config/claude/commands/continue-crash.md +38 -0
- ai_rules/config/claude/commands/dev-docs.md +169 -0
- ai_rules/config/claude/commands/pr-creator.md +247 -0
- ai_rules/config/claude/commands/test-cleanup.md +244 -0
- ai_rules/config/claude/commands/update-docs.md +324 -0
- ai_rules/config/claude/hooks/subagentStop.py +92 -0
- ai_rules/config/claude/mcps.json +1 -0
- ai_rules/config/claude/settings.json +119 -0
- ai_rules/config/claude/skills/doc-writer/SKILL.md +293 -0
- ai_rules/config/claude/skills/doc-writer/resources/templates.md +495 -0
- ai_rules/config/claude/skills/prompt-engineer/SKILL.md +272 -0
- ai_rules/config/claude/skills/prompt-engineer/resources/prompt_engineering_guide_2025.md +855 -0
- ai_rules/config/claude/skills/prompt-engineer/resources/templates.md +232 -0
- ai_rules/config/cursor/keybindings.json +14 -0
- ai_rules/config/cursor/settings.json +81 -0
- ai_rules/config/goose/.goosehints +1 -0
- ai_rules/config/goose/config.yaml +55 -0
- ai_rules/config/profiles/default.yaml +6 -0
- ai_rules/config/profiles/work.yaml +11 -0
- ai_rules/config.py +644 -0
- ai_rules/display.py +40 -0
- ai_rules/mcp.py +369 -0
- ai_rules/profiles.py +187 -0
- ai_rules/symlinks.py +207 -0
- 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]")
|