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
@@ -0,0 +1,44 @@
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
+ (Path("~/.config/goose/.goosehints"), self.config_dir / "AGENTS.md")
35
+ )
36
+
37
+ config_file = self.config_dir / "goose" / "config.yaml"
38
+ if config_file.exists():
39
+ target_file = self.config.get_settings_file_for_symlink(
40
+ "goose", config_file
41
+ )
42
+ result.append((Path("~/.config/goose/config.yaml"), target_file))
43
+
44
+ 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_pypi_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_pypi_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)
@@ -0,0 +1,249 @@
1
+ """Tool installation utilities."""
2
+
3
+ import os
4
+ import re
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+
9
+ from pathlib import Path
10
+
11
+ if sys.version_info >= (3, 11):
12
+ import tomllib
13
+ else:
14
+ import tomli as tomllib
15
+
16
+ UV_NOT_FOUND_ERROR = "uv not found in PATH. Install from https://docs.astral.sh/uv/"
17
+
18
+
19
+ def _validate_package_name(package_name: str) -> bool:
20
+ """Validate package name matches PyPI naming convention (PEP 508)."""
21
+ return bool(re.match(r"^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$", package_name))
22
+
23
+
24
+ def get_tool_config_dir(package_name: str = "ai-agent-rules") -> Path:
25
+ """Get config directory for a uv tool installation.
26
+
27
+ Computes the expected path where uv tool install places the package:
28
+ $XDG_DATA_HOME/uv/tools/{package}/lib/python{version}/site-packages/ai_rules/config/
29
+
30
+ Args:
31
+ package_name: Name of the uv tool package
32
+
33
+ Returns:
34
+ Path to the config directory in the uv tools location
35
+ """
36
+
37
+ data_home = os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local" / "share"))
38
+ python_version = f"python{sys.version_info.major}.{sys.version_info.minor}"
39
+
40
+ return (
41
+ Path(data_home)
42
+ / "uv"
43
+ / "tools"
44
+ / package_name
45
+ / "lib"
46
+ / python_version
47
+ / "site-packages"
48
+ / "ai_rules"
49
+ / "config"
50
+ )
51
+
52
+
53
+ def get_tool_source(package_name: str) -> str | None:
54
+ """Detect how a uv tool was installed (PyPI vs local file).
55
+
56
+ Args:
57
+ package_name: Name of the uv tool package
58
+
59
+ Returns:
60
+ "pypi" if installed from PyPI (no path key in requirements)
61
+ "local" if installed from local file (has path key)
62
+ None if tool not installed or receipt file not found
63
+ """
64
+ data_home = os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local" / "share"))
65
+ receipt_path = Path(data_home) / "uv" / "tools" / package_name / "uv-receipt.toml"
66
+
67
+ if not receipt_path.exists():
68
+ return None
69
+
70
+ try:
71
+ with open(receipt_path, "rb") as f:
72
+ receipt = tomllib.load(f)
73
+
74
+ requirements = receipt.get("tool", {}).get("requirements", [])
75
+ if not requirements:
76
+ return None
77
+
78
+ # Check first requirement for path key (indicates local install)
79
+ first_req = requirements[0]
80
+ if isinstance(first_req, dict) and "path" in first_req:
81
+ return "local"
82
+
83
+ return "pypi"
84
+
85
+ except (OSError, tomllib.TOMLDecodeError, KeyError, IndexError):
86
+ return None
87
+
88
+
89
+ def is_command_available(command: str) -> bool:
90
+ """Check if a command is available in PATH.
91
+
92
+ Args:
93
+ command: Command name to check
94
+
95
+ Returns:
96
+ True if command is available, False otherwise
97
+ """
98
+ return shutil.which(command) is not None
99
+
100
+
101
+ def install_tool(
102
+ package_name: str = "ai-agent-rules",
103
+ force: bool = False,
104
+ dry_run: bool = False,
105
+ ) -> tuple[bool, str]:
106
+ """Install package as a uv tool from PyPI.
107
+
108
+ Args:
109
+ package_name: Name of package to install
110
+ force: Force reinstall if already installed
111
+ dry_run: Show what would be done without executing
112
+
113
+ Returns:
114
+ Tuple of (success, message)
115
+ """
116
+ if not _validate_package_name(package_name):
117
+ return False, f"Invalid package name: {package_name}"
118
+
119
+ if not is_command_available("uv"):
120
+ return False, UV_NOT_FOUND_ERROR
121
+
122
+ cmd = ["uv", "tool", "install", package_name]
123
+ if force:
124
+ cmd.insert(3, "--force")
125
+
126
+ if dry_run:
127
+ return True, f"Would run: {' '.join(cmd)}"
128
+
129
+ try:
130
+ result = subprocess.run(
131
+ cmd,
132
+ capture_output=True,
133
+ text=True,
134
+ timeout=60,
135
+ )
136
+
137
+ if result.returncode == 0:
138
+ return True, "Installation successful"
139
+
140
+ error_msg = result.stderr.strip()
141
+ if not error_msg:
142
+ error_msg = "Installation failed with no error message"
143
+
144
+ return False, error_msg
145
+
146
+ except subprocess.TimeoutExpired:
147
+ return False, "Installation timed out after 60 seconds"
148
+ except Exception as e:
149
+ return False, f"Unexpected error: {e}"
150
+
151
+
152
+ def uninstall_tool(package_name: str = "ai-agent-rules") -> tuple[bool, str]:
153
+ """Uninstall package from uv tools.
154
+
155
+ Args:
156
+ package_name: Name of package to uninstall
157
+
158
+ Returns:
159
+ Tuple of (success, message)
160
+ """
161
+ if not _validate_package_name(package_name):
162
+ return False, f"Invalid package name: {package_name}"
163
+
164
+ if not is_command_available("uv"):
165
+ return False, UV_NOT_FOUND_ERROR
166
+
167
+ cmd = ["uv", "tool", "uninstall", package_name]
168
+
169
+ try:
170
+ result = subprocess.run(
171
+ cmd,
172
+ capture_output=True,
173
+ text=True,
174
+ timeout=30,
175
+ )
176
+
177
+ if result.returncode == 0:
178
+ return True, "Uninstallation successful"
179
+
180
+ error_msg = result.stderr.strip()
181
+ if not error_msg:
182
+ error_msg = "Uninstallation failed with no error message"
183
+
184
+ return False, error_msg
185
+
186
+ except subprocess.TimeoutExpired:
187
+ return False, "Uninstallation timed out after 30 seconds"
188
+ except Exception as e:
189
+ return False, f"Unexpected error: {e}"
190
+
191
+
192
+ def get_tool_version(tool_name: str) -> str | None:
193
+ """Get installed version of a uv tool by parsing `uv tool list`.
194
+
195
+ Args:
196
+ tool_name: Name of the tool package (e.g., "claude-code-statusline")
197
+
198
+ Returns:
199
+ Version string (e.g., "0.7.1") or None if not installed
200
+ """
201
+ if not _validate_package_name(tool_name):
202
+ return None
203
+
204
+ if not is_command_available("uv"):
205
+ return None
206
+
207
+ try:
208
+ result = subprocess.run(
209
+ ["uv", "tool", "list"],
210
+ capture_output=True,
211
+ text=True,
212
+ timeout=10,
213
+ )
214
+
215
+ if result.returncode != 0:
216
+ return None
217
+
218
+ for line in result.stdout.splitlines():
219
+ if line.startswith(tool_name):
220
+ match = re.search(r"v?(\d+\.\d+\.\d+)", line)
221
+ if match:
222
+ return match.group(1)
223
+
224
+ return None
225
+
226
+ except (subprocess.TimeoutExpired, Exception):
227
+ return None
228
+
229
+
230
+ def ensure_statusline_installed(dry_run: bool = False) -> str:
231
+ """Install claude-code-statusline if not already present. Fails open.
232
+
233
+ Args:
234
+ dry_run: If True, skip installation
235
+
236
+ Returns:
237
+ Status: "already_installed", "installed", "failed", or "skipped"
238
+ """
239
+ if is_command_available("claude-statusline"):
240
+ return "already_installed"
241
+
242
+ if dry_run:
243
+ return "skipped"
244
+
245
+ try:
246
+ success, _ = install_tool("claude-code-statusline", force=False, dry_run=False)
247
+ return "installed" if success else "failed"
248
+ except Exception:
249
+ return "failed"