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