ai-agent-rules 0.11.0__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.

Files changed (42) hide show
  1. ai_agent_rules-0.11.0.dist-info/METADATA +390 -0
  2. ai_agent_rules-0.11.0.dist-info/RECORD +42 -0
  3. ai_agent_rules-0.11.0.dist-info/WHEEL +5 -0
  4. ai_agent_rules-0.11.0.dist-info/entry_points.txt +3 -0
  5. ai_agent_rules-0.11.0.dist-info/licenses/LICENSE +22 -0
  6. ai_agent_rules-0.11.0.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 +121 -0
  11. ai_rules/agents/goose.py +44 -0
  12. ai_rules/agents/shared.py +35 -0
  13. ai_rules/bootstrap/__init__.py +75 -0
  14. ai_rules/bootstrap/config.py +261 -0
  15. ai_rules/bootstrap/installer.py +249 -0
  16. ai_rules/bootstrap/updater.py +221 -0
  17. ai_rules/bootstrap/version.py +52 -0
  18. ai_rules/cli.py +2292 -0
  19. ai_rules/completions.py +194 -0
  20. ai_rules/config/AGENTS.md +249 -0
  21. ai_rules/config/chat_agent_hints.md +1 -0
  22. ai_rules/config/claude/agents/code-reviewer.md +121 -0
  23. ai_rules/config/claude/commands/annotate-changelog.md +191 -0
  24. ai_rules/config/claude/commands/comment-cleanup.md +161 -0
  25. ai_rules/config/claude/commands/continue-crash.md +38 -0
  26. ai_rules/config/claude/commands/dev-docs.md +169 -0
  27. ai_rules/config/claude/commands/pr-creator.md +247 -0
  28. ai_rules/config/claude/commands/test-cleanup.md +244 -0
  29. ai_rules/config/claude/commands/update-docs.md +324 -0
  30. ai_rules/config/claude/hooks/subagentStop.py +92 -0
  31. ai_rules/config/claude/mcps.json +1 -0
  32. ai_rules/config/claude/settings.json +116 -0
  33. ai_rules/config/claude/skills/doc-writer/SKILL.md +293 -0
  34. ai_rules/config/claude/skills/doc-writer/resources/templates.md +495 -0
  35. ai_rules/config/claude/skills/prompt-engineer/SKILL.md +272 -0
  36. ai_rules/config/claude/skills/prompt-engineer/resources/prompt_engineering_guide_2025.md +855 -0
  37. ai_rules/config/claude/skills/prompt-engineer/resources/templates.md +232 -0
  38. ai_rules/config/goose/config.yaml +55 -0
  39. ai_rules/config.py +635 -0
  40. ai_rules/display.py +40 -0
  41. ai_rules/mcp.py +370 -0
  42. ai_rules/symlinks.py +207 -0
