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,123 @@
|
|
|
1
|
+
"""Claude Code agent implementation."""
|
|
2
|
+
|
|
3
|
+
from functools import cached_property
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from ai_rules.agents.base import Agent
|
|
7
|
+
from ai_rules.mcp import MCPManager, MCPStatus, OperationResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ClaudeAgent(Agent):
|
|
11
|
+
"""Agent for Claude Code configuration."""
|
|
12
|
+
|
|
13
|
+
DEPRECATED_SYMLINKS: list[Path] = [
|
|
14
|
+
Path("~/CLAUDE.md"),
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def name(self) -> str:
|
|
19
|
+
return "Claude Code"
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def agent_id(self) -> str:
|
|
23
|
+
return "claude"
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def config_file_name(self) -> str:
|
|
27
|
+
return "settings.json"
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def config_file_format(self) -> str:
|
|
31
|
+
return "json"
|
|
32
|
+
|
|
33
|
+
@cached_property
|
|
34
|
+
def symlinks(self) -> list[tuple[Path, Path]]:
|
|
35
|
+
"""Cached list of all Claude Code symlinks including dynamic agents/commands."""
|
|
36
|
+
result = []
|
|
37
|
+
|
|
38
|
+
result.append(
|
|
39
|
+
(Path("~/.claude/CLAUDE.md"), self.config_dir / "claude" / "CLAUDE.md")
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
settings_file = self.config_dir / "claude" / "settings.json"
|
|
43
|
+
if settings_file.exists():
|
|
44
|
+
target_file = self.config.get_settings_file_for_symlink(
|
|
45
|
+
"claude", settings_file
|
|
46
|
+
)
|
|
47
|
+
result.append((Path("~/.claude/settings.json"), target_file))
|
|
48
|
+
|
|
49
|
+
agents_dir = self.config_dir / "claude" / "agents"
|
|
50
|
+
if agents_dir.exists():
|
|
51
|
+
for agent_file in sorted(agents_dir.glob("*.md")):
|
|
52
|
+
result.append(
|
|
53
|
+
(
|
|
54
|
+
Path(f"~/.claude/agents/{agent_file.name}"),
|
|
55
|
+
agent_file,
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
commands_dir = self.config_dir / "claude" / "commands"
|
|
60
|
+
if commands_dir.exists():
|
|
61
|
+
for command_file in sorted(commands_dir.glob("*.md")):
|
|
62
|
+
result.append(
|
|
63
|
+
(
|
|
64
|
+
Path(f"~/.claude/commands/{command_file.name}"),
|
|
65
|
+
command_file,
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
skills_dir = self.config_dir / "claude" / "skills"
|
|
70
|
+
if skills_dir.exists():
|
|
71
|
+
for skill_folder in sorted(skills_dir.glob("*")):
|
|
72
|
+
if skill_folder.is_dir():
|
|
73
|
+
result.append(
|
|
74
|
+
(
|
|
75
|
+
Path(f"~/.claude/skills/{skill_folder.name}"),
|
|
76
|
+
skill_folder,
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
def get_deprecated_symlinks(self) -> list[Path]:
|
|
83
|
+
"""Return deprecated symlink locations for cleanup."""
|
|
84
|
+
return self.DEPRECATED_SYMLINKS
|
|
85
|
+
|
|
86
|
+
def install_mcps(
|
|
87
|
+
self, force: bool = False, dry_run: bool = False
|
|
88
|
+
) -> tuple[OperationResult, str, list[str]]:
|
|
89
|
+
"""Install managed MCPs into ~/.claude.json.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
force: Skip confirmation prompts
|
|
93
|
+
dry_run: Don't actually modify files
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Tuple of (result, message, conflicts_list)
|
|
97
|
+
"""
|
|
98
|
+
manager = MCPManager()
|
|
99
|
+
return manager.install_mcps(self.config_dir, self.config, force, dry_run)
|
|
100
|
+
|
|
101
|
+
def uninstall_mcps(
|
|
102
|
+
self, force: bool = False, dry_run: bool = False
|
|
103
|
+
) -> tuple[OperationResult, str]:
|
|
104
|
+
"""Uninstall managed MCPs from ~/.claude.json.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
force: Skip confirmation prompts
|
|
108
|
+
dry_run: Don't actually modify files
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Tuple of (result, message)
|
|
112
|
+
"""
|
|
113
|
+
manager = MCPManager()
|
|
114
|
+
return manager.uninstall_mcps(force, dry_run)
|
|
115
|
+
|
|
116
|
+
def get_mcp_status(self) -> MCPStatus:
|
|
117
|
+
"""Get status of managed and unmanaged MCPs.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
MCPStatus object with categorized MCPs
|
|
121
|
+
"""
|
|
122
|
+
manager = MCPManager()
|
|
123
|
+
return manager.get_status(self.config_dir, self.config)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Cursor editor agent implementation."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from functools import cached_property
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ai_rules.agents.base import Agent
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _get_cursor_target_prefix() -> str:
|
|
12
|
+
"""Get platform-specific Cursor config path with ~ prefix.
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
Path string with ~ prefix for the current platform:
|
|
16
|
+
- macOS: ~/Library/Application Support/Cursor/User
|
|
17
|
+
- Windows: ~/AppData/Roaming/Cursor/User
|
|
18
|
+
- Linux/WSL: ~/.config/Cursor/User
|
|
19
|
+
"""
|
|
20
|
+
if sys.platform == "darwin":
|
|
21
|
+
return "~/Library/Application Support/Cursor/User"
|
|
22
|
+
elif sys.platform == "win32":
|
|
23
|
+
return "~/AppData/Roaming/Cursor/User"
|
|
24
|
+
else: # Linux/WSL
|
|
25
|
+
return "~/.config/Cursor/User"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CursorAgent(Agent):
|
|
29
|
+
"""Agent for Cursor editor configuration."""
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def name(self) -> str:
|
|
33
|
+
return "Cursor"
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def agent_id(self) -> str:
|
|
37
|
+
return "cursor"
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def config_file_name(self) -> str:
|
|
41
|
+
return "settings.json"
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def config_file_format(self) -> str:
|
|
45
|
+
return "json"
|
|
46
|
+
|
|
47
|
+
@cached_property
|
|
48
|
+
def symlinks(self) -> list[tuple[Path, Path]]:
|
|
49
|
+
"""Cached list of all Cursor symlinks.
|
|
50
|
+
|
|
51
|
+
Settings file uses cache-based approach with override merging.
|
|
52
|
+
Keybindings file uses direct symlink (array structure, no merging).
|
|
53
|
+
"""
|
|
54
|
+
result = []
|
|
55
|
+
prefix = _get_cursor_target_prefix()
|
|
56
|
+
|
|
57
|
+
# Settings file - use cache if overrides exist
|
|
58
|
+
settings_file = self.config_dir / "cursor" / "settings.json"
|
|
59
|
+
if settings_file.exists():
|
|
60
|
+
target_file = self.config.get_settings_file_for_symlink(
|
|
61
|
+
"cursor", settings_file
|
|
62
|
+
)
|
|
63
|
+
result.append((Path(f"{prefix}/settings.json"), target_file))
|
|
64
|
+
|
|
65
|
+
# Keybindings file - direct symlink (no override merging for arrays)
|
|
66
|
+
keybindings_file = self.config_dir / "cursor" / "keybindings.json"
|
|
67
|
+
if keybindings_file.exists():
|
|
68
|
+
result.append((Path(f"{prefix}/keybindings.json"), keybindings_file))
|
|
69
|
+
|
|
70
|
+
return result
|
ai_rules/agents/goose.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
(
|
|
35
|
+
Path("~/.config/goose/.goosehints"),
|
|
36
|
+
self.config_dir / "goose" / ".goosehints",
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
config_file = self.config_dir / "goose" / "config.yaml"
|
|
41
|
+
if config_file.exists():
|
|
42
|
+
target_file = self.config.get_settings_file_for_symlink(
|
|
43
|
+
"goose", config_file
|
|
44
|
+
)
|
|
45
|
+
result.append((Path("~/.config/goose/config.yaml"), target_file))
|
|
46
|
+
|
|
47
|
+
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_index_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_index_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)
|