Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions src/ai_rules/agents/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ def preserved_fields(self) -> list[str]:
"""
return []

@property
def needs_cache(self) -> bool:
"""Whether this agent needs a cache file (has overrides or preserved fields)."""
return self.agent_id in self.config.settings_overrides or bool(
self.preserved_fields
)

@cached_property
@abstractmethod
def symlinks(self) -> list[tuple[Path, Path]]:
Expand Down Expand Up @@ -96,11 +103,11 @@ def build_merged_settings(
load_config_file,
)

if self.agent_id not in self.config.settings_overrides:
if not self.needs_cache:
return None

cache_path = self.config.get_merged_settings_path(
self.agent_id, self.config_file_name
self.agent_id, self.config_file_name, force=True
)

if not force_rebuild and cache_path and cache_path.exists():
Expand Down Expand Up @@ -162,11 +169,11 @@ def is_cache_stale(self) -> bool:
Returns:
True if cache needs rebuilding, False otherwise
"""
if self.agent_id not in self.config.settings_overrides:
if not self.needs_cache:
return False

cache_path = self.config.get_merged_settings_path(
self.agent_id, self.config_file_name
self.agent_id, self.config_file_name, force=True
)
if not cache_path or not cache_path.exists():
return True
Expand Down Expand Up @@ -206,7 +213,7 @@ def get_cache_diff(self) -> str | None:

from ai_rules.config import CONFIG_PARSE_ERRORS, load_config_file

if self.agent_id not in self.config.settings_overrides:
if not self.needs_cache:
return None

config_format = self.config_file_format
Expand All @@ -221,7 +228,7 @@ def get_cache_diff(self) -> str | None:
return None

cache_path = self.config.get_merged_settings_path(
self.agent_id, self.config_file_name
self.agent_id, self.config_file_name, force=True
)
cache_exists = cache_path and cache_path.exists()

Expand Down
2 changes: 1 addition & 1 deletion src/ai_rules/agents/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def symlinks(self) -> list[tuple[Path, Path]]:
settings_file = self.config_dir / "claude" / "settings.json"
if settings_file.exists():
target_file = self.config.get_settings_file_for_symlink(
"claude", settings_file
"claude", settings_file, force=bool(self.preserved_fields)
)
result.append((Path("~/.claude/settings.json"), target_file))

Expand Down
2 changes: 1 addition & 1 deletion src/ai_rules/agents/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def symlinks(self) -> list[tuple[Path, Path]]:
config_file = self.config_dir / "codex" / "config.toml"
if config_file.exists():
target_file = self.config.get_settings_file_for_symlink(
"codex", config_file
"codex", config_file, force=bool(self.preserved_fields)
)
result.append((Path("~/.codex/config.toml"), target_file))

Expand Down
2 changes: 1 addition & 1 deletion src/ai_rules/agents/gemini.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def symlinks(self) -> list[tuple[Path, Path]]:
config_file = self.config_dir / "gemini" / "settings.json"
if config_file.exists():
target_file = self.config.get_settings_file_for_symlink(
"gemini", config_file
"gemini", config_file, force=bool(self.preserved_fields)
)
result.append((Path("~/.gemini/settings.json"), target_file))

Expand Down
2 changes: 1 addition & 1 deletion src/ai_rules/agents/goose.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def symlinks(self) -> list[tuple[Path, Path]]:
config_file = self.config_dir / "goose" / "config.yaml"
if config_file.exists():
target_file = self.config.get_settings_file_for_symlink(
"goose", config_file
"goose", config_file, force=bool(self.preserved_fields)
)
result.append((Path("~/.config/goose/config.yaml"), target_file))