ai_rules/mcp.py ADDED
@@ -0,0 +1,370 @@
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
+
16
+
17
+ class OperationResult(Enum):
18
+ CREATED = "created"
19
+ UPDATED = "updated"
20
+ REMOVED = "removed"
21
+ ALREADY_SYNCED = "already_synced"
22
+ NOT_FOUND = "not_found"
23
+ ERROR = "error"
24
+
25
+
26
+ class MCPStatus:
27
+ def __init__(self) -> None:
28
+ self.managed_mcps: dict[str, dict[str, Any]] = {}
29
+ self.unmanaged_mcps: dict[str, dict[str, Any]] = {}
30
+ self.pending_mcps: dict[str, dict[str, Any]] = {}
31
+ self.stale_mcps: dict[str, dict[str, Any]] = {}
32
+ self.synced: dict[str, bool] = {}
33
+ self.has_overrides: dict[str, bool] = {}
34
+
35
+
36
+ MANAGED_BY_KEY = "_managedBy"
37
+ MANAGED_BY_VALUE = "ai-rules"
38
+
39
+
40
+ class MCPManager:
41
+ BACKUP_SUFFIX = "ai-rules-backup"
42
+
43
+ @property
44
+ def CLAUDE_JSON(self) -> Path:
45
+ return Path.home() / ".claude.json"
46
+
47
+ def load_managed_mcps(self, config_dir: Path, config: Config) -> dict[str, Any]:
48
+ """Load managed MCP definitions and apply user overrides.
49
+
50
+ Args:
51
+ config_dir: Config directory path
52
+ config: Config instance with user overrides
53
+
54
+ Returns:
55
+ Dictionary of MCP name -> MCP config (with overrides applied)
56
+ """
57
+ mcps_file = config_dir / "claude" / "mcps.json"
58
+ if not mcps_file.exists():
59
+ return {}
60
+
61
+ with open(mcps_file) as f:
62
+ base_mcps = json.load(f)
63
+
64
+ mcp_overrides = config.mcp_overrides if hasattr(config, "mcp_overrides") else {}
65
+
66
+ merged_mcps = {}
67
+ for name, _mcp_config in {**base_mcps, **mcp_overrides}.items():
68
+ 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
+ )
72
+ elif name in base_mcps:
73
+ merged_mcps[name] = copy.deepcopy(base_mcps[name])
74
+ else:
75
+ merged_mcps[name] = copy.deepcopy(mcp_overrides[name])
76
+
77
+ return merged_mcps
78
+
79
+ @cached_property
80
+ def claude_json(self) -> dict[str, Any]:
81
+ """Cached ~/.claude.json file contents.
82
+
83
+ Returns:
84
+ Dictionary containing Claude Code config
85
+ """
86
+ if not self.CLAUDE_JSON.exists():
87
+ return {}
88
+
89
+ with open(self.CLAUDE_JSON) as f:
90
+ return cast(dict[str, Any], json.load(f))
91
+
92
+ def invalidate_cache(self) -> None:
93
+ """Clear cached claude_json after writes to ensure fresh reads."""
94
+ if "claude_json" in self.__dict__:
95
+ del self.__dict__["claude_json"]
96
+
97
+ def save_claude_json(self, data: dict[str, Any]) -> None:
98
+ """Save ~/.claude.json file atomically.
99
+
100
+ Args:
101
+ data: Dictionary to save
102
+ """
103
+ self.CLAUDE_JSON.parent.mkdir(parents=True, exist_ok=True)
104
+
105
+ fd, temp_path = tempfile.mkstemp(
106
+ dir=self.CLAUDE_JSON.parent, prefix=f".{self.CLAUDE_JSON.name}."
107
+ )
108
+ try:
109
+ with open(fd, "w") as f:
110
+ json.dump(data, f, indent=2)
111
+
112
+ if self.CLAUDE_JSON.exists():
113
+ shutil.copystat(self.CLAUDE_JSON, temp_path)
114
+
115
+ shutil.move(temp_path, self.CLAUDE_JSON)
116
+
117
+ # Invalidate cache after write
118
+ self.invalidate_cache()
119
+ except Exception:
120
+ if Path(temp_path).exists():
121
+ Path(temp_path).unlink()
122
+ raise
123
+
124
+ def create_backup(self) -> Path | None:
125
+ """Create a timestamped backup of ~/.claude.json.
126
+
127
+ Returns:
128
+ Path to backup file, or None if source doesn't exist
129
+ """
130
+ if not self.CLAUDE_JSON.exists():
131
+ return None
132
+
133
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
134
+ backup_path = self.CLAUDE_JSON.with_suffix(
135
+ f"{self.CLAUDE_JSON.suffix}.{self.BACKUP_SUFFIX}.{timestamp}"
136
+ )
137
+ shutil.copy2(self.CLAUDE_JSON, backup_path)
138
+ return backup_path
139
+
140
+ def detect_conflicts(
141
+ self, expected: dict[str, Any], installed: dict[str, Any]
142
+ ) -> list[str]:
143
+ """Detect MCPs that have been modified locally.
144
+
145
+ Args:
146
+ expected: Expected MCP configs from repo
147
+ installed: Currently installed MCP configs
148
+
149
+ Returns:
150
+ List of MCP names that differ
151
+ """
152
+ conflicts = []
153
+ for name, expected_config in expected.items():
154
+ if name in installed:
155
+ expected_without_marker = {
156
+ k: v for k, v in expected_config.items() if k != MANAGED_BY_KEY
157
+ }
158
+ installed_without_marker = {
159
+ k: v for k, v in installed[name].items() if k != MANAGED_BY_KEY
160
+ }
161
+ if expected_without_marker != installed_without_marker:
162
+ conflicts.append(name)
163
+ return conflicts
164
+
165
+ def format_diff(
166
+ self, name: str, expected: dict[str, Any], installed: dict[str, Any]
167
+ ) -> str:
168
+ """Format a diff between expected and installed MCP config.
169
+
170
+ Args:
171
+ name: MCP name
172
+ expected: Expected config
173
+ installed: Installed config
174
+
175
+ Returns:
176
+ Formatted diff string
177
+ """
178
+ import difflib
179
+
180
+ expected_json = json.dumps(expected, indent=2)
181
+ installed_json = json.dumps(installed, indent=2)
182
+
183
+ expected_lines = expected_json.splitlines(keepends=True)
184
+ installed_lines = installed_json.splitlines(keepends=True)
185
+
186
+ diff = difflib.unified_diff(
187
+ expected_lines,
188
+ installed_lines,
189
+ fromfile="Expected (repo)",
190
+ tofile="Installed (local)",
191
+ lineterm="",
192
+ )
193
+
194
+ return f"MCP '{name}' has been modified locally:\n" + "".join(diff)
195
+
196
+ def install_mcps(
197
+ self,
198
+ config_dir: Path,
199
+ config: Config,
200
+ force: bool = False,
201
+ dry_run: bool = False,
202
+ ) -> tuple[OperationResult, str, list[str]]:
203
+ """Install managed MCPs into ~/.claude.json.
204
+
205
+ Auto-removes MCPs that were previously tracked but are no longer in current config.
206
+
207
+ Args:
208
+ config_dir: Config directory path
209
+ config: Config instance with user overrides
210
+ force: Skip confirmation prompts
211
+ dry_run: Don't actually modify files
212
+
213
+ Returns:
214
+ Tuple of (result, message, conflicts_list)
215
+ """
216
+ managed_mcps = self.load_managed_mcps(config_dir, config)
217
+
218
+ for _name, mcp_config in managed_mcps.items():
219
+ mcp_config[MANAGED_BY_KEY] = MANAGED_BY_VALUE
220
+
221
+ claude_data = self.claude_json
222
+ current_mcps = claude_data.get("mcpServers", {})
223
+
224
+ tracked_mcps = {
225
+ name
226
+ for name, cfg in current_mcps.items()
227
+ if cfg.get(MANAGED_BY_KEY) == MANAGED_BY_VALUE
228
+ }
229
+ removed_mcps = tracked_mcps - set(managed_mcps.keys())
230
+
231
+ if not managed_mcps and not removed_mcps:
232
+ return (
233
+ OperationResult.NOT_FOUND,
234
+ "No MCPs to install or remove",
235
+ [],
236
+ )
237
+
238
+ conflicts = self.detect_conflicts(managed_mcps, current_mcps)
239
+
240
+ if conflicts and not force:
241
+ return (
242
+ OperationResult.ERROR,
243
+ "Conflicts detected (use --force to override)",
244
+ conflicts,
245
+ )
246
+
247
+ if dry_run:
248
+ msg = f"Would update {len(managed_mcps)} MCPs, remove {len(removed_mcps)}"
249
+ return (OperationResult.UPDATED, msg, conflicts)
250
+
251
+ if not managed_mcps and not removed_mcps:
252
+ new_tracking = set(managed_mcps.keys())
253
+ if tracked_mcps == new_tracking:
254
+ return (OperationResult.ALREADY_SYNCED, "MCPs are already synced", [])
255
+
256
+ backup_path = self.create_backup() if (managed_mcps or removed_mcps) else None
257
+
258
+ for name in removed_mcps:
259
+ current_mcps.pop(name, None)
260
+
261
+ claude_data.setdefault("mcpServers", {})
262
+ claude_data["mcpServers"].update(managed_mcps)
263
+
264
+ self.save_claude_json(claude_data)
265
+
266
+ parts = []
267
+ if managed_mcps:
268
+ parts.append(f"installed {len(managed_mcps)}")
269
+ if removed_mcps:
270
+ parts.append(f"removed {len(removed_mcps)}")
271
+ msg = f"MCPs {', '.join(parts)}"
272
+ if backup_path:
273
+ msg += f" (backup: {backup_path})"
274
+
275
+ return (OperationResult.UPDATED, msg, [])
276
+
277
+ def uninstall_mcps(
278
+ self, force: bool = False, dry_run: bool = False
279
+ ) -> tuple[OperationResult, str]:
280
+ """Uninstall managed MCPs from ~/.claude.json.
281
+
282
+ Args:
283
+ force: Skip confirmation prompts
284
+ dry_run: Don't actually modify files
285
+
286
+ Returns:
287
+ Tuple of (result, message)
288
+ """
289
+ claude_data = self.claude_json
290
+ if not claude_data or "mcpServers" not in claude_data:
291
+ return (OperationResult.NOT_FOUND, "No MCPs found in ~/.claude.json")
292
+
293
+ current_mcps = claude_data["mcpServers"]
294
+ tracked_mcps = {
295
+ name
296
+ for name, cfg in current_mcps.items()
297
+ if cfg.get(MANAGED_BY_KEY) == MANAGED_BY_VALUE
298
+ }
299
+
300
+ if not tracked_mcps:
301
+ return (OperationResult.NOT_FOUND, "No tracked MCPs found")
302
+
303
+ if dry_run:
304
+ return (
305
+ OperationResult.REMOVED,
306
+ f"Would remove {len(tracked_mcps)} MCPs (dry run)",
307
+ )
308
+
309
+ backup_path = self.create_backup()
310
+
311
+ for name in tracked_mcps:
312
+ current_mcps.pop(name, None)
313
+
314
+ self.save_claude_json(claude_data)
315
+
316
+ backup_msg = f" (backup: {backup_path})" if backup_path else ""
317
+ return (
318
+ OperationResult.REMOVED,
319
+ f"Removed {len(tracked_mcps)} MCPs{backup_msg}",
320
+ )
321
+
322
+ def get_status(self, config_dir: Path, config: Config) -> MCPStatus:
323
+ """Get status of managed and unmanaged MCPs.
324
+
325
+ Args:
326
+ config_dir: Config directory path
327
+ config: Config instance
328
+
329
+ Returns:
330
+ MCPStatus object with categorized MCPs
331
+ """
332
+ self.invalidate_cache()
333
+
334
+ status = MCPStatus()
335
+ managed_mcps = self.load_managed_mcps(config_dir, config)
336
+
337
+ for _name, mcp_config in managed_mcps.items():
338
+ mcp_config[MANAGED_BY_KEY] = MANAGED_BY_VALUE
339
+
340
+ claude_data = self.claude_json
341
+ installed_mcps = claude_data.get("mcpServers", {})
342
+
343
+ mcp_overrides = config.mcp_overrides if hasattr(config, "mcp_overrides") else {}
344
+
345
+ for name, mcp_config in installed_mcps.items():
346
+ if mcp_config.get(MANAGED_BY_KEY) == MANAGED_BY_VALUE:
347
+ if name in managed_mcps:
348
+ status.managed_mcps[name] = mcp_config
349
+ expected_config = managed_mcps.get(name, {})
350
+ expected_without_marker = {
351
+ k: v for k, v in expected_config.items() if k != MANAGED_BY_KEY
352
+ }
353
+ installed_without_marker = {
354
+ k: v for k, v in mcp_config.items() if k != MANAGED_BY_KEY
355
+ }
356
+ status.synced[name] = (
357
+ expected_without_marker == installed_without_marker
358
+ )
359
+ status.has_overrides[name] = name in mcp_overrides
360
+ else:
361
+ status.stale_mcps[name] = mcp_config
362
+ else:
363
+ status.unmanaged_mcps[name] = mcp_config
364
+
365
+ for name, mcp_config in managed_mcps.items():
366
+ if name not in installed_mcps:
367
+ status.pending_mcps[name] = mcp_config
368
+ status.has_overrides[name] = name in mcp_overrides
369
+
370
+ return status
ai_rules/symlinks.py ADDED
@@ -0,0 +1,207 @@
1
+ """Symlink operations with safety checks."""
2
+
3
+ import os
4
+
5
+ from datetime import datetime
6
+ from enum import Enum
7
+ from pathlib import Path
8
+
9
+ from rich.console import Console
10
+
11
+ console = Console()
12
+
13
+
14
+ def create_backup_path(target: Path) -> Path:
15
+ """Create a timestamped backup path.
16
+
17
+ Args:
18
+ target: The file to backup
19
+
20
+ Returns:
21
+ Path with timestamp appended (e.g., file.md.ai-rules-backup.20250104-143022)
22
+ """
23
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
24
+ return Path(f"{target}.ai-rules-backup.{timestamp}")
25
+
26
+
27
+ class SymlinkResult(Enum):
28
+ """Result of symlink operation."""
29
+
30
+ CREATED = "created"
31
+ ALREADY_CORRECT = "already_correct"
32
+ UPDATED = "updated"
33
+ SKIPPED = "skipped"
34
+ ERROR = "error"
35
+
36
+
37
+ def create_symlink(
38
+ target_path: Path,
39
+ source_path: Path,
40
+ force: bool = False,
41
+ dry_run: bool = False,
42
+ ) -> tuple[SymlinkResult, str]:
43
+ """Create a symlink with safety checks.
44
+
45
+ Args:
46
+ target_path: Where the symlink should be created (e.g., ~/.CLAUDE.md)
47
+ source_path: What the symlink should point to (e.g., repo/config/AGENTS.md)
48
+ force: Skip confirmations
49
+ dry_run: Don't actually create symlinks
50
+
51
+ Returns:
52
+ Tuple of (result, message)
53
+ """
54
+ target = target_path.expanduser()
55
+ source = source_path.absolute()
56
+
57
+ if not source.exists():
58
+ return (
59
+ SymlinkResult.ERROR,
60
+ f"Source file does not exist: {source}",
61
+ )
62
+
63
+ if target.exists() or target.is_symlink():
64
+ if target.is_symlink():
65
+ current = target.resolve()
66
+ if current == source:
67
+ return (SymlinkResult.ALREADY_CORRECT, "Already correct")
68
+ elif dry_run:
69
+ return (SymlinkResult.UPDATED, f"Would update: {current} → {source}")
70
+ elif force:
71
+ target.unlink()
72
+ else:
73
+ response = console.input(
74
+ f"[yellow]?[/yellow] Symlink {target} exists but points to {current}\n Replace with {source}? (y/N): "
75
+ )
76
+ if response.lower() != "y":
77
+ return (SymlinkResult.SKIPPED, "Skipped by user")
78
+ target.unlink()
79
+ else:
80
+ if dry_run:
81
+ return (
82
+ SymlinkResult.CREATED,
83
+ f"Would backup {target} and create symlink",
84
+ )
85
+ elif force:
86
+ backup = create_backup_path(target)
87
+ target.rename(backup)
88
+ console.print(f" [dim]Backed up to {backup}[/dim]")
89
+ else:
90
+ response = console.input(
91
+ f"[yellow]?[/yellow] File {target} exists and is not a symlink\n Replace with symlink? (y/N): "
92
+ )
93
+ if response.lower() != "y":
94
+ return (SymlinkResult.SKIPPED, "Skipped by user")
95
+ backup = create_backup_path(target)
96
+ target.rename(backup)
97
+ console.print(f" [dim]Backed up to {backup}[/dim]")
98
+
99
+ if dry_run:
100
+ return (SymlinkResult.CREATED, f"Would create: {target} → {source}")
101
+
102
+ target.parent.mkdir(parents=True, exist_ok=True)
103
+
104
+ try:
105
+ rel_source = os.path.relpath(source, target.parent)
106
+ target.symlink_to(rel_source)
107
+ return (SymlinkResult.CREATED, "Created")
108
+ except PermissionError as e:
109
+ return (
110
+ SymlinkResult.ERROR,
111
+ f"Permission denied: {e}\n"
112
+ " [dim]Tip: Check file permissions and ownership. "
113
+ "You may need to remove existing files manually.[/dim]",
114
+ )
115
+ except FileExistsError as e:
116
+ return (
117
+ SymlinkResult.ERROR,
118
+ f"File already exists: {e}\n"
119
+ " [dim]Tip: Use --force to replace existing files.[/dim]",
120
+ )
121
+ except (OSError, ValueError) as e:
122
+ try:
123
+ target.symlink_to(source)
124
+ return (SymlinkResult.CREATED, "Created (absolute path)")
125
+ except PermissionError:
126
+ return (
127
+ SymlinkResult.ERROR,
128
+ f"Permission denied: {e}\n"
129
+ " [dim]Tip: Check file permissions and ownership.[/dim]",
130
+ )
131
+ except Exception as e2:
132
+ return (
133
+ SymlinkResult.ERROR,
134
+ f"Failed to create symlink: {e2}\n"
135
+ " [dim]Tip: Check that the target directory exists and is writable.[/dim]",
136
+ )
137
+
138
+
139
+ def check_symlink(target_path: Path, expected_source: Path) -> tuple[str, str]:
140
+ """Check if a symlink is correct.
141
+
142
+ Returns:
143
+ Tuple of (status, message) where status is one of:
144
+ - "correct": Symlink exists and points to correct location
145
+ - "missing": Symlink does not exist
146
+ - "broken": Symlink exists but target doesn't exist
147
+ - "wrong_target": Symlink points to wrong location
148
+ - "not_symlink": File exists but is not a symlink
149
+ """
150
+ target = target_path.expanduser()
151
+ expected = expected_source.absolute()
152
+
153
+ if not target.exists() and not target.is_symlink():
154
+ return ("missing", "Not installed")
155
+
156
+ if not target.is_symlink():
157
+ return ("not_symlink", "File exists but is not a symlink")
158
+
159
+ try:
160
+ actual = target.resolve()
161
+ except (OSError, RuntimeError):
162
+ return ("broken", "Symlink is broken")
163
+
164
+ if actual == expected:
165
+ return ("correct", str(expected))
166
+ else:
167
+ return ("wrong_target", f"Points to {actual} instead of {expected}")
168
+
169
+
170
+ def remove_symlink(target_path: Path, force: bool = False) -> tuple[bool, str]:
171
+ """Remove a symlink safely.
172
+
173
+ Args:
174
+ target_path: Path to the symlink to remove
175
+ force: Skip confirmation
176
+
177
+ Returns:
178
+ Tuple of (success, message)
179
+ """
180
+ target = target_path.expanduser()
181
+
182
+ if not target.exists() and not target.is_symlink():
183
+ return (False, "Does not exist")
184
+
185
+ if not target.is_symlink():
186
+ return (False, "Not a symlink (refusing to delete)")
187
+
188
+ if not force:
189
+ response = console.input(f"[yellow]?[/yellow] Remove {target}? (y/N): ")
190
+ if response.lower() != "y":
191
+ return (False, "Skipped by user")
192
+
193
+ try:
194
+ target.unlink()
195
+ return (True, "Removed")
196
+ except PermissionError as e:
197
+ return (
198
+ False,
199
+ f"Permission denied: {e}\n"
200
+ " [dim]Tip: Check file permissions. You may need elevated privileges.[/dim]",
201
+ )
202
+ except OSError as e:
203
+ return (
204
+ False,
205
+ f"Error removing symlink: {e}\n"
206
+ " [dim]Tip: Check that the file exists and is accessible.[/dim]",
207
+ )