ai-agent-rules 0.11.0__py3-none-any.whl → 0.15.8__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.

ai_rules/config.py CHANGED
@@ -12,6 +12,8 @@ from typing import Any
12
12
 
13
13
  import yaml
14
14
 
15
+ from ai_rules.utils import deep_merge
16
+
15
17
  __all__ = [
16
18
  "Config",
17
19
  "AGENT_CONFIG_METADATA",
@@ -25,6 +27,10 @@ AGENT_CONFIG_METADATA = {
25
27
  "config_file": "settings.json",
26
28
  "format": "json",
27
29
  },
30
+ "cursor": {
31
+ "config_file": "settings.json",
32
+ "format": "json",
33
+ },
28
34
  "goose": {
29
35
  "config_file": "config.yaml",
30
36
  "format": "yaml",
@@ -244,40 +250,65 @@ class Config:
244
250
  exclude_symlinks: list[str] | None = None,
245
251
  settings_overrides: dict[str, dict[str, Any]] | None = None,
246
252
  mcp_overrides: dict[str, dict[str, Any]] | None = None,
253
+ profile_name: str | None = None,
247
254
  ):
248
255
  self.exclude_symlinks = set(exclude_symlinks or [])
249
256
  self.settings_overrides = settings_overrides or {}
250
257
  self.mcp_overrides = mcp_overrides or {}
258
+ self.profile_name = profile_name
251
259
 
252
260
  @classmethod
253
- @lru_cache(maxsize=1)
254
- def load(cls) -> "Config":
255
- """Load configuration from ~/.ai-rules-config.yaml.
261
+ def load(cls, profile: str | None = None) -> "Config":
262
+ """Load configuration from profile and ~/.ai-rules-config.yaml.
256
263
 
257
- Cached to avoid redundant YAML parsing within a single CLI invocation.
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
258
267
 
259
- Returns empty config if file doesn't exist.
268
+ Args:
269
+ profile: Optional profile name to load (default: "default")
260
270
 
261
- Note: Repo-level config support was removed in v0.5.0.
262
- All configuration is now user-specific.
271
+ Returns:
272
+ Config instance with merged overrides
263
273
  """
264
- user_config_path = Path.home() / ".ai-rules-config.yaml"
274
+ return cls._load_cached(profile or "default")
265
275
 
266
- exclude_symlinks = []
267
- settings_overrides = {}
268
- mcp_overrides = {}
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)
269
292
 
293
+ user_config_path = Path.home() / ".ai-rules-config.yaml"
270
294
  if user_config_path.exists():
271
295
  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", {})
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)
276
306
 
277
307
  return cls(
278
308
  exclude_symlinks=exclude_symlinks,
279
309
  settings_overrides=settings_overrides,
280
310
  mcp_overrides=mcp_overrides,
311
+ profile_name=profile_name,
281
312
  )
282
313
 
283
314
  def is_excluded(self, symlink_target: str) -> bool:
@@ -301,36 +332,6 @@ class Config:
301
332
  cache_dir.mkdir(parents=True, exist_ok=True)
302
333
  return cache_dir
303
334
 
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
335
  def merge_settings(
335
336
  self, agent: str, base_settings: dict[str, Any]
336
337
  ) -> dict[str, Any]:
@@ -346,7 +347,7 @@ class Config:
346
347
  if agent not in self.settings_overrides:
347
348
  return base_settings
348
349
 
349
- return self._deep_merge(base_settings, self.settings_overrides[agent])
350
+ return deep_merge(base_settings, self.settings_overrides[agent])
350
351
 
351
352
  def get_merged_settings_path(self, agent: str) -> Path | None:
352
353
  """Get the path to cached merged settings for an agent.
@@ -429,6 +430,14 @@ class Config:
429
430
  if user_config_path.stat().st_mtime > cache_mtime:
430
431
  return True
431
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
+
432
441
  return False
433
442
 
434
443
  def get_cache_diff(self, agent: str, base_settings_path: Path) -> str | None:
ai_rules/mcp.py CHANGED
@@ -12,6 +12,7 @@ from pathlib import Path
12
12
  from typing import Any, cast
13
13
 
14
14
  from .config import Config
15
+ from .utils import deep_merge
15
16
 
16
17
 
17
18
  class OperationResult(Enum):
@@ -66,9 +67,7 @@ class MCPManager:
66
67
  merged_mcps = {}
67
68
  for name, _mcp_config in {**base_mcps, **mcp_overrides}.items():
68
69
  if name in base_mcps and name in mcp_overrides:
69
- merged_mcps[name] = Config._deep_merge(
70
- base_mcps[name], mcp_overrides[name]
71
- )
70
+ merged_mcps[name] = deep_merge(base_mcps[name], mcp_overrides[name])
72
71
  elif name in base_mcps:
73
72
  merged_mcps[name] = copy.deepcopy(base_mcps[name])
74
73
  else:
ai_rules/profiles.py ADDED
@@ -0,0 +1,187 @@
1
+ """Profile loading and inheritance resolution."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from importlib.resources import files as resource_files
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import yaml
9
+
10
+ from ai_rules.utils import deep_merge
11
+
12
+
13
+ @dataclass
14
+ class Profile:
15
+ """A named collection of configuration overrides."""
16
+
17
+ name: str
18
+ description: str = ""
19
+ extends: str | None = None
20
+ settings_overrides: dict[str, dict[str, Any]] = field(default_factory=dict)
21
+ exclude_symlinks: list[str] = field(default_factory=list)
22
+ mcp_overrides: dict[str, dict[str, Any]] = field(default_factory=dict)
23
+
24
+
25
+ class ProfileError(Exception):
26
+ """Base exception for profile-related errors."""
27
+
28
+ pass
29
+
30
+
31
+ class ProfileNotFoundError(ProfileError):
32
+ """Raised when a profile is not found."""
33
+
34
+ pass
35
+
36
+
37
+ class CircularInheritanceError(ProfileError):
38
+ """Raised when circular profile inheritance is detected."""
39
+
40
+ pass
41
+
42
+
43
+ class ProfileLoader:
44
+ """Loads and resolves profile inheritance."""
45
+
46
+ def __init__(self, profiles_dir: Path | None = None):
47
+ """Initialize profile loader.
48
+
49
+ Args:
50
+ profiles_dir: Optional override for profiles directory (for testing)
51
+ """
52
+ if profiles_dir:
53
+ self._profiles_dir = profiles_dir
54
+ else:
55
+ config_resource = resource_files("ai_rules") / "config" / "profiles"
56
+ self._profiles_dir = Path(str(config_resource))
57
+
58
+ def list_profiles(self) -> list[str]:
59
+ """List all available profile names."""
60
+ if not self._profiles_dir.exists():
61
+ return ["default"]
62
+
63
+ profiles = []
64
+ for path in self._profiles_dir.glob("*.yaml"):
65
+ profiles.append(path.stem)
66
+
67
+ if "default" not in profiles:
68
+ profiles.append("default")
69
+
70
+ return sorted(profiles)
71
+
72
+ def load_profile(self, name: str) -> Profile:
73
+ """Load a profile by name, resolving inheritance.
74
+
75
+ Args:
76
+ name: Profile name (without .yaml extension)
77
+
78
+ Returns:
79
+ Fully resolved Profile with inherited values merged
80
+
81
+ Raises:
82
+ ProfileNotFoundError: If profile doesn't exist
83
+ CircularInheritanceError: If circular inheritance detected
84
+ """
85
+ return self._load_with_inheritance(name, visited=set())
86
+
87
+ def _load_with_inheritance(self, name: str, visited: set[str]) -> Profile:
88
+ """Recursively load profile with inheritance chain."""
89
+ if name in visited:
90
+ cycle = " -> ".join(visited) + f" -> {name}"
91
+ raise CircularInheritanceError(
92
+ f"Circular profile inheritance detected: {cycle}"
93
+ )
94
+
95
+ visited.add(name)
96
+
97
+ profile_path = self._profiles_dir / f"{name}.yaml"
98
+
99
+ if not profile_path.exists():
100
+ if name == "default":
101
+ return Profile(
102
+ name="default", description="Default profile (no overrides)"
103
+ )
104
+ available = self.list_profiles()
105
+ raise ProfileNotFoundError(
106
+ f"Profile '{name}' not found. Available profiles: {', '.join(available)}"
107
+ )
108
+
109
+ try:
110
+ with open(profile_path) as f:
111
+ data = yaml.safe_load(f) or {}
112
+ except yaml.YAMLError as e:
113
+ raise ProfileError(f"Profile '{name}' has invalid YAML: {e}") from e
114
+
115
+ self._validate_profile_data(data, name)
116
+
117
+ profile = Profile(
118
+ name=data.get("name", name),
119
+ description=data.get("description", ""),
120
+ extends=data.get("extends"),
121
+ settings_overrides=data.get("settings_overrides", {}),
122
+ exclude_symlinks=data.get("exclude_symlinks", []),
123
+ mcp_overrides=data.get("mcp_overrides", {}),
124
+ )
125
+
126
+ if profile.extends:
127
+ parent = self._load_with_inheritance(profile.extends, visited.copy())
128
+ profile = self._merge_profiles(parent, profile)
129
+
130
+ return profile
131
+
132
+ def _validate_profile_data(self, data: dict[str, Any], profile_name: str) -> None:
133
+ """Validate profile data types."""
134
+ if "settings_overrides" in data and not isinstance(
135
+ data["settings_overrides"], dict
136
+ ):
137
+ raise ProfileError(
138
+ f"Profile '{profile_name}': settings_overrides must be a dict"
139
+ )
140
+ if "exclude_symlinks" in data and not isinstance(
141
+ data["exclude_symlinks"], list
142
+ ):
143
+ raise ProfileError(
144
+ f"Profile '{profile_name}': exclude_symlinks must be a list"
145
+ )
146
+ if "mcp_overrides" in data and not isinstance(data["mcp_overrides"], dict):
147
+ raise ProfileError(
148
+ f"Profile '{profile_name}': mcp_overrides must be a dict"
149
+ )
150
+
151
+ def _merge_profiles(self, parent: Profile, child: Profile) -> Profile:
152
+ """Merge parent profile into child, with child taking precedence."""
153
+ merged_settings = deep_merge(
154
+ parent.settings_overrides, child.settings_overrides
155
+ )
156
+
157
+ merged_mcp = deep_merge(parent.mcp_overrides, child.mcp_overrides)
158
+
159
+ merged_excludes = list(
160
+ set(parent.exclude_symlinks) | set(child.exclude_symlinks)
161
+ )
162
+
163
+ return Profile(
164
+ name=child.name,
165
+ description=child.description,
166
+ extends=child.extends,
167
+ settings_overrides=merged_settings,
168
+ exclude_symlinks=merged_excludes,
169
+ mcp_overrides=merged_mcp,
170
+ )
171
+
172
+ def get_profile_info(self, name: str) -> dict[str, Any]:
173
+ """Get profile information without resolving inheritance."""
174
+ profile_path = self._profiles_dir / f"{name}.yaml"
175
+ if not profile_path.exists():
176
+ if name == "default":
177
+ return {
178
+ "name": "default",
179
+ "description": "Default profile (no overrides)",
180
+ }
181
+ raise ProfileNotFoundError(f"Profile '{name}' not found")
182
+
183
+ try:
184
+ with open(profile_path) as f:
185
+ return yaml.safe_load(f) or {}
186
+ except yaml.YAMLError as e:
187
+ raise ProfileError(f"Profile '{name}' has invalid YAML: {e}") from e
ai_rules/state.py ADDED
@@ -0,0 +1,47 @@
1
+ """State management for ai-rules."""
2
+
3
+ from datetime import UTC, datetime
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import yaml
8
+
9
+
10
+ def _get_state_file() -> Path:
11
+ """Get the state file path."""
12
+ return Path.home() / ".ai-rules" / "state.yaml"
13
+
14
+
15
+ def get_state() -> dict[str, Any]:
16
+ """Load state from file."""
17
+ state_file = _get_state_file()
18
+ if not state_file.exists():
19
+ return {}
20
+
21
+ try:
22
+ with state_file.open() as f:
23
+ return yaml.safe_load(f) or {}
24
+ except Exception:
25
+ return {}
26
+
27
+
28
+ def _save_state(state: dict[str, Any]) -> None:
29
+ """Save state to file."""
30
+ state_file = _get_state_file()
31
+ state_file.parent.mkdir(parents=True, exist_ok=True)
32
+ with state_file.open("w") as f:
33
+ yaml.dump(state, f, default_flow_style=False, sort_keys=False)
34
+
35
+
36
+ def get_active_profile() -> str | None:
37
+ """Get the currently active profile."""
38
+ state = get_state()
39
+ return state.get("active_profile")
40
+
41
+
42
+ def set_active_profile(profile: str) -> None:
43
+ """Set the active profile."""
44
+ state = get_state()
45
+ state["active_profile"] = profile
46
+ state["last_install"] = datetime.now(UTC).isoformat()
47
+ _save_state(state)
ai_rules/utils.py ADDED
@@ -0,0 +1,35 @@
1
+ """Shared utility functions."""
2
+
3
+ import copy
4
+
5
+ from typing import Any
6
+
7
+
8
+ def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
9
+ """Deep merge two dictionaries, with override values taking precedence.
10
+
11
+ Supports merging nested dictionaries and arrays. Arrays are merged element-by-element,
12
+ with dict elements being recursively merged.
13
+
14
+ Uses deep copy to prevent mutation of the base dictionary.
15
+ """
16
+ result = copy.deepcopy(base)
17
+ for key, value in override.items():
18
+ if key not in result:
19
+ result[key] = value
20
+ elif isinstance(result[key], dict) and isinstance(value, dict):
21
+ result[key] = deep_merge(result[key], value)
22
+ elif isinstance(result[key], list) and isinstance(value, list):
23
+ merged_array = copy.deepcopy(result[key])
24
+ for i, item in enumerate(value):
25
+ if i < len(merged_array):
26
+ if isinstance(merged_array[i], dict) and isinstance(item, dict):
27
+ merged_array[i] = deep_merge(merged_array[i], item)
28
+ else:
29
+ merged_array[i] = item
30
+ else:
31
+ merged_array.append(item)
32
+ result[key] = merged_array
33
+ else:
34
+ result[key] = value
35
+ return result