ai-agent-rules 0.11.0__py3-none-any.whl → 0.15.8__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.

@@ -2,6 +2,7 @@
2
2
 
3
3
  import json
4
4
  import logging
5
+ import os
5
6
  import re
6
7
  import subprocess
7
8
  import urllib.request
@@ -10,17 +11,37 @@ from collections.abc import Callable
10
11
  from dataclasses import dataclass
11
12
 
12
13
  from .installer import (
14
+ GITHUB_REPO,
15
+ STATUSLINE_GITHUB_REPO,
13
16
  UV_NOT_FOUND_ERROR,
17
+ ToolSource,
14
18
  _validate_package_name,
15
19
  get_tool_source,
16
20
  get_tool_version,
17
21
  is_command_available,
22
+ make_github_install_url,
18
23
  )
19
24
  from .version import is_newer
20
25
 
21
26
  logger = logging.getLogger(__name__)
22
27
 
23
- PYPI_JSON_API_URL = "https://pypi.org/pypi/{package_name}/json"
28
+
29
+ def get_configured_index_url() -> str | None:
30
+ """Get package index URL from environment.
31
+
32
+ Checks in order of preference:
33
+ 1. UV_DEFAULT_INDEX (modern uv, recommended)
34
+ 2. UV_INDEX_URL (deprecated uv, still supported)
35
+ 3. PIP_INDEX_URL (pip compatibility)
36
+
37
+ Returns:
38
+ Index URL if configured, None otherwise
39
+ """
40
+ return (
41
+ os.environ.get("UV_DEFAULT_INDEX")
42
+ or os.environ.get("UV_INDEX_URL")
43
+ or os.environ.get("PIP_INDEX_URL")
44
+ )
24
45
 
25
46
 
26
47
  @dataclass
@@ -30,65 +51,177 @@ class UpdateInfo:
30
51
  has_update: bool
31
52
  current_version: str
32
53
  latest_version: str
33
- source: str # "pypi"
54
+ source: str
55
+
56
+
57
+ @dataclass
58
+ class ToolSpec:
59
+ """Specification for an updatable tool."""
60
+
61
+ tool_id: str
62
+ package_name: str
63
+ display_name: str
64
+ get_version: Callable[[], str | None]
65
+ is_installed: Callable[[], bool]
66
+ github_repo: str | None = None
34
67
 
68
+ @property
69
+ def github_install_url(self) -> str | None:
70
+ """Get the GitHub install URL for uv tool install."""
71
+ if self.github_repo:
72
+ return make_github_install_url(self.github_repo)
73
+ return None
35
74
 
