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,279 @@
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
+ PACKAGE_NAME = "ai-agent-rules"
18
+ GITHUB_REPO = "wpfleger96/ai-rules"
19
+ GITHUB_REPO_URL = f"git+ssh://git@github.com/{GITHUB_REPO}.git"
20
+ STATUSLINE_GITHUB_REPO = "wpfleger96/claude-code-status-line"
21
+ STATUSLINE_GITHUB_REPO_URL = f"git+ssh://git@github.com/{STATUSLINE_GITHUB_REPO}.git"
22
+
23
+
24
+ def _validate_package_name(package_name: str) -> bool:
25
+ """Validate package name matches PyPI naming convention (PEP 508)."""
26
+ return bool(re.match(r"^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$", package_name))
27
+
28
+
29
+ def get_tool_config_dir(package_name: str = "ai-agent-rules") -> Path:
30
+ """Get config directory for a uv tool installation.
31
+
32
+ Computes the expected path where uv tool install places the package:
33
+ $XDG_DATA_HOME/uv/tools/{package}/lib/python{version}/site-packages/ai_rules/config/
34
+
35
+ Args:
36
+ package_name: Name of the uv tool package
37
+
38
+ Returns:
39
+ Path to the config directory in the uv tools location
40
+ """
41
+
42
+ data_home = os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local" / "share"))
43
+ python_version = f"python{sys.version_info.major}.{sys.version_info.minor}"
44
+
45
+ return (
46
+ Path(data_home)
47
+ / "uv"
48
+ / "tools"
49
+ / package_name
50
+ / "lib"
51
+ / python_version
52
+ / "site-packages"
53
+ / "ai_rules"
54
+ / "config"
55
+ )
56
+
57
+
58
+ def get_tool_source(package_name: str) -> str | None:
59
+ """Detect how a uv tool was installed.
60
+
61
+ Args:
62
+ package_name: Name of the uv tool package
63
+
64
+ Returns:
65
+ "pypi" if installed from PyPI
66
+ "github" if installed from GitHub
67
+ "local" if installed from local file
68
+ None if tool not installed or receipt file not found
69
+ """
70
+ data_home = os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local" / "share"))
71
+ receipt_path = Path(data_home) / "uv" / "tools" / package_name / "uv-receipt.toml"
72
+
73
+ if not receipt_path.exists():
74
+ return None
75
+
76
+ try:
77
+ with open(receipt_path, "rb") as f:
78
+ receipt = tomllib.load(f)
79
+
80
+ requirements = receipt.get("tool", {}).get("requirements", [])
81
+ if not requirements:
82
+ return None
83
+
84
+ first_req = requirements[0]
85
+ if isinstance(first_req, dict):
86
+ if "path" in first_req:
87
+ return "local"
88
+ if "git" in first_req and "github.com" in first_req["git"]:
89
+ return "github"
90
+
91
+ return "pypi"
92
+
93
+ except (OSError, tomllib.TOMLDecodeError, KeyError, IndexError):
94
+ return None
95
+
96
+
97
+ def is_command_available(command: str) -> bool:
98
+ """Check if a command is available in PATH.
99
+
100
+ Args:
101
+ command: Command name to check
102
+
103
+ Returns:
104
+ True if command is available, False otherwise
105
+ """
106
+ return shutil.which(command) is not None
107
+
108
+
109
+ def install_tool(
110
+ package_name: str = "ai-agent-rules",
111
+ from_github: bool = False,
112
+ github_url: str | None = None,
113
+ force: bool = False,
114
+ dry_run: bool = False,
115
+ ) -> tuple[bool, str]:
116
+ """Install package as a uv tool.
117
+
118
+ Args:
119
+ package_name: Name of package to install (ignored if from_github=True)
120
+ from_github: Install from GitHub instead of PyPI
121
+ github_url: GitHub URL to install from (only used if from_github=True)
122
+ force: Force reinstall if already installed
123
+ dry_run: Show what would be done without executing
124
+
125
+ Returns:
126
+ Tuple of (success, message)
127
+ """
128
+ if not from_github and not _validate_package_name(package_name):
129
+ return False, f"Invalid package name: {package_name}"
130
+
131
+ if not is_command_available("uv"):
132
+ return False, UV_NOT_FOUND_ERROR
133
+
134
+ if from_github:
135
+ source = github_url if github_url else GITHUB_REPO_URL
136
+ else:
137
+ source = package_name
138
+ cmd = ["uv", "tool", "install", source]
139
+
140
+ if force:
141
+ cmd.insert(3, "--force")
142
+ if from_github:
143
+ cmd.insert(4, "--reinstall")
144
+
145
+ if dry_run:
146
+ return True, f"Would run: {' '.join(cmd)}"
147
+
148
+ try:
149
+ result = subprocess.run(
150
+ cmd,
151
+ capture_output=True,
152
+ text=True,
153
+ timeout=60,
154
+ )
155
+
156
+ if result.returncode == 0:
157
+ return True, "Installation successful"
158
+
159
+ error_msg = result.stderr.strip()
160
+ if not error_msg:
161
+ error_msg = "Installation failed with no error message"
162
+
163
+ return False, error_msg
164
+
165
+ except subprocess.TimeoutExpired:
166
+ return False, "Installation timed out after 60 seconds"
167
+ except Exception as e:
168
+ return False, f"Unexpected error: {e}"
169
+
170
+
171
+ def uninstall_tool(package_name: str = "ai-agent-rules") -> tuple[bool, str]:
172
+ """Uninstall package from uv tools.
173
+
174
+ Args:
175
+ package_name: Name of package to uninstall
176
+
177
+ Returns:
178
+ Tuple of (success, message)
179
+ """
180
+ if not _validate_package_name(package_name):
181
+ return False, f"Invalid package name: {package_name}"
182
+
183
+ if not is_command_available("uv"):
184
+ return False, UV_NOT_FOUND_ERROR
185
+
186
+ cmd = ["uv", "tool", "uninstall", package_name]
187
+
188
+ try:
189
+ result = subprocess.run(
190
+ cmd,
191
+ capture_output=True,
192
+ text=True,
193
+ timeout=30,
194
+ )
195
+
196
+ if result.returncode == 0:
197
+ return True, "Uninstallation successful"
198
+
199
+ error_msg = result.stderr.strip()
200
+ if not error_msg:
201
+ error_msg = "Uninstallation failed with no error message"
202
+
203
+ return False, error_msg
204
+
205
+ except subprocess.TimeoutExpired:
206
+ return False, "Uninstallation timed out after 30 seconds"
207
+ except Exception as e:
208
+ return False, f"Unexpected error: {e}"
209
+
210
+
211
+ def get_tool_version(tool_name: str) -> str | None:
212
+ """Get installed version of a uv tool by parsing `uv tool list`.
213
+
214
+ Args:
215
+ tool_name: Name of the tool package (e.g., "claude-code-statusline")
216
+
217
+ Returns:
218
+ Version string (e.g., "0.7.1") or None if not installed
219
+ """
220
+ if not _validate_package_name(tool_name):
221
+ return None
222
+
223
+ if not is_command_available("uv"):
224
+ return None
225
+
226
+ try:
227
+ result = subprocess.run(
228
+ ["uv", "tool", "list"],
229
+ capture_output=True,
230
+ text=True,
231
+ timeout=10,
232
+ )
233
+
234
+ if result.returncode != 0:
235
+ return None
236
+
237
+ for line in result.stdout.splitlines():
238
+ if line.startswith(tool_name):
239
+ match = re.search(r"v?(\d+\.\d+\.\d+)", line)
240
+ if match:
241
+ return match.group(1)
242
+
243
+ return None
244
+
245
+ except (subprocess.TimeoutExpired, Exception):
246
+ return None
247
+
248
+
249
+ def ensure_statusline_installed(
250
+ dry_run: bool = False, from_github: bool = False
251
+ ) -> tuple[str, str | None]:
252
+ """Install claude-code-statusline if not already present. Fails open.
253
+
254
+ Args:
255
+ dry_run: If True, show what would be done without executing
256
+ from_github: Install from GitHub instead of PyPI
257
+
258
+ Returns:
259
+ Tuple of (status, message) where status is:
260
+ "already_installed", "installed", "failed", or "skipped"
261
+ Message is only provided in dry_run mode
262
+ """
263
+ if is_command_available("claude-statusline"):
264
+ return "already_installed", None
265
+
266
+ try:
267
+ success, message = install_tool(
268
+ "claude-code-statusline",
269
+ from_github=from_github,
270
+ github_url=STATUSLINE_GITHUB_REPO_URL if from_github else None,
271
+ force=False,
272
+ dry_run=dry_run,
273
+ )
274
+ if success:
275
+ return "installed", message if dry_run else None
276
+ else:
277
+ return "failed", None
278
+ except Exception:
279
+ return "failed", None
@@ -0,0 +1,344 @@
1
+ """Update checking and application utilities."""
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ import re
7
+ import subprocess
8
+ import urllib.request
9
+
10
+ from collections.abc import Callable
11
+ from dataclasses import dataclass
12
+
13
+ from .installer import (
14
+ GITHUB_REPO,
15
+ GITHUB_REPO_URL,
16
+ UV_NOT_FOUND_ERROR,
17
+ _validate_package_name,
18
+ get_tool_source,
19
+ get_tool_version,
20
+ is_command_available,
21
+ )
22
+ from .version import is_newer
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ def get_configured_index_url() -> str | None:
28
+ """Get package index URL from environment.
29
+
30
+ Checks in order of preference:
31
+ 1. UV_DEFAULT_INDEX (modern uv, recommended)
32
+ 2. UV_INDEX_URL (deprecated uv, still supported)
33
+ 3. PIP_INDEX_URL (pip compatibility)
34
+
35
+ Returns:
36
+ Index URL if configured, None otherwise
37
+ """
38
+ return (
39
+ os.environ.get("UV_DEFAULT_INDEX")
40
+ or os.environ.get("UV_INDEX_URL")
41
+ or os.environ.get("PIP_INDEX_URL")
42
+ )
43
+
44
+
45
+ @dataclass
46
+ class UpdateInfo:
47
+ """Information about available updates."""
48
+
49
+ has_update: bool
50
+ current_version: str
51
+ latest_version: str
52
+ source: str
53
+
54
+
55
+ def check_index_updates(
56
+ package_name: str, current_version: str, timeout: int = 30
57
+ ) -> UpdateInfo:
58
+ """Check configured package index for newer version.
59
+
60
+ Uses `uvx pip index versions` to query the user's configured index,
61
+ which respects pip.conf and environment variables.
62
+
63
+ Args:
64
+ package_name: Package name to check
65
+ current_version: Currently installed version
66
+ timeout: Request timeout in seconds (default: 30)
67
+
68
+ Returns:
69
+ UpdateInfo with update status
70
+ """
71
+ if not _validate_package_name(package_name):
72
+ return UpdateInfo(
73
+ has_update=False,
74
+ current_version=current_version,
75
+ latest_version=current_version,
76
+ source="index",
77
+ )
78
+
79
+ if not is_command_available("uvx"):
80
+ return UpdateInfo(
81
+ has_update=False,
82
+ current_version=current_version,
83
+ latest_version=current_version,
84
+ source="index",
85
+ )
86
+
87
+ try:
88
+ cmd = ["uvx", "--refresh", "pip", "index", "versions", package_name]
89
+
90
+ # Pass index URL explicitly since pip doesn't understand UV_* env vars
91
+ if index_url := get_configured_index_url():
92
+ cmd.extend(["--index-url", index_url])
93
+
94
+ result = subprocess.run(
95
+ cmd,
96
+ capture_output=True,
97
+ text=True,
98
+ timeout=timeout,
99
+ )
100
+
101
+ if result.returncode != 0:
102
+ logger.debug(f"uvx pip index versions failed: {result.stderr}")
103
+ return UpdateInfo(
104
+ has_update=False,
105
+ current_version=current_version,
106
+ latest_version=current_version,
107
+ source="index",
108
+ )
109
+
110
+ output = result.stdout.strip()
111
+ match = re.search(r"^\S+\s+\(([^)]+)\)", output)
112
+ if match:
113
+ latest_version = match.group(1)
114
+ has_update = is_newer(latest_version, current_version)
115
+ return UpdateInfo(
116
+ has_update=has_update,
117
+ current_version=current_version,
118
+ latest_version=latest_version,
119
+ source="index",
120
+ )
121
+
122
+ return UpdateInfo(
123
+ has_update=False,
124
+ current_version=current_version,
125
+ latest_version=current_version,
126
+ source="index",
127
+ )
128
+
129
+ except subprocess.TimeoutExpired:
130
+ logger.debug("uvx pip index versions timed out")
131
+ return UpdateInfo(
132
+ has_update=False,
133
+ current_version=current_version,
134
+ latest_version=current_version,
135
+ source="index",
136
+ )
137
+ except Exception as e:
138
+ logger.debug(f"Index check failed: {e}")
139
+ return UpdateInfo(
140
+ has_update=False,
141
+ current_version=current_version,
142
+ latest_version=current_version,
143
+ source="index",
144
+ )
145
+
146
+
147
+ def check_github_updates(
148
+ repo: str, current_version: str, timeout: int = 10
149
+ ) -> UpdateInfo:
150
+ """Check GitHub tags for newer version.
151
+
152
+ Args:
153
+ repo: GitHub repository in format "owner/repo"
154
+ current_version: Currently installed version
155
+ timeout: Request timeout in seconds (default: 10)
156
+
157
+ Returns:
158
+ UpdateInfo with update status
159
+ """
160
+ try:
161
+ url = f"https://api.github.com/repos/{repo}/tags"
162
+
163
+ req = urllib.request.Request(url)
164
+ req.add_header("User-Agent", f"ai-rules/{current_version}")
165
+
166
+ with urllib.request.urlopen(req, timeout=timeout) as response:
167
+ data = json.loads(response.read().decode())
168
+
169
+ if not data or len(data) == 0:
170
+ return UpdateInfo(
171
+ has_update=False,
172
+ current_version=current_version,
173
+ latest_version=current_version,
174
+ source="github",
175
+ )
176
+
177
+ latest_tag = data[0]["name"]
178
+ latest_version = latest_tag.lstrip("v")
179
+
180
+ has_update = is_newer(latest_version, current_version)
181
+
182
+ return UpdateInfo(
183
+ has_update=has_update,
184
+ current_version=current_version,
185
+ latest_version=latest_version,
186
+ source="github",
187
+ )
188
+
189
+ except (urllib.error.URLError, json.JSONDecodeError, KeyError, IndexError) as e:
190
+ logger.debug(f"GitHub check failed: {e}")
191
+ return UpdateInfo(
192
+ has_update=False,
193
+ current_version=current_version,
194
+ latest_version=current_version,
195
+ source="github",
196
+ )
197
+
198
+
199
+ def perform_pypi_update(package_name: str) -> tuple[bool, str, bool]:
200
+ """Upgrade via uv tool upgrade or GitHub.
201
+
202
+ Args:
203
+ package_name: Name of package to upgrade
204
+
205
+ Returns:
206
+ Tuple of (success, message, was_upgraded)
207
+ - success: Whether command succeeded
208
+ - message: Human-readable status message
209
+ - was_upgraded: True if package was actually upgraded (not already up-to-date)
210
+ """
211
+ if not is_command_available("uv"):
212
+ return False, UV_NOT_FOUND_ERROR, False
213
+
214
+ source = get_tool_source(package_name)
215
+
216
+ if source == "github":
217
+ cmd = ["uv", "tool", "install", "--force", "--reinstall", GITHUB_REPO_URL]
218
+ elif source == "local":
219
+ cmd = ["uv", "tool", "install", package_name, "--force", "--no-cache"]
220
+ else:
221
+ if not _validate_package_name(package_name):
222
+ return False, f"Invalid package name: {package_name}", False
223
+ cmd = ["uv", "tool", "upgrade", package_name, "--no-cache"]
224
+
225
+ # Ensure upgrade uses same index as version check
226
+ # Use --default-index (modern) not --index-url (deprecated)
227
+ if index_url := get_configured_index_url():
228
+ cmd.extend(["--default-index", index_url])
229
+
230
+ try:
231
+ result = subprocess.run(
232
+ cmd,
233
+ capture_output=True,
234
+ text=True,
235
+ timeout=60,
236
+ )
237
+
238
+ if result.returncode == 0:
239
+ output = result.stdout + result.stderr
240
+
241
+ upgrade_patterns = [
242
+ r"Upgraded .+ from .+ to .+",
243
+ r"Installed .+ \d+\.\d+",
244
+ r"Successfully installed",
245
+ ]
246
+
247
+ already_up_to_date_patterns = [
248
+ r"Nothing to upgrade",
249
+ r"already.*installed",
250
+ r"already.*up.*to.*date",
251
+ ]
252
+
253
+ was_upgraded = False
254
+ if any(
255
+ re.search(pattern, output, re.IGNORECASE)
256
+ for pattern in upgrade_patterns
257
+ ):
258
+ was_upgraded = True
259
+ elif any(
260
+ re.search(pattern, output, re.IGNORECASE)
261
+ for pattern in already_up_to_date_patterns
262
+ ):
263
+ was_upgraded = False
264
+ else:
265
+ was_upgraded = True
266
+
267
+ return True, "Upgrade successful", was_upgraded
268
+
269
+ error_msg = result.stderr.strip()
270
+ if not error_msg:
271
+ error_msg = "Upgrade failed with no error message"
272
+
273
+ return False, error_msg, False
274
+
275
+ except subprocess.TimeoutExpired:
276
+ return False, "Upgrade timed out after 60 seconds", False
277
+ except Exception as e:
278
+ return False, f"Unexpected error: {e}", False
279
+
280
+
281
+ @dataclass
282
+ class ToolSpec:
283
+ """Specification for an updatable tool."""
284
+
285
+ tool_id: str
286
+ package_name: str
287
+ display_name: str
288
+ get_version: Callable[[], str | None]
289
+ is_installed: Callable[[], bool]
290
+
291
+
292
+ UPDATABLE_TOOLS: list[ToolSpec] = [
293
+ ToolSpec(
294
+ tool_id="ai-rules",
295
+ package_name="ai-agent-rules",
296
+ display_name="ai-rules",
297
+ get_version=lambda: get_tool_version("ai-agent-rules"),
298
+ is_installed=lambda: True, # Always installed (it's us)
299
+ ),
300
+ ToolSpec(
301
+ tool_id="statusline",
302
+ package_name="claude-code-statusline",
303
+ display_name="statusline",
304
+ get_version=lambda: get_tool_version("claude-code-statusline"),
305
+ is_installed=lambda: is_command_available("claude-statusline"),
306
+ ),
307
+ ]
308
+
309
+
310
+ def check_tool_updates(tool: ToolSpec, timeout: int = 30) -> UpdateInfo | None:
311
+ """Check for updates for any tool - auto-detect PyPI vs GitHub source.
312
+
313
+ Args:
314
+ tool: Tool specification
315
+ timeout: Request timeout in seconds (default: 30)
316
+
317
+ Returns:
318
+ UpdateInfo if tool is installed and update check succeeds, None otherwise
319
+ """
320
+ if not tool.is_installed():
321
+ return None
322
+
323
+ current = tool.get_version()
324
+ if current is None:
325
+ return None
326
+
327
+ source = get_tool_source(tool.package_name)
328
+
329
+ if source == "github" and tool.tool_id == "ai-rules":
330
+ return check_github_updates(GITHUB_REPO, current, timeout)
331
+ else:
332
+ return check_index_updates(tool.package_name, current, timeout)
333
+
334
+
335
+ def get_tool_by_id(tool_id: str) -> ToolSpec | None:
336
+ """Look up tool spec by ID.
337
+
338
+ Args:
339
+ tool_id: Tool identifier (e.g., "ai-rules", "statusline")
340
+
341
+ Returns:
342
+ ToolSpec if found, None otherwise
343
+ """
344
+ return next((t for t in UPDATABLE_TOOLS if t.tool_id == tool_id), None)
@@ -0,0 +1,52 @@
1
+ """Version utilities for package management."""
2
+
3
+ from importlib.metadata import version as get_version
4
+
5
+ from packaging.version import InvalidVersion, Version
6
+
7
+
8
+ def parse_version(version_str: str) -> Version:
9
+ """Parse version string, handling 'v' prefix.
10
+
11
+ Args:
12
+ version_str: Version string (e.g., "1.2.3" or "v1.2.3")
13
+
14
+ Returns:
15
+ Parsed Version object
16
+
17
+ Raises:
18
+ InvalidVersion: If version string is malformed
19
+ """
20
+ clean = version_str.lstrip("v")
21
+ return Version(clean)
22
+
23
+
24
+ def is_newer(latest: str, current: str) -> bool:
25
+ """Check if latest version is newer than current.
26
+
27
+ Args:
28
+ latest: Latest version string
29
+ current: Current version string
30
+
31
+ Returns:
32
+ True if latest is newer, False otherwise
33
+ """
34
+ try:
35
+ return parse_version(latest) > parse_version(current)
36
+ except InvalidVersion:
37
+ return False
38
+
39
+
40
+ def get_package_version(package_name: str) -> str:
41
+ """Get installed version of a package.
42
+
43
+ Args:
44
+ package_name: Name of the package (e.g., "ai-rules")
45
+
46
+ Returns:
47
+ Version string (e.g., "0.3.0")
48
+
49
+ Raises:
50
+ PackageNotFoundError: If package is not installed
51
+ """
52
+ return get_version(package_name)