Skip to content
Merged
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
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_recall_installed,
ensure_statusline_installed,
get_effective_install_source,
get_tool_config_dir,
Expand All @@ -35,6 +36,7 @@
"parse_version",
"UV_NOT_FOUND_ERROR",
"ToolSource",
"ensure_recall_installed",
"ensure_statusline_installed",
"get_effective_install_source",
"get_tool_config_dir",
Expand Down
153 changes: 148 additions & 5 deletions src/ai_rules/bootstrap/installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,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/"
RECALL_GITHUB_REPO = "wpfleger96/recall"


def _validate_package_name(package_name: str) -> bool:
Expand Down Expand Up @@ -270,29 +271,36 @@ def get_tool_version(tool_name: str) -> str | None:


def get_effective_install_source(
tool_id: str, cli_github_flag: bool = False
tool_id: str, cli_github_flag: bool = False, config: object | None = None
) -> tuple[ToolSource, str | None]:
"""Resolve the install source for a tool.

Priority (highest first):
1. cli_github_flag — explicit session override (--github flag)
2. Merged config (user config > active profile) managed_tools.install_sources
2. Provided config or merged active config managed_tools.install_sources
3. Default: (ToolSource.PYPI, None)

Args:
tool_id: Tool identifier (e.g., "statusline", "ai-agent-rules")
cli_github_flag: True if --github was passed on the CLI
config: Optional already-loaded Config to avoid active-profile lookups

Returns:
Tuple of (ToolSource, local_path). local_path is set only for LOCAL source.
"""
if cli_github_flag:
return ToolSource.GITHUB, None
try:
from ai_rules.config import Config
if config is None:
from ai_rules.config import Config

config = Config.load()
source = config.get_tool_install_source(tool_id)
config = Config.load()

source_getter = getattr(config, "get_tool_install_source", None)
if not callable(source_getter):
return ToolSource.PYPI, None