36
- def check_pypi_updates(
37
- package_name: str, current_version: str, timeout: int = 10
75
+
76
+ def check_index_updates(
77
+ package_name: str, current_version: str, timeout: int = 30
38
78
  ) -> UpdateInfo:
39
- """Check PyPI for newer version.
79
+ """Check configured package index for newer version.
80
+
81
+ Uses `uvx pip index versions` to query the user's configured index,
82
+ which respects pip.conf and environment variables.
40
83
 
41
84
  Args:
42
- package_name: Package name on PyPI
85
+ package_name: Package name to check
43
86
  current_version: Currently installed version
44
- timeout: Request timeout in seconds (default: 10)
87
+ timeout: Request timeout in seconds (default: 30)
45
88
 
46
89
  Returns:
47
90
  UpdateInfo with update status
48
91
  """
49
- # Validate package name (PEP 508 compliant)
50
- if not re.match(r"^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$", package_name):
92
+ if not _validate_package_name(package_name):
93
+ return UpdateInfo(
94
+ has_update=False,
95
+ current_version=current_version,
96
+ latest_version=current_version,
97
+ source="index",
98
+ )
99
+
100
+ if not is_command_available("uvx"):
101
+ return UpdateInfo(
102
+ has_update=False,
103
+ current_version=current_version,
104
+ latest_version=current_version,
105
+ source="index",
106
+ )
107
+
108
+ try:
109
+ cmd = ["uvx", "--refresh", "pip", "index", "versions", package_name]
110
+
111
+ # Pass index URL explicitly since pip doesn't understand UV_* env vars
112
+ if index_url := get_configured_index_url():
113
+ cmd.extend(["--index-url", index_url])
114
+
115
+ result = subprocess.run(
116
+ cmd,
117
+ capture_output=True,
118
+ text=True,
119
+ timeout=timeout,
120
+ )
121
+
122
+ if result.returncode != 0:
123
+ logger.debug(f"uvx pip index versions failed: {result.stderr}")
124
+ return UpdateInfo(
125
+ has_update=False,
126
+ current_version=current_version,
127
+ latest_version=current_version,
128
+ source="index",
129
+ )
130
+
131
+ output = result.stdout.strip()
132
+ match = re.search(r"^\S+\s+\(([^)]+)\)", output)
133
+ if match:
134
+ latest_version = match.group(1)
135
+ has_update = is_newer(latest_version, current_version)
136
+ return UpdateInfo(
137
+ has_update=has_update,
138
+ current_version=current_version,
139
+ latest_version=latest_version,
140
+ source="index",
141
+ )
142
+
51
143
  return UpdateInfo(
52
144
  has_update=False,
53
145
  current_version=current_version,
54
146
  latest_version=current_version,
55
- source="pypi",
147
+ source="index",
56
148
  )
57
149
 
150
+ except subprocess.TimeoutExpired:
151
+ logger.debug("uvx pip index versions timed out")
152
+ return UpdateInfo(
153
+ has_update=False,
154
+ current_version=current_version,
155
+ latest_version=current_version,
156
+ source="index",
157
+ )
158
+ except Exception as e:
159
+ logger.debug(f"Index check failed: {e}")
160
+ return UpdateInfo(
161
+ has_update=False,
162
+ current_version=current_version,
163
+ latest_version=current_version,
164
+ source="index",
165
+ )
166
+
167
+
168
+ def check_github_updates(
169
+ repo: str, current_version: str, timeout: int = 10
170
+ ) -> UpdateInfo:
171
+ """Check GitHub tags for newer version.
172
+
173
+ Args:
174
+ repo: GitHub repository in format "owner/repo"
175
+ current_version: Currently installed version
176
+ timeout: Request timeout in seconds (default: 10)
177
+
178
+ Returns:
179
+ UpdateInfo with update status
180
+ """
58
181
  try:
59
- url = PYPI_JSON_API_URL.format(package_name=package_name)
182
+ url = f"https://api.github.com/repos/{repo}/tags"
60
183
 
61
184
  req = urllib.request.Request(url)
62
- req.add_header("User-Agent", f"{package_name}/{current_version}")
185
+ req.add_header("User-Agent", f"ai-rules/{current_version}")
63
186
 
64
187
  with urllib.request.urlopen(req, timeout=timeout) as response:
65
188
  data = json.loads(response.read().decode())
66
189
 
67
- latest_version = data["info"]["version"]
190
+ if not data or len(data) == 0:
191
+ return UpdateInfo(
192
+ has_update=False,
193
+ current_version=current_version,
194
+ latest_version=current_version,
195
+ source="github",
196
+ )
197
+
198
+ latest_tag = data[0]["name"]
199
+ latest_version = latest_tag.lstrip("v")
200
+
68
201
  has_update = is_newer(latest_version, current_version)
69
202
 
70
203
  return UpdateInfo(
71
204
  has_update=has_update,
72
205
  current_version=current_version,
73
206
  latest_version=latest_version,
74
- source="pypi",
207
+ source="github",
75
208
  )
76
209
 
77
- except (urllib.error.URLError, json.JSONDecodeError, KeyError) as e:
78
- logger.debug(f"PyPI check failed: {e}")
210
+ except (urllib.error.URLError, json.JSONDecodeError, KeyError, IndexError) as e:
211
+ logger.debug(f"GitHub check failed: {e}")
79
212
  return UpdateInfo(
80
213
  has_update=False,
81
214
  current_version=current_version,
82
215
  latest_version=current_version,
83
- source="pypi",
216
+ source="github",
84
217
  )
85
218
 
86
219
 
87
- def perform_pypi_update(package_name: str) -> tuple[bool, str, bool]:
88
- """Upgrade via uv tool upgrade.
220
+ def perform_tool_upgrade(tool: ToolSpec) -> tuple[bool, str, bool]:
221
+ """Upgrade a tool via uv, handling PyPI, GitHub, and local sources.
89
222
 
90
223
  Args:
91
- package_name: Name of package to upgrade
224
+ tool: Tool specification to upgrade
92
225
 
93
226
  Returns:
94
227
  Tuple of (success, message, was_upgraded)
@@ -96,18 +229,29 @@ def perform_pypi_update(package_name: str) -> tuple[bool, str, bool]:
96
229
  - message: Human-readable status message
97
230
  - was_upgraded: True if package was actually upgraded (not already up-to-date)
98
231
  """
99
- if not _validate_package_name(package_name):
100
- return False, f"Invalid package name: {package_name}", False
101
-
102
232
  if not is_command_available("uv"):
103
233
  return False, UV_NOT_FOUND_ERROR, False
104
234
 
105
- source = get_tool_source(package_name)
106
-
107
- if source == "local":
108
- cmd = ["uv", "tool", "install", package_name, "--force", "--no-cache"]
235
+ source = get_tool_source(tool.package_name)
236
+
237
+ if source == ToolSource.GITHUB and tool.github_install_url:
238
+ cmd = [
239
+ "uv",
240
+ "tool",
241
+ "install",
242
+ "--force",
243
+ "--reinstall",
244
+ tool.github_install_url,
245
+ ]
109
246
  else:
110
- cmd = ["uv", "tool", "upgrade", package_name, "--no-cache"]
247
+ if not _validate_package_name(tool.package_name):
248
+ return False, f"Invalid package name: {tool.package_name}", False
249
+ cmd = ["uv", "tool", "upgrade", tool.package_name, "--no-cache"]
250
+
251
+ # Ensure upgrade uses same index as version check
252
+ # Use --default-index (modern) not --index-url (deprecated)
253
+ if index_url := get_configured_index_url():
254
+ cmd.extend(["--default-index", index_url])
111
255
 
112
256
  try:
113
257
  result = subprocess.run(
@@ -160,17 +304,6 @@ def perform_pypi_update(package_name: str) -> tuple[bool, str, bool]:
160
304
  return False, f"Unexpected error: {e}", False
161
305
 
162
306
 
163
- @dataclass
164
- class ToolSpec:
165
- """Specification for an updatable tool."""
166
-
167
- tool_id: str
168
- package_name: str
169
- display_name: str
170
- get_version: Callable[[], str | None]
171
- is_installed: Callable[[], bool]
172
-
173
-
174
307
  UPDATABLE_TOOLS: list[ToolSpec] = [
175
308
  ToolSpec(
176
309
  tool_id="ai-rules",
@@ -178,6 +311,7 @@ UPDATABLE_TOOLS: list[ToolSpec] = [
178
311
  display_name="ai-rules",
179
312
  get_version=lambda: get_tool_version("ai-agent-rules"),
180
313
  is_installed=lambda: True, # Always installed (it's us)
314
+ github_repo=GITHUB_REPO,
181
315
  ),
182
316
  ToolSpec(
183
317
  tool_id="statusline",
@@ -185,16 +319,17 @@ UPDATABLE_TOOLS: list[ToolSpec] = [
185
319
  display_name="statusline",
186
320
  get_version=lambda: get_tool_version("claude-code-statusline"),
187
321
  is_installed=lambda: is_command_available("claude-statusline"),
322
+ github_repo=STATUSLINE_GITHUB_REPO,
188
323
  ),
189
324
  ]
190
325
 
191
326
 
192
- def check_tool_updates(tool: ToolSpec, timeout: int = 10) -> UpdateInfo | None:
193
- """Check for updates for any tool.
327
+ def check_tool_updates(tool: ToolSpec, timeout: int = 30) -> UpdateInfo | None:
328
+ """Check for updates for any tool - auto-detect PyPI vs GitHub source.
194
329
 
195
330
  Args:
196
331
  tool: Tool specification
197
- timeout: Request timeout in seconds (default: 10)
332
+ timeout: Request timeout in seconds (default: 30)
198
333
 
199
334
  Returns:
200
335
  UpdateInfo if tool is installed and update check succeeds, None otherwise
@@ -206,7 +341,12 @@ def check_tool_updates(tool: ToolSpec, timeout: int = 10) -> UpdateInfo | None:
206
341
  if current is None:
207
342
  return None
208
343
 
209
- return check_pypi_updates(tool.package_name, current, timeout)
344
+ source = get_tool_source(tool.package_name)
345
+
346
+ if source == ToolSource.GITHUB and tool.github_repo:
347
+ return check_github_updates(tool.github_repo, current, timeout)
348
+ else:
349
+ return check_index_updates(tool.package_name, current, timeout)
210
350
 
211
351
 
212
352
  def get_tool_by_id(tool_id: str) -> ToolSpec | None: