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/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