source = source_getter(tool_id)
if source == "github":
return ToolSource.GITHUB, None
if source == "pypi":
Expand Down Expand Up @@ -418,3 +426,138 @@ def ensure_statusline_installed(
return "failed", None
except Exception:
return "failed", None


def _is_recall_configured(config: object) -> bool:
"""Check if recall is configured in the merged MCP config."""
if hasattr(config, "mcp_overrides") and "recall" 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 "recall" in data:
return True
except Exception:
pass

return False


def ensure_recall_installed(
dry_run: bool = False,
config: object | None = None,
) -> tuple[str, str | None]:
"""Install or upgrade recall if needed. Fails open.

Args:
dry_run: If True, show what would be done without executing
config: Config object; if provided and recall is not configured, skip

Returns:
Tuple of (status, message) where status is:
"already_installed", "installed", "upgraded", "upgrade_available",
"source_switched", "source_switch_needed", "failed", or "skipped"
"""
if config is not None and not _is_recall_configured(config):
return "skipped", None

source, local_path = get_effective_install_source("recall", config=config)
from_github = source == ToolSource.GITHUB

if is_command_available("recall"):
if source != ToolSource.LOCAL:
try:
from ai_rules.bootstrap.updater import (
check_tool_updates,
get_tool_by_id,
perform_tool_upgrade,
)

recall_tool = get_tool_by_id("recall")
if recall_tool:
current_source = get_tool_source(recall_tool.package_name)
if current_source is not None and current_source != source:
if dry_run:
return (
"source_switch_needed",
f"Would switch recall from {current_source.name} to {source.name}",
)
uninstall_success, _ = uninstall_tool(recall_tool.package_name)
if uninstall_success:
github_url = (
recall_tool.github_install_url if from_github else None
)
success, _ = install_tool(
recall_tool.package_name,
from_github=from_github,
github_url=github_url,
local_path=local_path,
force=True,
)
if success:
return (
"source_switched",
f"{current_source.name} → {source.name}",
)
return "failed", None

update_info = check_tool_updates(recall_tool, timeout=10)
if update_info and update_info.has_update:
if dry_run:
return (
"upgrade_available",
f"Would upgrade recall {update_info.current_version} → {update_info.latest_version}",
)
success, msg, _ = perform_tool_upgrade(recall_tool)
if success:
return (
"upgraded",
f"{update_info.current_version} → {update_info.latest_version}",
)
except Exception:
pass
else:
# LOCAL source: always reinstall to pick up latest local changes
success, message = install_tool(
"recall-mcp-server",
local_path=local_path,
force=True,
dry_run=dry_run,
)
if dry_run:
return "upgrade_available", message
if success:
return "upgraded", "reinstalled from local path"
return "failed", None

return "already_installed", None

try:
github_url = (
make_github_install_url(RECALL_GITHUB_REPO) if from_github else None
)
success, message = install_tool(
"recall-mcp-server",
from_github=from_github,
github_url=github_url,
local_path=local_path,
force=False,
dry_run=dry_run,
)
if success:
return "installed", message if dry_run else None
else:
return "failed", None
except Exception:
return "failed", None
27 changes: 27 additions & 0 deletions src/ai_rules/bootstrap/updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@
from dataclasses import dataclass

from .installer import (
RECALL_GITHUB_REPO,
UV_NOT_FOUND_ERROR,
ToolSource,
_is_recall_configured,
_validate_package_name,
get_tool_source,
get_tool_version,
Expand Down Expand Up @@ -65,6 +67,7 @@ class ToolSpec:
get_version: Callable[[], str | None]
is_installed: Callable[[], bool]
github_repo: str | None = None
is_enabled: Callable[[], bool] | None = None

@property
def github_install_url(self) -> str | None:
Expand Down Expand Up @@ -390,6 +393,19 @@ def perform_tool_upgrade(tool: ToolSpec) -> tuple[bool, str, bool]:
return False, f"Unexpected error: {e}", False


def _is_recall_configured_for_active_profile() -> bool:
"""Check if recall 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_recall_configured(config)
except Exception:
return False


_SELF_SPEC = ToolSpec(
tool_id="ai-agent-rules",
package_name="ai-agent-rules",
Expand All @@ -399,6 +415,16 @@ def perform_tool_upgrade(tool: ToolSpec) -> tuple[bool, str, bool]:
github_repo=_SELF_GITHUB_REPO,
)

_RECALL_SPEC = ToolSpec(
tool_id="recall",
package_name="recall-mcp-server",
display_name="recall",
get_version=lambda: get_tool_version("recall-mcp-server"),
is_installed=lambda: is_command_available("recall"),
github_repo=RECALL_GITHUB_REPO,
is_enabled=lambda: _is_recall_configured_for_active_profile(),
)


def get_updatable_tools() -> list[ToolSpec]:
"""Get all updatable tool specs, deriving from registered Tool classes."""
Expand All @@ -407,6 +433,7 @@ def get_updatable_tools() -> list[ToolSpec]:
tools: list[ToolSpec] = [_SELF_SPEC]
if StatuslineTool.INSTALL_SPEC is not None:
tools.append(StatuslineTool.INSTALL_SPEC)
tools.append(_RECALL_SPEC)
return tools


Expand Down
45 changes: 44 additions & 1 deletion src/ai_rules/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,18 @@ def version_callback(ctx: click.Context, param: click.Parameter, value: bool) ->
except Exception as e:
logger.debug(f"Failed to get statusline version: {e}")

try:
from ai_rules.bootstrap import get_tool_version, is_command_available

if is_command_available("recall"):
bm_version = get_tool_version("recall-mcp-server")
if bm_version:
console.print(f"recall, version {bm_version}")
else:
console.print("recall, version [dim](installed, version unknown)[/dim]")
except Exception as e:
logger.debug(f"Failed to get recall version: {e}")

try:
from ai_rules.bootstrap import check_tool_updates, get_tool_by_id

Expand Down Expand Up @@ -1061,6 +1073,7 @@ def install(

from ai_rules.bootstrap import (
ToolSource,
ensure_recall_installed,
ensure_statusline_installed,
get_effective_install_source,
)
Expand Down Expand Up @@ -1108,7 +1121,28 @@ def install(
if profile and profile != "default":
console.print(f"[dim]Using profile: {profile}[/dim]\n")

sl_source, sl_local_path = get_effective_install_source("statusline")
recall_result, recall_message = ensure_recall_installed(
dry_run=dry_run, config=config
)
if recall_result == "installed":
if dry_run and recall_message:
console.print(f"[dim]{recall_message}[/dim]\n")
else:
console.print("[green]✓[/green] Installed recall\n")
elif recall_result in ("upgraded", "source_switched"):
console.print(
f"[green]✓[/green] Updated recall ({recall_message})\n"
if recall_message
else "[green]✓[/green] Updated recall\n"
)
elif recall_result == "upgrade_available" and dry_run and recall_message:
console.print(f"[dim]{recall_message}[/dim]\n")
elif recall_result == "failed":
console.print(
"[yellow]⚠[/yellow] Failed to install recall (continuing anyway)\n"
)

sl_source, sl_local_path = get_effective_install_source("statusline", config=config)
statusline_result, statusline_message = ensure_statusline_installed(
dry_run=dry_run,
from_github=sl_source == ToolSource.GITHUB,
Expand Down Expand Up @@ -1684,6 +1718,15 @@ def status(agents: str | None) -> None:
console.print(" [yellow]○[/yellow] claude-statusline not installed")
statusline_missing = True

from ai_rules.bootstrap.installer import _is_recall_configured

if _is_recall_configured(config):
if is_command_available("recall"):
console.print(" [green]✓[/green] recall installed")
else:
console.print(" [yellow]○[/yellow] recall not installed")
statusline_missing = True

console.print()

console.print("[bold cyan]Shell Completions[/bold cyan]\n")
Expand Down
11 changes: 11 additions & 0 deletions src/ai_rules/config/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,17 @@ Three similar lines > premature abstraction | No helpers for one-time ops | Only

**Why:** LLMs confidently generate plausible-sounding but incorrect assumptions. Explicit verification prevents wasted work and builds trust through transparency.

### Persistent Knowledge Base (recall)

When the recall MCP is configured, it provides a persistent markdown knowledge base at `~/.recall/` with FTS5 full-text search and BM25 ranking. Knowledge persists across sessions, repos, and machines via git sync.

**Write-back loop:** When recall tools are available and you synthesize a useful answer, debug a non-obvious issue, or the user corrects a false assumption, consider persisting it via `write_note`. Search first to avoid duplicates. Invoke the `/kb` skill for formatting conventions.

**Quality conventions:** Use `[GAP: ...]` annotations for unverified claims. Use `[misconception]` tags to document what is NOT true (prevents confident false assertions). Use `[promote]` to flag patterns worth promoting to AGENTS.md rules.

**Key tools:** `search_notes(query)` for FTS5 search, `build_context(path)` for graph traversal, `read_note(path)` for specific notes, `write_note(path, content)` to persist knowledge.


---

## Technical Standards
Expand Down
3 changes: 2 additions & 1 deletion src/ai_rules/config/claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,10 @@
"type": "command",
"command": "claude-statusline"
},
"viewMode": "verbose",
"alwaysThinkingEnabled": true,
"showThinkingSummaries": true,
"skipDangerousModePermissionPrompt": true,
"skipAutoPermissionPrompt": true,
"viewMode": "verbose"
"hooks": {}
}
10 changes: 9 additions & 1 deletion src/ai_rules/config/profiles/personal.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,12 @@ settings_overrides:
terminal_title:
enabled: true
exclude_symlinks: []
mcp_overrides: {}
mcp_overrides:
recall:
type: stdio
command: recall
args:
- mcp
- stdio
name: recall
description: Persistent markdown knowledge base with FTS5 search and git sync
10 changes: 9 additions & 1 deletion src/ai_rules/config/profiles/work.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,12 @@ settings_overrides:
useFullWidth: true
exclude_symlinks:
- ~/.config/goose/config.yaml
mcp_overrides: {}
mcp_overrides:
recall:
type: stdio
command: recall
args:
- mcp
- stdio
name: recall
description: Persistent markdown knowledge base with FTS5 search and git sync
Loading
Loading