Expand Down
2 changes: 2 additions & 0 deletions src/ai_rules/bootstrap/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from .installer import (
UV_NOT_FOUND_ERROR,
ToolSource,
ensure_basic_memory_installed,
ensure_statusline_installed,
get_tool_config_dir,
get_tool_source,
Expand All @@ -34,6 +35,7 @@
"parse_version",
"UV_NOT_FOUND_ERROR",
"ToolSource",
"ensure_basic_memory_installed",
"ensure_statusline_installed",
"get_tool_config_dir",
"get_tool_source",
Expand Down
140 changes: 140 additions & 0 deletions src/ai_rules/bootstrap/installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ def make_github_install_url(repo: str) -> str:
UV_NOT_FOUND_ERROR = "uv not found in PATH. Install from https://docs.astral.sh/uv/"
GITHUB_REPO = "wpfleger96/ai-rules"
STATUSLINE_GITHUB_REPO = "wpfleger96/claude-code-status-line"
BASIC_MEMORY_GITHUB_REPO = "basicmachines-co/basic-memory"


def _validate_package_name(package_name: str) -> bool:
Expand Down Expand Up @@ -318,3 +319,142 @@ def ensure_statusline_installed(
return "failed", None
except Exception:
return "failed", None


def _run_basic_memory_setup() -> None:
"""Run the idempotent basic-memory setup script (git init, GitHub remote).

Reads basic_memory config from ~/.ai-rules-config.yaml and passes
as env vars to the setup script.
"""
setup_script = (
Path(__file__).parent.parent
/ "config"
/ "claude"
/ "hooks"
/ "basic-memory-setup.sh"
)
if not setup_script.exists():
return

env = dict(os.environ)
try:
import yaml

user_config_path = Path.home() / ".ai-rules-config.yaml"
if user_config_path.exists():
with open(user_config_path) as f:
user_config = yaml.safe_load(f) or {}
bm_config = user_config.get("basic_memory", {})
if bm_config.get("repo"):
env["BASIC_MEMORY_WIKI_REPO"] = bm_config["repo"]
if bm_config.get("path"):
env["BASIC_MEMORY_HOME"] = str(Path(bm_config["path"]).expanduser())
except Exception:
pass

try:
subprocess.run(
["bash", str(setup_script)],
timeout=60,
capture_output=True,
env=env,
)
except (subprocess.TimeoutExpired, Exception):
pass


def _is_basic_memory_configured(config: object) -> bool:
"""Check if basic-memory is configured in the merged MCP config.

Checks both profile mcp_overrides and the base mcps.json file.
"""
if hasattr(config, "mcp_overrides") and "basic-memory" in config.mcp_overrides:
return True

try:
import importlib.resources

config_pkg = importlib.resources.files("ai_rules") / "config"
for mcps_path in [
config_pkg / "mcps.json",
config_pkg / "claude" / "mcps.json",
]:
traversable = mcps_path
if hasattr(traversable, "is_file") and traversable.is_file():
import json

data = json.loads(traversable.read_text())
if "basic-memory" in data:
return True
except Exception:
pass

return False


def ensure_basic_memory_installed(
dry_run: bool = False,
from_github: bool = False,
config: object | None = None,
) -> tuple[str, str | None]:
"""Install or upgrade basic-memory if needed. Runs setup script after. Fails open.

Args:
dry_run: If True, show what would be done without executing
from_github: Install from GitHub instead of PyPI
config: Config object; if provided and basic-memory is not configured, skip

Returns:
Tuple of (status, message) where status is:
"already_installed", "installed", "upgraded", "upgrade_available", "failed", or "skipped"
"""
if config is not None and not _is_basic_memory_configured(config):
return "skipped", None
if is_command_available("basic-memory"):
try:
from ai_rules.bootstrap.updater import (
check_tool_updates,
get_tool_by_id,
perform_tool_upgrade,
)

bm_tool = get_tool_by_id("basic-memory")
if bm_tool:
update_info = check_tool_updates(bm_tool, timeout=10)
if update_info and update_info.has_update:
if dry_run:
return (
"upgrade_available",
f"Would upgrade basic-memory {update_info.current_version} → {update_info.latest_version}",
)
success, msg, _ = perform_tool_upgrade(bm_tool)
if success:
return (
"upgraded",
f"{update_info.current_version} → {update_info.latest_version}",
)
except Exception:
pass
if not dry_run:
_run_basic_memory_setup()
return "already_installed", None

try:
success, message = install_tool(
"basic-memory",
from_github=from_github,
github_url=make_github_install_url(BASIC_MEMORY_GITHUB_REPO)
if from_github
else None,
force=False,
dry_run=dry_run,
)
if success:
if not dry_run:
_run_basic_memory_setup()
return "installed", message if dry_run else None
else:
return "failed", None
except Exception:
return "failed", None
24 changes: 24 additions & 0 deletions src/ai_rules/bootstrap/updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@
from dataclasses import dataclass

from .installer import (
BASIC_MEMORY_GITHUB_REPO,
GITHUB_REPO,
STATUSLINE_GITHUB_REPO,
UV_NOT_FOUND_ERROR,
ToolSource,
_is_basic_memory_configured,
_validate_package_name,
get_tool_source,
get_tool_version,
Expand Down Expand Up @@ -388,6 +390,19 @@ def perform_tool_upgrade(tool: ToolSpec) -> tuple[bool, str, bool]:
return False, f"Unexpected error: {e}", False


def _is_basic_memory_configured_for_active_profile() -> bool:
"""Check if basic-memory is configured for the currently active profile."""
try:
from ai_rules.config import Config
from ai_rules.state import get_active_profile

profile = get_active_profile() or "default"
config = Config.load(profile=profile)
return _is_basic_memory_configured(config)
except Exception:
return False


UPDATABLE_TOOLS: list[ToolSpec] = [
ToolSpec(
tool_id="ai-rules",
Expand All @@ -405,6 +420,15 @@ def perform_tool_upgrade(tool: ToolSpec) -> tuple[bool, str, bool]:
is_installed=lambda: is_command_available("claude-statusline"),
github_repo=STATUSLINE_GITHUB_REPO,
),
ToolSpec(
tool_id="basic-memory",
package_name="basic-memory",
display_name="basic-memory",
get_version=lambda: get_tool_version("basic-memory"),
is_installed=lambda: is_command_available("basic-memory"),
github_repo=BASIC_MEMORY_GITHUB_REPO,
is_enabled=lambda: _is_basic_memory_configured_for_active_profile(),
),
]


Expand Down
Loading
Loading