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
@@ -0,0 +1,123 @@
1
+ """Claude Code agent implementation."""
2
+
3
+ from functools import cached_property
4
+ from pathlib import Path
5
+
6
+ from ai_rules.agents.base import Agent
7
+ from ai_rules.mcp import MCPManager, MCPStatus, OperationResult
8
+
9
+
10
+ class ClaudeAgent(Agent):
11
+ """Agent for Claude Code configuration."""
12
+
13
+ DEPRECATED_SYMLINKS: list[Path] = [
14
+ Path("~/CLAUDE.md"),
15
+ ]
16
+
17
+ @property
18
+ def name(self) -> str:
19
+ return "Claude Code"
20
+
21
+ @property
22
+ def agent_id(self) -> str:
23
+ return "claude"
24
+
25
+ @property
26
+ def config_file_name(self) -> str:
27
+ return "settings.json"
28
+
29
+ @property
30
+ def config_file_format(self) -> str:
31
+ return "json"
32
+
33
+ @cached_property
34
+ def symlinks(self) -> list[tuple[Path, Path]]:
35
+ """Cached list of all Claude Code symlinks including dynamic agents/commands."""
36
+ result = []
37
+
38
+ result.append(
39
+ (Path("~/.claude/CLAUDE.md"), self.config_dir / "claude" / "CLAUDE.md")
40
+ )
41
+
42
+ settings_file = self.config_dir / "claude" / "settings.json"
43
+ if settings_file.exists():
44
+ target_file = self.config.get_settings_file_for_symlink(
45
+ "claude", settings_file
46
+ )
47
+ result.append((Path("~/.claude/settings.json"), target_file))
48
+
49
+ agents_dir = self.config_dir / "claude" / "agents"
50
+ if agents_dir.exists():
51
+ for agent_file in sorted(agents_dir.glob("*.md")):
52
+ result.append(
53
+ (
54
+ Path(f"~/.claude/agents/{agent_file.name}"),
55
+ agent_file,
56
+ )
57
+ )
58
+
59
+ commands_dir = self.config_dir / "claude" / "commands"
60
+ if commands_dir.exists():
61
+ for command_file in sorted(commands_dir.glob("*.md")):
62
+ result.append(
63
+ (
64
+ Path(f"~/.claude/commands/{command_file.name}"),
65
+ command_file,
66
+ )
67
+ )
68
+
69
+ skills_dir = self.config_dir / "claude" / "skills"
70
+ if skills_dir.exists():
71
+ for skill_folder in sorted(skills_dir.glob("*")):
72
+ if skill_folder.is_dir():
73
+ result.append(
74
+ (
75
+ Path(f"~/.claude/skills/{skill_folder.name}"),
76
+ skill_folder,
77
+ )
78
+ )
79
+
80
+ return result
81
+
82
+ def get_deprecated_symlinks(self) -> list[Path]:
83
+ """Return deprecated symlink locations for cleanup."""
84
+ return self.DEPRECATED_SYMLINKS
85
+
86
+ def install_mcps(
87
+ self, force: bool = False, dry_run: bool = False
88
+ ) -> tuple[OperationResult, str, list[str]]:
89
+ """Install managed MCPs into ~/.claude.json.
90
+
91
+ Args:
92
+ force: Skip confirmation prompts
93
+ dry_run: Don't actually modify files
94
+
95
+ Returns:
96
+ Tuple of (result, message, conflicts_list)
97
+ """
98
+ manager = MCPManager()
99
+ return manager.install_mcps(self.config_dir, self.config, force, dry_run)
100
+
101
+ def uninstall_mcps(
102
+ self, force: bool = False, dry_run: bool = False
103
+ ) -> tuple[OperationResult, str]:
104
+ """Uninstall managed MCPs from ~/.claude.json.
105
+
106
+ Args:
107
+ force: Skip confirmation prompts
108
+ dry_run: Don't actually modify files
109
+
110
+ Returns:
111
+ Tuple of (result, message)
112
+ """
113
+ manager = MCPManager()
114
+ return manager.uninstall_mcps(force, dry_run)
115
+
116
+ def get_mcp_status(self) -> MCPStatus:
117
+ """Get status of managed and unmanaged MCPs.
118
+
119
+ Returns:
120
+ MCPStatus object with categorized MCPs
121
+ """
122
+ manager = MCPManager()
123
+ return manager.get_status(self.config_dir, self.config)
@@ -0,0 +1,70 @@
1
+ """Cursor editor agent implementation."""
2
+
3
+ import sys
4
+
5
+ from functools import cached_property
6
+ from pathlib import Path
7
+
8
+ from ai_rules.agents.base import Agent
9
+
10
+
11
+ def _get_cursor_target_prefix() -> str:
12
+ """Get platform-specific Cursor config path with ~ prefix.
13
+
14
+ Returns:
15
+ Path string with ~ prefix for the current platform:
16
+ - macOS: ~/Library/Application Support/Cursor/User
17
+ - Windows: ~/AppData/Roaming/Cursor/User
18
+ - Linux/WSL: ~/.config/Cursor/User
19
+ """
20
+ if sys.platform == "darwin":
21
+ return "~/Library/Application Support/Cursor/User"
22
+ elif sys.platform == "win32":
23
+ return "~/AppData/Roaming/Cursor/User"
24
+ else: # Linux/WSL
25
+ return "~/.config/Cursor/User"
26
+
27
+
28
+ class CursorAgent(Agent):
29
+ """Agent for Cursor editor configuration."""
30
+
31
+ @property
32
+ def name(self) -> str:
33
+ return "Cursor"
34
+
35
+ @property
36
+ def agent_id(self) -> str:
37
+ return "cursor"
38
+
39
+ @property
40
+ def config_file_name(self) -> str:
41
+ return "settings.json"
42
+
43
+ @property
44
+ def config_file_format(self) -> str:
45
+ return "json"
46
+
47
+ @cached_property
48
+ def symlinks(self) -> list[tuple[Path, Path]]:
49
+ """Cached list of all Cursor symlinks.
50
+
51
+ Settings file uses cache-based approach with override merging.
52
+ Keybindings file uses direct symlink (array structure, no merging).
53
+ """
54
+ result = []
55
+ prefix = _get_cursor_target_prefix()
56
+
57
+ # Settings file - use cache if overrides exist
58
+ settings_file = self.config_dir / "cursor" / "settings.json"
59
+ if settings_file.exists():
60
+ target_file = self.config.get_settings_file_for_symlink(
61
+ "cursor", settings_file
62
+ )
63
+ result.append((Path(f"{prefix}/settings.json"), target_file))
64
+
65
+ # Keybindings file - direct symlink (no override merging for arrays)
66
+ keybindings_file = self.config_dir / "cursor" / "keybindings.json"
67
+ if keybindings_file.exists():
68
+ result.append((Path(f"{prefix}/keybindings.json"), keybindings_file))
69
+
70
+ return result
@@ -0,0 +1,47 @@
1
+ """Goose agent implementation."""
2
+
3
+ from functools import cached_property
4
+ from pathlib import Path
5
+
6
+ from ai_rules.agents.base import Agent
7
+
8
+
9
+ class GooseAgent(Agent):
10
+ """Agent for Goose configuration."""
11
+
12
+ @property
13
+ def name(self) -> str:
14
+ return "Goose"
15
+
16
+ @property
17
+ def agent_id(self) -> str:
18
+ return "goose"
19
+
20
+ @property
21
+ def config_file_name(self) -> str:
22
+ return "config.yaml"
23
+
24
+ @property
25
+ def config_file_format(self) -> str:
26
+ return "yaml"
27
+
28
+ @cached_property
29
+ def symlinks(self) -> list[tuple[Path, Path]]:
30
+ """Cached list of all Goose symlinks."""
31
+ result = []
32
+
33
+ result.append(
34
+ (
35
+ Path("~/.config/goose/.goosehints"),
36
+ self.config_dir / "goose" / ".goosehints",
37
+ )
38
+ )
39
+
40
+ config_file = self.config_dir / "goose" / "config.yaml"
41
+ if config_file.exists():
42
+ target_file = self.config.get_settings_file_for_symlink(
43
+ "goose", config_file
44
+ )
45
+ result.append((Path("~/.config/goose/config.yaml"), target_file))
46
+
47
+ return result
@@ -0,0 +1,35 @@
1
+ """Shared agent implementation for agent-agnostic configurations."""
2
+
3
+ from functools import cached_property
4
+ from pathlib import Path
5
+
6
+ from ai_rules.agents.base import Agent
7
+
8
+
9
+ class SharedAgent(Agent):
10
+ """Agent for shared configurations that both Claude Code and Goose respect."""
11
+
12
+ @property
13
+ def name(self) -> str:
14
+ return "Shared"
15
+
16
+ @property
17
+ def agent_id(self) -> str:
18
+ return "shared"
19
+
20
+ @property
21
+ def config_file_name(self) -> str:
22
+ return ""
23
+
24
+ @property
25
+ def config_file_format(self) -> str:
26
+ return ""
27
+
28
+ @cached_property
29
+ def symlinks(self) -> list[tuple[Path, Path]]:
30
+ """Cached list of shared symlinks for agent-agnostic configurations."""
31
+ result = []
32
+
33
+ result.append((Path("~/AGENTS.md"), self.config_dir / "AGENTS.md"))
34
+
35
+ return result
@@ -0,0 +1,75 @@
1
+ """Bootstrap module for system-wide installation and auto-update functionality.
2
+
3
+ This module provides utilities for:
4
+ - Installing tools via uv (PyPI-based)
5
+ - Checking for and applying updates from PyPI
6
+ - Managing auto-update configuration
7
+
8
+ Designed to be self-contained and easily extractable for use in other projects.
9
+ """
10
+
11
+ from .config import (
12
+ AutoUpdateConfig,
13
+ clear_all_pending_updates,
14
+ clear_pending_update,
15
+ get_config_dir,
16
+ get_config_path,
17
+ get_pending_update_path,
18
+ load_all_pending_updates,
19
+ load_auto_update_config,
20
+ load_pending_update,
21
+ save_auto_update_config,
22
+ save_pending_update,
23
+ should_check_now,
24
+ )
25
+ from .installer import (
26
+ UV_NOT_FOUND_ERROR,
27
+ ensure_statusline_installed,
28
+ get_tool_config_dir,
29
+ get_tool_version,
30
+ install_tool,
31
+ is_command_available,
32
+ uninstall_tool,
33
+ )
34
+ from .updater import (
35
+ UPDATABLE_TOOLS,
36
+ ToolSpec,
37
+ UpdateInfo,
38
+ check_index_updates,
39
+ check_tool_updates,
40
+ get_tool_by_id,
41
+ perform_pypi_update,
42
+ )
43
+ from .version import get_package_version, is_newer, parse_version
44
+
45
+ __all__ = [
46
+ "get_package_version",
47
+ "is_newer",
48
+ "parse_version",
49
+ "UV_NOT_FOUND_ERROR",
50
+ "ensure_statusline_installed",
51
+ "get_tool_config_dir",
52
+ "get_tool_version",
53
+ "install_tool",
54
+ "is_command_available",
55
+ "uninstall_tool",
56
+ "UPDATABLE_TOOLS",
57
+ "ToolSpec",
58
+ "UpdateInfo",
59
+ "check_index_updates",
60
+ "check_tool_updates",
61
+ "get_tool_by_id",
62
+ "perform_pypi_update",
63
+ "AutoUpdateConfig",
64
+ "clear_all_pending_updates",
65
+ "clear_pending_update",
66
+ "get_config_dir",
67
+ "get_config_path",
68
+ "get_pending_update_path",
69
+ "load_all_pending_updates",
70
+ "load_auto_update_config",
71
+ "load_pending_update",
72
+ "save_auto_update_config",
73
+ "save_pending_update",
74
+ "should_check_now",
75
+ ]
@@ -0,0 +1,261 @@
1
+ """Auto-update configuration management."""
2
+
3
+ import dataclasses
4
+ import json
5
+ import logging
6
+ import re
7
+
8
+ from dataclasses import asdict, dataclass
9
+ from datetime import datetime, timedelta
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import yaml
14
+
15
+ from .updater import UpdateInfo
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def _validate_tool_id(tool_id: str) -> bool:
21
+ """Validate tool_id contains only safe characters."""
22
+ return bool(re.match(r"^[a-z0-9][a-z0-9_-]*$", tool_id))
23
+
24
+
25
+ @dataclass
26
+ class AutoUpdateConfig:
27
+ """Configuration for automatic update checks."""
28
+
29
+ enabled: bool = True
30
+ frequency: str = "daily" # daily, weekly, never
31
+ last_check: str | None = None # ISO format timestamp
32
+ notify_only: bool = False
33
+
34
+ @classmethod
35
+ def from_dict(cls, data: dict[str, Any]) -> "AutoUpdateConfig":
36
+ """Create from dict, using dataclass defaults for missing keys."""
37
+ fields = {f.name for f in dataclasses.fields(cls)}
38
+ kwargs = {k: v for k, v in data.items() if k in fields}
39
+ return cls(**kwargs)
40
+
41
+
42
+ def get_config_dir(package_name: str = "ai-rules") -> Path:
43
+ """Get the config directory for the package.
44
+
45
+ Args:
46
+ package_name: Name of the package
47
+
48
+ Returns:
49
+ Path to config directory (e.g., ~/.ai-rules/)
50
+ """
51
+ config_dir = Path.home() / f".{package_name}"
52
+ config_dir.mkdir(parents=True, exist_ok=True)
53
+ return config_dir
54
+
55
+
56
+ def get_config_path(package_name: str = "ai-rules") -> Path:
57
+ """Get path to bootstrap config file.
58
+
59
+ Args:
60
+ package_name: Name of the package
61
+
62
+ Returns:
63
+ Path to update_config.yaml
64
+ """
65
+ return get_config_dir(package_name) / "update_config.yaml"
66
+
67
+
68
+ def load_auto_update_config(package_name: str = "ai-rules") -> AutoUpdateConfig:
69
+ """Load auto-update configuration.
70
+
71
+ Args:
72
+ package_name: Name of the package
73
+
74
+ Returns:
75
+ AutoUpdateConfig with loaded or default values
76
+ """
77
+ config_path = get_config_path(package_name)
78
+
79
+ if not config_path.exists():
80
+ return AutoUpdateConfig()
81
+
82
+ try:
83
+ with open(config_path) as f:
84
+ data = yaml.safe_load(f) or {}
85
+ return AutoUpdateConfig.from_dict(data)
86
+ except (yaml.YAMLError, OSError):
87
+ return AutoUpdateConfig()
88
+
89
+
90
+ def save_auto_update_config(
91
+ config: AutoUpdateConfig, package_name: str = "ai-rules"
92
+ ) -> None:
93
+ """Save auto-update configuration.
94
+
95
+ Args:
96
+ config: Configuration to save
97
+ package_name: Name of the package
98
+ """
99
+ config_path = get_config_path(package_name)
100
+
101
+ try:
102
+ with open(config_path, "w") as f:
103
+ yaml.dump(asdict(config), f, default_flow_style=False, sort_keys=False)
104
+ except OSError as e:
105
+ logger.debug(f"Failed to save config to {config_path}: {e}")
106
+
107
+
108
+ def should_check_now(config: AutoUpdateConfig) -> bool:
109
+ """Determine if update check is due based on frequency.
110
+
111
+ Args:
112
+ config: Auto-update configuration
113
+
114
+ Returns:
115
+ True if check should be performed, False otherwise
116
+ """
117
+ if not config.enabled:
118
+ return False
119
+
120
+ if config.frequency == "never":
121
+ return False
122
+
123
+ if not config.last_check:
124
+ return True
125
+
126
+ try:
127
+ last_check = datetime.fromisoformat(config.last_check)
128
+ now = datetime.now()
129
+
130
+ if config.frequency == "daily":
131
+ return now - last_check > timedelta(days=1)
132
+ elif config.frequency == "weekly":
133
+ return now - last_check > timedelta(days=7)
134
+
135
+ except (ValueError, TypeError):
136
+ return True
137
+
138
+ return False
139
+
140
+
141
+ def get_pending_update_path(tool_id: str = "ai-rules") -> Path:
142
+ """Get path to pending update cache file for a specific tool.
143
+
144
+ Args:
145
+ tool_id: Tool identifier (e.g., "ai-rules", "statusline")
146
+
147
+ Returns:
148
+ Path to pending update JSON file
149
+
150
+ Raises:
151
+ ValueError: If tool_id contains invalid characters
152
+ """
153
+ if not _validate_tool_id(tool_id):
154
+ raise ValueError(f"Invalid tool_id: {tool_id}")
155
+
156
+ if tool_id == "ai-rules":
157
+ filename = "pending_update.json"
158
+ else:
159
+ filename = f"pending_{tool_id}_update.json"
160
+
161
+ return get_config_dir("ai-rules") / filename
162
+
163
+
164
+ def load_pending_update(tool_id: str = "ai-rules") -> UpdateInfo | None:
165
+ """Load cached update info from previous background check.
166
+
167
+ Args:
168
+ tool_id: Tool identifier (e.g., "ai-rules", "statusline")
169
+
170
+ Returns:
171
+ UpdateInfo if available, None otherwise
172
+ """
173
+ if not _validate_tool_id(tool_id):
174
+ return None
175
+
176
+ pending_path = get_pending_update_path(tool_id)
177
+
178
+ if not pending_path.exists():
179
+ return None
180
+
181
+ try:
182
+ with open(pending_path) as f:
183
+ data = json.load(f)
184
+
185
+ return UpdateInfo(
186
+ has_update=data.get("has_update", False),
187
+ current_version=data["current_version"],
188
+ latest_version=data["latest_version"],
189
+ source=data["source"],
190
+ )
191
+ except (json.JSONDecodeError, KeyError, OSError):
192
+ return None
193
+
194
+
195
+ def save_pending_update(info: UpdateInfo, tool_id: str = "ai-rules") -> None:
196
+ """Save update info for next session.
197
+
198
+ Args:
199
+ info: Update information to save
200
+ tool_id: Tool identifier (e.g., "ai-rules", "statusline")
201
+ """
202
+ if not _validate_tool_id(tool_id):
203
+ return
204
+
205
+ pending_path = get_pending_update_path(tool_id)
206
+
207
+ try:
208
+ data = {
209
+ "has_update": info.has_update,
210
+ "current_version": info.current_version,
211
+ "latest_version": info.latest_version,
212
+ "source": info.source,
213
+ "checked_at": datetime.now().isoformat(),
214
+ }
215
+
216
+ with open(pending_path, "w") as f:
217
+ json.dump(data, f, indent=2)
218
+ except OSError as e:
219
+ logger.debug(f"Failed to save pending update to {pending_path}: {e}")
220
+
221
+
222
+ def clear_pending_update(tool_id: str = "ai-rules") -> None:
223
+ """Clear pending update after user action.
224
+
225
+ Args:
226
+ tool_id: Tool identifier (e.g., "ai-rules", "statusline")
227
+ """
228
+ if not _validate_tool_id(tool_id):
229
+ return
230
+
231
+ pending_path = get_pending_update_path(tool_id)
232
+
233
+ try:
234
+ pending_path.unlink(missing_ok=True)
235
+ except OSError as e:
236
+ logger.debug(f"Failed to delete pending update at {pending_path}: {e}")
237
+
238
+
239
+ def load_all_pending_updates() -> dict[str, UpdateInfo]:
240
+ """Load pending updates for all tools.
241
+
242
+ Returns:
243
+ Dictionary mapping tool_id to UpdateInfo for tools with pending updates
244
+ """
245
+ from .updater import UPDATABLE_TOOLS
246
+
247
+ result = {}
248
+ for tool in UPDATABLE_TOOLS:
249
+ pending = load_pending_update(tool.tool_id)
250
+ if pending and pending.has_update:
251
+ result[tool.tool_id] = pending
252
+
253
+ return result
254
+
255
+
256
+ def clear_all_pending_updates() -> None:
257
+ """Clear pending updates for all tools."""
258
+ from .updater import UPDATABLE_TOOLS
259
+
260
+ for tool in UPDATABLE_TOOLS:
261
+ clear_pending_update(tool.tool_id)