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/mcp.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""MCP server management."""
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import json
|
|
5
|
+
import shutil
|
|
6
|
+
import tempfile
|
|
7
|
+
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from functools import cached_property
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, cast
|
|
13
|
+
|
|
14
|
+
from .config import Config
|
|
15
|
+
from .utils import deep_merge
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class OperationResult(Enum):
|
|
19
|
+
CREATED = "created"
|
|
20
|
+
UPDATED = "updated"
|
|
21
|
+
REMOVED = "removed"
|
|
22
|
+
ALREADY_SYNCED = "already_synced"
|
|
23
|
+
NOT_FOUND = "not_found"
|
|
24
|
+
ERROR = "error"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MCPStatus:
|
|
28
|
+
def __init__(self) -> None:
|
|
29
|
+
self.managed_mcps: dict[str, dict[str, Any]] = {}
|
|
30
|
+
self.unmanaged_mcps: dict[str, dict[str, Any]] = {}
|
|
31
|
+
self.pending_mcps: dict[str, dict[str, Any]] = {}
|
|
32
|
+
self.stale_mcps: dict[str, dict[str, Any]] = {}
|
|
33
|
+
self.synced: dict[str, bool] = {}
|
|
34
|
+
self.has_overrides: dict[str, bool] = {}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
MANAGED_BY_KEY = "_managedBy"
|
|
38
|
+
MANAGED_BY_VALUE = "ai-rules"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class MCPManager:
|
|
42
|
+
BACKUP_SUFFIX = "ai-rules-backup"
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def CLAUDE_JSON(self) -> Path:
|
|
46
|
+
return Path.home() / ".claude.json"
|
|
47
|
+
|
|
48
|
+
def load_managed_mcps(self, config_dir: Path, config: Config) -> dict[str, Any]:
|
|
49
|
+
"""Load managed MCP definitions and apply user overrides.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
config_dir: Config directory path
|
|
53
|
+
config: Config instance with user overrides
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Dictionary of MCP name -> MCP config (with overrides applied)
|
|
57
|
+
"""
|
|
58
|
+
mcps_file = config_dir / "claude" / "mcps.json"
|
|
59
|
+
if not mcps_file.exists():
|
|
60
|
+
return {}
|
|
61
|
+
|
|
62
|
+
with open(mcps_file) as f:
|
|
63
|
+
base_mcps = json.load(f)
|
|
64
|
+
|
|
65
|
+
mcp_overrides = config.mcp_overrides if hasattr(config, "mcp_overrides") else {}
|
|
66
|
+
|
|
67
|
+
merged_mcps = {}
|
|
68
|
+
for name, _mcp_config in {**base_mcps, **mcp_overrides}.items():
|
|
69
|
+
if name in base_mcps and name in mcp_overrides:
|
|
70
|
+
merged_mcps[name] = deep_merge(base_mcps[name], mcp_overrides[name])
|
|
71
|
+
elif name in base_mcps:
|
|
72
|
+
merged_mcps[name] = copy.deepcopy(base_mcps[name])
|
|
73
|
+
else:
|
|
74
|
+
merged_mcps[name] = copy.deepcopy(mcp_overrides[name])
|
|
75
|
+
|
|
76
|
+
return merged_mcps
|
|
77
|
+
|
|
78
|
+
@cached_property
|
|
79
|
+
def claude_json(self) -> dict[str, Any]:
|
|
80
|
+
"""Cached ~/.claude.json file contents.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Dictionary containing Claude Code config
|
|
84
|
+
"""
|
|
85
|
+
if not self.CLAUDE_JSON.exists():
|
|
86
|
+
return {}
|
|
87
|
+
|
|
88
|
+
with open(self.CLAUDE_JSON) as f:
|
|
89
|
+
return cast(dict[str, Any], json.load(f))
|
|
90
|
+
|
|
91
|
+
def invalidate_cache(self) -> None:
|
|
92
|
+
"""Clear cached claude_json after writes to ensure fresh reads."""
|
|
93
|
+
if "claude_json" in self.__dict__:
|
|
94
|
+
del self.__dict__["claude_json"]
|
|
95
|
+
|
|
96
|
+
def save_claude_json(self, data: dict[str, Any]) -> None:
|
|
97
|
+
"""Save ~/.claude.json file atomically.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
data: Dictionary to save
|
|
101
|
+
"""
|
|
102
|
+
self.CLAUDE_JSON.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
|
|
104
|
+
fd, temp_path = tempfile.mkstemp(
|
|
105
|
+
dir=self.CLAUDE_JSON.parent, prefix=f".{self.CLAUDE_JSON.name}."
|
|
106
|
+
)
|
|
107
|
+
try:
|
|
108
|
+
with open(fd, "w") as f:
|
|
109
|
+
json.dump(data, f, indent=2)
|
|
110
|
+
|
|
111
|
+
if self.CLAUDE_JSON.exists():
|
|
112
|
+
shutil.copystat(self.CLAUDE_JSON, temp_path)
|
|
113
|
+
|
|
114
|
+
shutil.move(temp_path, self.CLAUDE_JSON)
|
|
115
|
+
|
|
116
|
+
# Invalidate cache after write
|
|
117
|
+
self.invalidate_cache()
|
|
118
|
+
except Exception:
|
|
119
|
+
if Path(temp_path).exists():
|
|
120
|
+
Path(temp_path).unlink()
|
|
121
|
+
raise
|
|
122
|
+
|
|
123
|
+
def create_backup(self) -> Path | None:
|
|
124
|
+
"""Create a timestamped backup of ~/.claude.json.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
Path to backup file, or None if source doesn't exist
|
|
128
|
+
"""
|
|
129
|
+
if not self.CLAUDE_JSON.exists():
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
133
|
+
backup_path = self.CLAUDE_JSON.with_suffix(
|
|
134
|
+
f"{self.CLAUDE_JSON.suffix}.{self.BACKUP_SUFFIX}.{timestamp}"
|
|
135
|
+
)
|
|
136
|
+
shutil.copy2(self.CLAUDE_JSON, backup_path)
|
|
137
|
+
return backup_path
|
|
138
|
+
|
|
139
|
+
def detect_conflicts(
|
|
140
|
+
self, expected: dict[str, Any], installed: dict[str, Any]
|
|
141
|
+
) -> list[str]:
|
|
142
|
+
"""Detect MCPs that have been modified locally.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
expected: Expected MCP configs from repo
|
|
146
|
+
installed: Currently installed MCP configs
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
List of MCP names that differ
|
|
150
|
+
"""
|
|
151
|
+
conflicts = []
|
|
152
|
+
for name, expected_config in expected.items():
|
|
153
|
+
if name in installed:
|
|
154
|
+
expected_without_marker = {
|
|
155
|
+
k: v for k, v in expected_config.items() if k != MANAGED_BY_KEY
|
|
156
|
+
}
|
|
157
|
+
installed_without_marker = {
|
|
158
|
+
k: v for k, v in installed[name].items() if k != MANAGED_BY_KEY
|
|
159
|
+
}
|
|
160
|
+
if expected_without_marker != installed_without_marker:
|
|
161
|
+
conflicts.append(name)
|
|
162
|
+
return conflicts
|
|
163
|
+
|
|
164
|
+
def format_diff(
|
|
165
|
+
self, name: str, expected: dict[str, Any], installed: dict[str, Any]
|
|
166
|
+
) -> str:
|
|
167
|
+
"""Format a diff between expected and installed MCP config.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
name: MCP name
|
|
171
|
+
expected: Expected config
|
|
172
|
+
installed: Installed config
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Formatted diff string
|
|
176
|
+
"""
|
|
177
|
+
import difflib
|
|
178
|
+
|
|
179
|
+
expected_json = json.dumps(expected, indent=2)
|
|
180
|
+
installed_json = json.dumps(installed, indent=2)
|
|
181
|
+
|
|
182
|
+
expected_lines = expected_json.splitlines(keepends=True)
|
|
183
|
+
installed_lines = installed_json.splitlines(keepends=True)
|
|
184
|
+
|
|
185
|
+
diff = difflib.unified_diff(
|
|
186
|
+
expected_lines,
|
|
187
|
+
installed_lines,
|
|
188
|
+
fromfile="Expected (repo)",
|
|
189
|
+
tofile="Installed (local)",
|
|
190
|
+
lineterm="",
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
return f"MCP '{name}' has been modified locally:\n" + "".join(diff)
|
|
194
|
+
|
|
195
|
+
def install_mcps(
|
|
196
|
+
self,
|
|
197
|
+
config_dir: Path,
|
|
198
|
+
config: Config,
|
|
199
|
+
force: bool = False,
|
|
200
|
+
dry_run: bool = False,
|
|
201
|
+
) -> tuple[OperationResult, str, list[str]]:
|
|
202
|
+
"""Install managed MCPs into ~/.claude.json.
|
|
203
|
+
|
|
204
|
+
Auto-removes MCPs that were previously tracked but are no longer in current config.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
config_dir: Config directory path
|
|
208
|
+
config: Config instance with user overrides
|
|
209
|
+
force: Skip confirmation prompts
|
|
210
|
+
dry_run: Don't actually modify files
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
Tuple of (result, message, conflicts_list)
|
|
214
|
+
"""
|
|
215
|
+
managed_mcps = self.load_managed_mcps(config_dir, config)
|
|
216
|
+
|
|
217
|
+
for _name, mcp_config in managed_mcps.items():
|
|
218
|
+
mcp_config[MANAGED_BY_KEY] = MANAGED_BY_VALUE
|
|
219
|
+
|
|
220
|
+
claude_data = self.claude_json
|
|
221
|
+
current_mcps = claude_data.get("mcpServers", {})
|
|
222
|
+
|
|
223
|
+
tracked_mcps = {
|
|
224
|
+
name
|
|
225
|
+
for name, cfg in current_mcps.items()
|
|
226
|
+
if cfg.get(MANAGED_BY_KEY) == MANAGED_BY_VALUE
|
|
227
|
+
}
|
|
228
|
+
removed_mcps = tracked_mcps - set(managed_mcps.keys())
|
|
229
|
+
|
|
230
|
+
if not managed_mcps and not removed_mcps:
|
|
231
|
+
return (
|
|
232
|
+
OperationResult.NOT_FOUND,
|
|
233
|
+
"No MCPs to install or remove",
|
|
234
|
+
[],
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
conflicts = self.detect_conflicts(managed_mcps, current_mcps)
|
|
238
|
+
|
|
239
|
+
if conflicts and not force:
|
|
240
|
+
return (
|
|
241
|
+
OperationResult.ERROR,
|
|
242
|
+
"Conflicts detected (use --force to override)",
|
|
243
|
+
conflicts,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
if dry_run:
|
|
247
|
+
msg = f"Would update {len(managed_mcps)} MCPs, remove {len(removed_mcps)}"
|
|
248
|
+
return (OperationResult.UPDATED, msg, conflicts)
|
|
249
|
+
|
|
250
|
+
if not managed_mcps and not removed_mcps:
|
|
251
|
+
new_tracking = set(managed_mcps.keys())
|
|
252
|
+
if tracked_mcps == new_tracking:
|
|
253
|
+
return (OperationResult.ALREADY_SYNCED, "MCPs are already synced", [])
|
|
254
|
+
|
|
255
|
+
backup_path = self.create_backup() if (managed_mcps or removed_mcps) else None
|
|
256
|
+
|
|
257
|
+
for name in removed_mcps:
|
|
258
|
+
current_mcps.pop(name, None)
|
|
259
|
+
|
|
260
|
+
claude_data.setdefault("mcpServers", {})
|
|
261
|
+
claude_data["mcpServers"].update(managed_mcps)
|
|
262
|
+
|
|
263
|
+
self.save_claude_json(claude_data)
|
|
264
|
+
|
|
265
|
+
parts = []
|
|
266
|
+
if managed_mcps:
|
|
267
|
+
parts.append(f"installed {len(managed_mcps)}")
|
|
268
|
+
if removed_mcps:
|
|
269
|
+
parts.append(f"removed {len(removed_mcps)}")
|
|
270
|
+
msg = f"MCPs {', '.join(parts)}"
|
|
271
|
+
if backup_path:
|
|
272
|
+
msg += f" (backup: {backup_path})"
|
|
273
|
+
|
|
274
|
+
return (OperationResult.UPDATED, msg, [])
|
|
275
|
+
|
|
276
|
+
def uninstall_mcps(
|
|
277
|
+
self, force: bool = False, dry_run: bool = False
|
|
278
|
+
) -> tuple[OperationResult, str]:
|
|
279
|
+
"""Uninstall managed MCPs from ~/.claude.json.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
force: Skip confirmation prompts
|
|
283
|
+
dry_run: Don't actually modify files
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
Tuple of (result, message)
|
|
287
|
+
"""
|
|
288
|
+
claude_data = self.claude_json
|
|
289
|
+
if not claude_data or "mcpServers" not in claude_data:
|
|
290
|
+
return (OperationResult.NOT_FOUND, "No MCPs found in ~/.claude.json")
|
|
291
|
+
|
|
292
|
+
current_mcps = claude_data["mcpServers"]
|
|
293
|
+
tracked_mcps = {
|
|
294
|
+
name
|
|
295
|
+
for name, cfg in current_mcps.items()
|
|
296
|
+
if cfg.get(MANAGED_BY_KEY) == MANAGED_BY_VALUE
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if not tracked_mcps:
|
|
300
|
+
return (OperationResult.NOT_FOUND, "No tracked MCPs found")
|
|
301
|
+
|
|
302
|
+
if dry_run:
|
|
303
|
+
return (
|
|
304
|
+
OperationResult.REMOVED,
|
|
305
|
+
f"Would remove {len(tracked_mcps)} MCPs (dry run)",
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
backup_path = self.create_backup()
|
|
309
|
+
|
|
310
|
+
for name in tracked_mcps:
|
|
311
|
+
current_mcps.pop(name, None)
|
|
312
|
+
|
|
313
|
+
self.save_claude_json(claude_data)
|
|
314
|
+
|
|
315
|
+
backup_msg = f" (backup: {backup_path})" if backup_path else ""
|
|
316
|
+
return (
|
|
317
|
+
OperationResult.REMOVED,
|
|
318
|
+
f"Removed {len(tracked_mcps)} MCPs{backup_msg}",
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
def get_status(self, config_dir: Path, config: Config) -> MCPStatus:
|
|
322
|
+
"""Get status of managed and unmanaged MCPs.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
config_dir: Config directory path
|
|
326
|
+
config: Config instance
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
MCPStatus object with categorized MCPs
|
|
330
|
+
"""
|
|
331
|
+
self.invalidate_cache()
|
|
332
|
+
|
|
333
|
+
status = MCPStatus()
|
|
334
|
+
managed_mcps = self.load_managed_mcps(config_dir, config)
|
|
335
|
+
|
|
336
|
+
for _name, mcp_config in managed_mcps.items():
|
|
337
|
+
mcp_config[MANAGED_BY_KEY] = MANAGED_BY_VALUE
|
|
338
|
+
|
|
339
|
+
claude_data = self.claude_json
|
|
340
|
+
installed_mcps = claude_data.get("mcpServers", {})
|
|
341
|
+
|
|
342
|
+
mcp_overrides = config.mcp_overrides if hasattr(config, "mcp_overrides") else {}
|
|
343
|
+
|
|
344
|
+
for name, mcp_config in installed_mcps.items():
|
|
345
|
+
if mcp_config.get(MANAGED_BY_KEY) == MANAGED_BY_VALUE:
|
|
346
|
+
if name in managed_mcps:
|
|
347
|
+
status.managed_mcps[name] = mcp_config
|
|
348
|
+
expected_config = managed_mcps.get(name, {})
|
|
349
|
+
expected_without_marker = {
|
|
350
|
+
k: v for k, v in expected_config.items() if k != MANAGED_BY_KEY
|
|
351
|
+
}
|
|
352
|
+
installed_without_marker = {
|
|
353
|
+
k: v for k, v in mcp_config.items() if k != MANAGED_BY_KEY
|
|
354
|
+
}
|
|
355
|
+
status.synced[name] = (
|
|
356
|
+
expected_without_marker == installed_without_marker
|
|
357
|
+
)
|
|
358
|
+
status.has_overrides[name] = name in mcp_overrides
|
|
359
|
+
else:
|
|
360
|
+
status.stale_mcps[name] = mcp_config
|
|
361
|
+
else:
|
|
362
|
+
status.unmanaged_mcps[name] = mcp_config
|
|
363
|
+
|
|
364
|
+
for name, mcp_config in managed_mcps.items():
|
|
365
|
+
if name not in installed_mcps:
|
|
366
|
+
status.pending_mcps[name] = mcp_config
|
|
367
|
+
status.has_overrides[name] = name in mcp_overrides
|
|
368
|
+
|
|
369
|
+
return status
|
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
|