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.
- ai_agent_rules-0.15.2.dist-info/METADATA +451 -0
- ai_agent_rules-0.15.2.dist-info/RECORD +52 -0
- ai_agent_rules-0.15.2.dist-info/WHEEL +5 -0
- ai_agent_rules-0.15.2.dist-info/entry_points.txt +3 -0
- ai_agent_rules-0.15.2.dist-info/licenses/LICENSE +22 -0
- ai_agent_rules-0.15.2.dist-info/top_level.txt +1 -0
- ai_rules/__init__.py +8 -0
- ai_rules/agents/__init__.py +1 -0
- ai_rules/agents/base.py +68 -0
- ai_rules/agents/claude.py +123 -0
- ai_rules/agents/cursor.py +70 -0
- ai_rules/agents/goose.py +47 -0
- ai_rules/agents/shared.py +35 -0
- ai_rules/bootstrap/__init__.py +75 -0
- ai_rules/bootstrap/config.py +261 -0
- ai_rules/bootstrap/installer.py +279 -0
- ai_rules/bootstrap/updater.py +344 -0
- ai_rules/bootstrap/version.py +52 -0
- ai_rules/cli.py +2434 -0
- ai_rules/completions.py +194 -0
- ai_rules/config/AGENTS.md +249 -0
- ai_rules/config/chat_agent_hints.md +1 -0
- ai_rules/config/claude/CLAUDE.md +1 -0
- ai_rules/config/claude/agents/code-reviewer.md +121 -0
- ai_rules/config/claude/commands/agents-md.md +422 -0
- ai_rules/config/claude/commands/annotate-changelog.md +191 -0
- ai_rules/config/claude/commands/comment-cleanup.md +161 -0
- ai_rules/config/claude/commands/continue-crash.md +38 -0
- ai_rules/config/claude/commands/dev-docs.md +169 -0
- ai_rules/config/claude/commands/pr-creator.md +247 -0
- ai_rules/config/claude/commands/test-cleanup.md +244 -0
- ai_rules/config/claude/commands/update-docs.md +324 -0
- ai_rules/config/claude/hooks/subagentStop.py +92 -0
- ai_rules/config/claude/mcps.json +1 -0
- ai_rules/config/claude/settings.json +119 -0
- ai_rules/config/claude/skills/doc-writer/SKILL.md +293 -0
- ai_rules/config/claude/skills/doc-writer/resources/templates.md +495 -0
- ai_rules/config/claude/skills/prompt-engineer/SKILL.md +272 -0
- ai_rules/config/claude/skills/prompt-engineer/resources/prompt_engineering_guide_2025.md +855 -0
- ai_rules/config/claude/skills/prompt-engineer/resources/templates.md +232 -0
- ai_rules/config/cursor/keybindings.json +14 -0
- ai_rules/config/cursor/settings.json +81 -0
- ai_rules/config/goose/.goosehints +1 -0
- ai_rules/config/goose/config.yaml +55 -0
- ai_rules/config/profiles/default.yaml +6 -0
- ai_rules/config/profiles/work.yaml +11 -0
- ai_rules/config.py +644 -0
- ai_rules/display.py +40 -0
- ai_rules/mcp.py +369 -0
- ai_rules/profiles.py +187 -0
- ai_rules/symlinks.py +207 -0
- 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)
|