diff --git a/src/ai_rules/bootstrap/__init__.py b/src/ai_rules/bootstrap/__init__.py index df4f655..1cc8a84 100644 --- a/src/ai_rules/bootstrap/__init__.py +++ b/src/ai_rules/bootstrap/__init__.py @@ -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, @@ -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", diff --git a/src/ai_rules/bootstrap/installer.py b/src/ai_rules/bootstrap/installer.py index e38577a..b64a4fb 100644 --- a/src/ai_rules/bootstrap/installer.py +++ b/src/ai_rules/bootstrap/installer.py @@ -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: @@ -270,18 +271,19 @@ 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. @@ -289,10 +291,16 @@ def get_effective_install_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": @@ -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 diff --git a/src/ai_rules/bootstrap/updater.py b/src/ai_rules/bootstrap/updater.py index b32d7f5..323a913 100644 --- a/src/ai_rules/bootstrap/updater.py +++ b/src/ai_rules/bootstrap/updater.py @@ -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, @@ -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: @@ -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", @@ -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.""" @@ -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 diff --git a/src/ai_rules/cli.py b/src/ai_rules/cli.py index 9c849ca..d05f816 100644 --- a/src/ai_rules/cli.py +++ b/src/ai_rules/cli.py @@ -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 @@ -1061,6 +1073,7 @@ def install( from ai_rules.bootstrap import ( ToolSource, + ensure_recall_installed, ensure_statusline_installed, get_effective_install_source, ) @@ -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, @@ -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") diff --git a/src/ai_rules/config/AGENTS.md b/src/ai_rules/config/AGENTS.md index d26565b..b1a7f87 100644 --- a/src/ai_rules/config/AGENTS.md +++ b/src/ai_rules/config/AGENTS.md @@ -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 diff --git a/src/ai_rules/config/claude/settings.json b/src/ai_rules/config/claude/settings.json index f898a5d..05b88a8 100644 --- a/src/ai_rules/config/claude/settings.json +++ b/src/ai_rules/config/claude/settings.json @@ -135,9 +135,10 @@ "type": "command", "command": "claude-statusline" }, + "viewMode": "verbose", "alwaysThinkingEnabled": true, "showThinkingSummaries": true, "skipDangerousModePermissionPrompt": true, "skipAutoPermissionPrompt": true, - "viewMode": "verbose" + "hooks": {} } diff --git a/src/ai_rules/config/profiles/personal.yaml b/src/ai_rules/config/profiles/personal.yaml index c049c7e..cde0947 100644 --- a/src/ai_rules/config/profiles/personal.yaml +++ b/src/ai_rules/config/profiles/personal.yaml @@ -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 diff --git a/src/ai_rules/config/profiles/work.yaml b/src/ai_rules/config/profiles/work.yaml index f893bb7..8ab6201 100644 --- a/src/ai_rules/config/profiles/work.yaml +++ b/src/ai_rules/config/profiles/work.yaml @@ -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 diff --git a/src/ai_rules/config/skills/kb/SKILL.md b/src/ai_rules/config/skills/kb/SKILL.md new file mode 100644 index 0000000..913d562 --- /dev/null +++ b/src/ai_rules/config/skills/kb/SKILL.md @@ -0,0 +1,182 @@ +--- +name: kb +description: >- + This skill should be used when recall MCP tools are available and the user + asks to "save to knowledge base", "write a note", "persist this", "remember + this pattern", "update the KB", or when the Stop hook instructs the agent to + persist session knowledge. Also use when asking "search knowledge base", + "what do we know about", or needing cross-project context from recall. +--- + +# Knowledge Base (recall) + +When configured, recall provides a persistent markdown knowledge base at `~/.recall/`. Knowledge persists across sessions, repos, and machines via git sync. Searchable with FTS5 full-text search with BM25 ranking. + +## Workflow + +1. **Search first** to avoid duplicates: `search_notes(query="topic")` +2. **Read existing** if a related note exists: `read_note(path="repos/ai-rules.md")` +3. **Write or update**: `write_note(path="repos/ai-rules.md", content="---\ntitle: ...\ntype: note\nupdated: 2026-04-30\ntags: []\n---\n\n# ...")` +4. **Connect related notes** using `[[wikilinks]]` in the Relations section + +## Directory Guide + +| Directory | Use for | Example titles | +|-----------|---------|----------------| +| `repos/` | Per-repo commands, gotchas, patterns | "ai-rules", "goosed-slackbot" | +| `patterns/` | Reusable technical knowledge | "uv-run-not-direct-invocation" | +| `decisions/` | ADRs -- why something was chosen | "raw-sql-over-sqlalchemy" | +| `preferences/` | User working style, conventions | "test-docstring-conventions" | +| `references/` | External knowledge, company info | "block-ci-cd-pipeline" | +| `references/block/` | Block/Square-specific knowledge | "service-registry-conventions" | +| `people/` | Teammates, communication context | "tyler-sprout-expert" | +| `feedback/` | Corrections, lessons from mistakes | "no-assertions-without-verification" | +| `projects/` | Multi-repo initiative context | "slackbot-kotlin-migration" | + +## Note Format + +Every note uses YAML frontmatter + structured observations + relations: + +```markdown +--- +title: Note Title +type: note +updated: 2026-04-30 +tags: [tag1, tag2] +--- + +# Note Title + +## Observations +- [fact] Concrete, verified information +- [tip] Practical advice for future use +- [method] How to do something specific +- [preference] User's stated preference or convention +- [decision] A choice that was made and why + +## Relations +- related_to [[other-note-title]] +- depends_on [[prerequisite-note]] +``` + +### Observation Categories + +- **`[fact]`** -- verified, objective information (e.g., "Uses Gradle wrapper, not Maven") +- **`[tip]`** -- practical guidance (e.g., "Check Justfile before guessing build commands") +- **`[method]`** -- how to do something (e.g., "Run `just check-all` for full validation") +- **`[preference]`** -- user's stated preference (e.g., "Never add docstrings to test functions") +- **`[decision]`** -- a choice with rationale (e.g., "Chose raw SQL for performance over ORM convenience") +- **`[misconception]`** -- something commonly assumed but false (e.g., "recall does NOT use vector search -- it uses FTS5 with BM25 ranking; the LLM handles semantic understanding via query expansion") + +### GAP Annotations + +When writing notes with uncertain or unverified information, mark the uncertainty explicitly: + +``` +- [fact] recall uses FTS5 full-text search with BM25 ranking [GAP: retrieval quality vs vector search not benchmarked] +- [fact] uv tool install respects XDG_DATA_HOME [GAP: behavior on Windows not verified] +``` + +`[GAP: ...]` makes knowledge base weaknesses machine-readable. Agents can grep for `[GAP:` to find notes needing verification. Include: what information is missing and how to verify it. + +### Tags + +Use lowercase, hyphenated tags in frontmatter. Common tags: `python`, `kotlin`, `rust`, `block`, `testing`, `ci-cd`, `architecture`, `gotcha`. + +### Wikilinks + +Connect related notes with `[[note-title]]` in the Relations section. Use typed relations: +- `related_to [[note]]` -- general connection +- `depends_on [[note]]` -- prerequisite +- `supersedes [[note]]` -- replaces older knowledge +- `contradicts [[note]]` -- conflicting information (flag for review) + +## When to Write vs Search + +**Write a note when:** +- A decision was made about architecture or approach +- A repo-specific gotcha or non-obvious command was discovered +- Company-specific knowledge was learned (Block internals, service names, team conventions) +- The user corrected a previous assumption +- A reusable pattern was identified + +**Search instead when:** +- Starting work in an unfamiliar repo -- `search_notes(query="repo-name")` +- Encountering a pattern already seen -- `search_notes(query="topic")` +- Needing cross-project context -- `build_context(path="repos/ai-rules.md")` + +**Do NOT write:** +- Session-specific ephemeral context (what files were edited this session) +- Information obvious from the codebase (README content, import paths) +- Speculative or unverified information (unless marked with `[GAP:]`) + +## Write-Back Loop + +After any session where you synthesized a useful answer, consider what's worth persisting: + +**When to trigger a write-back:** +- You answered a question by combining knowledge from multiple sources +- You discovered a non-obvious pattern, workaround, or gotcha not in the codebase docs +- You corrected a previous false assumption (write the correction, tag `[misconception]` for the old belief) +- The user explicitly says "remember this" or "save this" + +**The write-back workflow:** +1. Search first: `search_notes(query="")` to avoid duplicating existing knowledge +2. If note exists: use `edit_note()` to add the new insight rather than rewriting +3. If new: pick the appropriate directory, use the template from `references/note-templates.md` +4. Connect with wikilinks to related notes +5. Recall auto-commits and pushes after each write (no external hook needed) + +**Schema promotion:** When a pattern repeats across 3+ notes, it may be worth promoting to `AGENTS.md` as a standing rule. Flag candidate patterns with a `[promote]` tag in the observation. + +## Eval-First Methodology + +Don't pre-populate the knowledge base speculatively. Let real usage reveal what's missing: + +1. **Collect failing questions** -- track queries where agents struggled or gave wrong answers +2. **Test without KB** -- establish baseline: what does the agent get wrong cold? +3. **Write targeted notes** -- one note per knowledge gap, no more +4. **Re-test** -- verify the note actually fixes the failure case +5. **Repeat** -- iterate on real failures, not hypothetical ones + +This prevents "note sprawl" -- a KB full of notes that never get queried because they weren't driven by actual agent failure patterns. + +## KB Lint (Health Check) + +Periodically audit the knowledge base for quality issues. Use `recall_lint()` for automated checks. + +**Lint checks (automated via `recall_lint()`):** +1. **Staleness** -- notes not updated in 90+ days +2. **Orphan wikilinks** -- `[[links]]` pointing to notes that don't exist +3. **Missing frontmatter** -- notes missing required fields (title, type, updated) +4. **GAP annotations** -- notes with unresolved `[GAP:]` markers +5. **Contradictions** -- notes with `contradicts [[...]]` relations + +**What to do with findings:** +- Stale notes: verify and update, or add `[fact] Unable to verify as of ` +- Orphans: create the missing target note, or fix the link +- Contradictions: reconcile, update the winner, mark the loser with `supersedes` +- Missing fields: add required frontmatter + +## MCP Tools Quick Reference + +| Tool | Purpose | +|------|---------| +| `search_notes(query, tags?, types?)` | FTS5 full-text search across all notes | +| `build_context(path)` | Graph traversal from a note via wikilinks | +| `read_note(path)` | Read a specific note by relative path | +| `write_note(path, content)` | Create or update a note (full markdown with frontmatter) | +| `edit_note(path, find, replace)` | Partial edit without full rewrite | +| `delete_note(path)` | Delete a note (ask user first) | +| `recent_activity(n?)` | Recently modified notes | +| `list_directory(directory?)` | Browse the folder structure | +| `recall_lint()` | Automated KB health check | +| `recall_sync()` | Explicit git pull/push | +| `recall_status()` | Index stats, last sync, repo info | + +## Additional Resources + +### Reference Files + +For complete note templates with realistic examples for each directory type: +- **`references/note-templates.md`** -- Full templates for repos/, patterns/, decisions/, preferences/, references/, people/, feedback/, projects/ diff --git a/tests/unit/test_bootstrap_installer.py b/tests/unit/test_bootstrap_installer.py index b65ad5e..1ffcedc 100644 --- a/tests/unit/test_bootstrap_installer.py +++ b/tests/unit/test_bootstrap_installer.py @@ -3,6 +3,7 @@ import subprocess import sys +from types import SimpleNamespace from unittest.mock import MagicMock import pytest @@ -10,6 +11,8 @@ from ai_rules.bootstrap.installer import ( UV_NOT_FOUND_ERROR, ToolSource, + _is_recall_configured, + ensure_recall_installed, get_effective_install_source, get_tool_config_dir, get_tool_source, @@ -486,6 +489,22 @@ def test_config_local_returns_local_with_path(self, monkeypatch): assert source == ToolSource.LOCAL assert local_path == "~/Development/recall" + def test_passed_config_is_used_without_loading_active_profile(self, monkeypatch): + """An explicit config avoids active-profile source lookups.""" + from ai_rules.config import Config + + def _raise(*args, **kwargs): + raise RuntimeError("should not load active profile") + + mock_config = MagicMock() + mock_config.get_tool_install_source.return_value = "local:~/Development/recall" + monkeypatch.setattr(Config, "load", _raise) + + source, local_path = get_effective_install_source("recall", config=mock_config) + + assert source == ToolSource.LOCAL + assert local_path == "~/Development/recall" + def test_defaults_to_pypi_when_nothing_configured(self, monkeypatch): """Falls back to PYPI when no config and no CLI flag.""" from ai_rules.config import Config @@ -508,3 +527,196 @@ def _raise(*args, **kwargs): source, local_path = get_effective_install_source("statusline") assert source == ToolSource.PYPI assert local_path is None + + +@pytest.mark.unit +@pytest.mark.bootstrap +class TestIsRecallConfigured: + """Tests for _is_recall_configured helper.""" + + def test_returns_true_when_in_mcp_overrides(self): + config = SimpleNamespace(mcp_overrides={"recall": {"command": "recall"}}) + assert _is_recall_configured(config) is True + + def test_returns_false_when_mcp_overrides_empty(self, monkeypatch): + import importlib.resources + + config = SimpleNamespace(mcp_overrides={}) + monkeypatch.setattr( + importlib.resources, + "files", + lambda pkg: _MockTraversable({}), + ) + assert _is_recall_configured(config) is False + + def test_returns_false_when_no_mcp_overrides_attr(self, monkeypatch): + import importlib.resources + + config = SimpleNamespace() + monkeypatch.setattr( + importlib.resources, + "files", + lambda pkg: _MockTraversable({}), + ) + assert _is_recall_configured(config) is False + + +@pytest.mark.unit +@pytest.mark.bootstrap +class TestEnsureRecallInstalled: + """Tests for ensure_recall_installed function.""" + + def test_skips_when_not_configured(self, monkeypatch): + config = SimpleNamespace(mcp_overrides={}) + monkeypatch.setattr( + "ai_rules.bootstrap.installer._is_recall_configured", + lambda c: False, + ) + status, msg = ensure_recall_installed(config=config) + assert status == "skipped" + assert msg is None + + def test_returns_already_installed_when_available(self, monkeypatch): + monkeypatch.setattr( + "ai_rules.bootstrap.installer._is_recall_configured", + lambda c: True, + ) + monkeypatch.setattr( + "ai_rules.bootstrap.installer.is_command_available", + lambda cmd: True, + ) + monkeypatch.setattr( + "ai_rules.bootstrap.installer.get_effective_install_source", + lambda *args, **kwargs: (ToolSource.PYPI, None), + ) + + from ai_rules.bootstrap import updater + + monkeypatch.setattr( + updater, + "get_tool_by_id", + lambda tid: None, + ) + status, msg = ensure_recall_installed( + config=SimpleNamespace(mcp_overrides={"recall": {}}) + ) + assert status == "already_installed" + + def test_installs_on_fresh_install(self, monkeypatch): + monkeypatch.setattr( + "ai_rules.bootstrap.installer._is_recall_configured", + lambda c: True, + ) + monkeypatch.setattr( + "ai_rules.bootstrap.installer.is_command_available", + lambda cmd: cmd != "recall", + ) + monkeypatch.setattr( + "ai_rules.bootstrap.installer.get_effective_install_source", + lambda *args, **kwargs: (ToolSource.PYPI, None), + ) + monkeypatch.setattr( + "ai_rules.bootstrap.installer.install_tool", + lambda *a, **kw: (True, "ok"), + ) + status, msg = ensure_recall_installed( + config=SimpleNamespace(mcp_overrides={"recall": {}}) + ) + assert status == "installed" + + def test_local_install_failure_returns_failed(self, monkeypatch): + """LOCAL install failure must return 'failed', not fall through to 'already_installed'.""" + monkeypatch.setattr( + "ai_rules.bootstrap.installer._is_recall_configured", + lambda c: True, + ) + monkeypatch.setattr( + "ai_rules.bootstrap.installer.is_command_available", + lambda cmd: True, + ) + monkeypatch.setattr( + "ai_rules.bootstrap.installer.get_effective_install_source", + lambda *args, **kwargs: (ToolSource.LOCAL, "~/dev/recall"), + ) + monkeypatch.setattr( + "ai_rules.bootstrap.installer.install_tool", + lambda *a, **kw: (False, "install failed"), + ) + status, msg = ensure_recall_installed( + config=SimpleNamespace(mcp_overrides={"recall": {}}) + ) + assert status == "failed" + + def test_local_install_success_returns_upgraded(self, monkeypatch): + """Successful LOCAL reinstall returns 'upgraded'.""" + monkeypatch.setattr( + "ai_rules.bootstrap.installer._is_recall_configured", + lambda c: True, + ) + monkeypatch.setattr( + "ai_rules.bootstrap.installer.is_command_available", + lambda cmd: True, + ) + monkeypatch.setattr( + "ai_rules.bootstrap.installer.get_effective_install_source", + lambda *args, **kwargs: (ToolSource.LOCAL, "~/dev/recall"), + ) + monkeypatch.setattr( + "ai_rules.bootstrap.installer.install_tool", + lambda *a, **kw: (True, "ok"), + ) + status, msg = ensure_recall_installed( + config=SimpleNamespace(mcp_overrides={"recall": {}}) + ) + assert status == "upgraded" + + def test_uses_passed_config_for_local_source(self, monkeypatch): + captured = {} + + class ConfigWithRecallSource: + mcp_overrides = {"recall": {"command": "recall"}} + + def get_tool_install_source(self, tool_id): + return "local:/tmp/recall" + + monkeypatch.setattr( + "ai_rules.bootstrap.installer._is_recall_configured", + lambda c: True, + ) + monkeypatch.setattr( + "ai_rules.bootstrap.installer.is_command_available", + lambda cmd: True, + ) + + def install_spy(*args, **kwargs): + captured.update(kwargs) + return True, "ok" + + monkeypatch.setattr( + "ai_rules.bootstrap.installer.install_tool", + install_spy, + ) + + status, msg = ensure_recall_installed(config=ConfigWithRecallSource()) + + assert status == "upgraded" + assert msg == "reinstalled from local path" + assert captured["local_path"] == "/tmp/recall" + + +class _MockTraversable: + """Mock for importlib.resources traversable that returns empty mcps.json.""" + + def __init__(self, data: dict): + self._data = data + + def __truediv__(self, other): + return _MockTraversable(self._data) + + def is_file(self): + return True + + def read_text(self): + import json + + return json.dumps(self._data) diff --git a/tests/unit/test_bootstrap_updater.py b/tests/unit/test_bootstrap_updater.py index 2a8fe19..eae5dca 100644 --- a/tests/unit/test_bootstrap_updater.py +++ b/tests/unit/test_bootstrap_updater.py @@ -1,7 +1,11 @@ """Tests for update checking and application utilities.""" +from __future__ import annotations + import subprocess +from collections.abc import Callable + import pytest from ai_rules.bootstrap.installer import UV_NOT_FOUND_ERROR, ToolSource @@ -375,3 +379,47 @@ def spy_run(*args, **kwargs): assert success is True assert was_upgraded is False assert not subprocess_called + + +@pytest.mark.unit +@pytest.mark.bootstrap +class TestIsEnabledFiltering: + """Tests for is_enabled filtering logic in upgrade command.""" + + @staticmethod + def _make_tool( + tool_id: str, is_enabled: Callable[[], bool] | None = None + ) -> ToolSpec: + return ToolSpec( + tool_id=tool_id, + package_name=f"{tool_id}-pkg", + display_name=tool_id, + get_version=lambda: "1.0.0", + is_installed=lambda: True, + is_enabled=is_enabled, + ) + + def test_disabled_tool_excluded_in_default_upgrade(self): + tools = [self._make_tool("recall", is_enabled=lambda: False)] + tools = [t for t in tools if t.is_installed()] + tools = [t for t in tools if t.is_enabled is None or t.is_enabled()] + assert len(tools) == 0 + + def test_enabled_tool_included(self): + tools = [self._make_tool("recall", is_enabled=lambda: True)] + tools = [t for t in tools if t.is_installed()] + tools = [t for t in tools if t.is_enabled is None or t.is_enabled()] + assert len(tools) == 1 + + def test_disabled_tool_still_included_when_explicitly_targeted(self): + """When user passes --only=recall, is_enabled is not checked.""" + resolved_only = "recall" + tools = [self._make_tool("recall", is_enabled=lambda: False)] + tools = [ + t for t in tools if resolved_only is None or t.tool_id == resolved_only + ] + tools = [t for t in tools if t.is_installed()] + # is_enabled check only runs when resolved_only is None + if resolved_only is None: + tools = [t for t in tools if t.is_enabled is None or t.is_enabled()] + assert len(tools) == 1