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.
- ai_agent_rules-0.11.0.dist-info/METADATA +390 -0
- ai_agent_rules-0.11.0.dist-info/RECORD +42 -0
- ai_agent_rules-0.11.0.dist-info/WHEEL +5 -0
- ai_agent_rules-0.11.0.dist-info/entry_points.txt +3 -0
- ai_agent_rules-0.11.0.dist-info/licenses/LICENSE +22 -0
- ai_agent_rules-0.11.0.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 +121 -0
- ai_rules/agents/goose.py +44 -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 +249 -0
- ai_rules/bootstrap/updater.py +221 -0
- ai_rules/bootstrap/version.py +52 -0
- ai_rules/cli.py +2292 -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/agents/code-reviewer.md +121 -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 +116 -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/goose/config.yaml +55 -0
- ai_rules/config.py +635 -0
- ai_rules/display.py +40 -0
- ai_rules/mcp.py +370 -0
- ai_rules/symlinks.py +207 -0
ai_rules/agents/goose.py
ADDED
|
@@ -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"
|