diff --git a/src/ai_rules/cli/components/config.py b/src/ai_rules/cli/components/config.py index bc6f3f28..61f432ca 100644 --- a/src/ai_rules/cli/components/config.py +++ b/src/ai_rules/cli/components/config.py @@ -21,7 +21,11 @@ SPECIALIZED_PATH_PARTS = ("/agents/", "/commands/", "/skills/", "/hooks/") -def _is_specialized_path(target: Path) -> bool: +def _is_specialized_path(target_owner: ConfigTarget, target: Path) -> bool: + from ai_rules.agents.base import Agent + + if not isinstance(target_owner, Agent): + return False target_str = target.as_posix() return any(part in target_str for part in SPECIALIZED_PATH_PARTS) @@ -128,7 +132,7 @@ def plan(self, ctx: CliContext) -> ConfigPlan: config_symlinks = [ (tgt, src) for tgt, src in filtered_symlinks - if not _is_specialized_path(tgt) + if not _is_specialized_path(agent, tgt) ] symlink_ops.extend(config_symlinks) @@ -225,7 +229,7 @@ def install(self, ctx: CliContext) -> ComponentResult: config_symlinks = [ (tgt, src) for tgt, src in filtered_symlinks - if not _is_specialized_path(tgt) + if not _is_specialized_path(agent, tgt) ] if user_excluded_count > 0: @@ -296,7 +300,7 @@ def status(self, ctx: CliContext) -> ComponentResult: ] for tgt, source in filtered_symlinks: - if _is_specialized_path(tgt): + if _is_specialized_path(target, tgt): continue if tgt.expanduser() in copy_targets: @@ -329,7 +333,7 @@ def diff(self, ctx: CliContext) -> ComponentResult: target_diffs: list[tuple[Path, Path, str, str, str | None]] = [] for tgt, source in target.get_filtered_symlinks(): - if _is_specialized_path(tgt): + if _is_specialized_path(target, tgt): continue target_path = tgt.expanduser() if target_path in copy_targets: @@ -452,7 +456,7 @@ def uninstall(self, ctx: CliContext) -> ComponentResult: console.print(f"\n[bold]{target.name}[/bold]") for tgt, _source in target.get_filtered_symlinks(): - if _is_specialized_path(tgt): + if _is_specialized_path(target, tgt): continue if tgt.expanduser() in copy_targets: success, message = remove_file_copy(tgt, ctx.yes) diff --git a/src/ai_rules/config/sprout/avatars/duncan.png b/src/ai_rules/config/sprout/avatars/duncan.png deleted file mode 100644 index c6defd9b..00000000 Binary files a/src/ai_rules/config/sprout/avatars/duncan.png and /dev/null differ diff --git a/src/ai_rules/config/sprout/avatars/irulan.png b/src/ai_rules/config/sprout/avatars/irulan.png deleted file mode 100644 index c245a267..00000000 Binary files a/src/ai_rules/config/sprout/avatars/irulan.png and /dev/null differ diff --git a/src/ai_rules/config/sprout/avatars/jessica.png b/src/ai_rules/config/sprout/avatars/jessica.png deleted file mode 100644 index bbcceffb..00000000 Binary files a/src/ai_rules/config/sprout/avatars/jessica.png and /dev/null differ diff --git a/src/ai_rules/config/sprout/avatars/mohiam.png b/src/ai_rules/config/sprout/avatars/mohiam.png deleted file mode 100644 index 1ed5d8fc..00000000 Binary files a/src/ai_rules/config/sprout/avatars/mohiam.png and /dev/null differ diff --git a/src/ai_rules/config/sprout/avatars/paul.png b/src/ai_rules/config/sprout/avatars/paul.png deleted file mode 100644 index 463b88f5..00000000 Binary files a/src/ai_rules/config/sprout/avatars/paul.png and /dev/null differ diff --git a/src/ai_rules/config/sprout/avatars/stilgar.png b/src/ai_rules/config/sprout/avatars/stilgar.png deleted file mode 100644 index f04918a9..00000000 Binary files a/src/ai_rules/config/sprout/avatars/stilgar.png and /dev/null differ diff --git a/src/ai_rules/config/sprout/avatars/thufir.png b/src/ai_rules/config/sprout/avatars/thufir.png deleted file mode 100644 index 7fc40939..00000000 Binary files a/src/ai_rules/config/sprout/avatars/thufir.png and /dev/null differ diff --git a/src/ai_rules/platform.py b/src/ai_rules/platform.py index 80384269..4f699b33 100644 --- a/src/ai_rules/platform.py +++ b/src/ai_rules/platform.py @@ -109,3 +109,8 @@ def get_statusline_config_dir() -> Path: if is_platform(Platform.WINDOWS): return get_appdata_dir() / "claude-statusline" return Path("~/.config/claude-statusline") + + +def get_sprout_packs_dir(dev: bool = False) -> Path: + bundle = "xyz.block.sprout.app.dev" if dev else "xyz.block.sprout.app" + return Path.home() / "Library" / "Application Support" / bundle / "agents" / "packs" diff --git a/src/ai_rules/targets/registry.py b/src/ai_rules/targets/registry.py index 3fc86a27..a79ee407 100644 --- a/src/ai_rules/targets/registry.py +++ b/src/ai_rules/targets/registry.py @@ -12,6 +12,7 @@ from ai_rules.agents.shared import SharedAgent from ai_rules.config import Config from ai_rules.targets.base import ConfigTarget +from ai_rules.tools.sprout import SproutTool from ai_rules.tools.statusline import StatuslineTool TARGET_CLASSES: tuple[type[ConfigTarget], ...] = ( @@ -22,6 +23,7 @@ GooseAgent, SharedAgent, StatuslineTool, + SproutTool, ) @@ -33,5 +35,7 @@ def get_targets(config_dir: Path, config: Config) -> list[ConfigTarget]: for target_class in TARGET_CLASSES: if is_platform(Platform.WINDOWS) and target_class is AmpAgent: continue + if not is_platform(Platform.MACOS) and target_class is SproutTool: + continue targets.append(target_class(config_dir, config)) return targets diff --git a/src/ai_rules/tools/sprout.py b/src/ai_rules/tools/sprout.py new file mode 100644 index 00000000..d0328bba --- /dev/null +++ b/src/ai_rules/tools/sprout.py @@ -0,0 +1,54 @@ +"""Sprout persona pack symlink tool.""" + +from __future__ import annotations + +import json + +from functools import cached_property +from pathlib import Path + +from ai_rules.platform import ( + Platform, + get_sprout_packs_dir, + is_platform, +) +from ai_rules.tools.base import Tool + + +class SproutTool(Tool): + """Manages Sprout persona pack symlinks into production and dev data directories.""" + + name = "Sprout" + tool_id = "sprout" + config_file_name = "" + config_file_format = "" + + @property + def needs_cache(self) -> bool: + return False + + def _read_pack_id(self) -> str | None: + manifest = self.config_dir / "sprout" / ".plugin" / "plugin.json" + if not manifest.is_file(): + return None + try: + data = json.loads(manifest.read_text()) + pack_id = data.get("id") + return pack_id if isinstance(pack_id, str) and pack_id else None + except json.JSONDecodeError, OSError: + return None + + @cached_property + def symlinks(self) -> list[tuple[Path, Path]]: + if not is_platform(Platform.MACOS): + return [] + source = self.config_dir / "sprout" + if not source.exists(): + return [] + pack_id = self._read_pack_id() + if not pack_id: + return [] + return [ + (get_sprout_packs_dir(dev=False) / pack_id, source), + (get_sprout_packs_dir(dev=True) / pack_id, source), + ] diff --git a/tests/unit/test_sprout_tool.py b/tests/unit/test_sprout_tool.py new file mode 100644 index 00000000..36de2513 --- /dev/null +++ b/tests/unit/test_sprout_tool.py @@ -0,0 +1,189 @@ +"""Tests for SproutTool and _is_specialized_path.""" + +import json + +from pathlib import Path + +import pytest + +from ai_rules.agents.claude import ClaudeAgent +from ai_rules.cli.components.config import _is_specialized_path +from ai_rules.config import Config +from ai_rules.platform import Platform +from ai_rules.tools.sprout import SproutTool + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _create_pack_manifest(sprout_dir: Path, pack_id: str = "com.test.my-pack") -> None: + plugin_dir = sprout_dir / ".plugin" + plugin_dir.mkdir(parents=True, exist_ok=True) + (plugin_dir / "plugin.json").write_text(json.dumps({"id": pack_id})) + + +# --------------------------------------------------------------------------- +# SproutTool.symlinks +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_sprout_tool_symlinks_on_macos( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr("ai_rules.platform.detect_platform", lambda: Platform.MACOS) + sprout_dir = tmp_path / "sprout" + sprout_dir.mkdir() + _create_pack_manifest(sprout_dir) + tool = SproutTool(tmp_path, Config()) + + links = tool.symlinks + + assert len(links) == 2 + + +@pytest.mark.unit +def test_sprout_tool_symlinks_empty_on_linux( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # mock_platform_for_tests autouse fixture already sets Linux, but be explicit. + monkeypatch.setattr("ai_rules.platform.detect_platform", lambda: Platform.LINUX) + sprout_dir = tmp_path / "sprout" + sprout_dir.mkdir() + _create_pack_manifest(sprout_dir) + tool = SproutTool(tmp_path, Config()) + + assert tool.symlinks == [] + + +@pytest.mark.unit +def test_sprout_tool_symlinks_empty_when_source_missing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr("ai_rules.platform.detect_platform", lambda: Platform.MACOS) + # Deliberately do NOT create tmp_path / "sprout" + tool = SproutTool(tmp_path, Config()) + + assert tool.symlinks == [] + + +@pytest.mark.unit +def test_sprout_tool_target_paths_use_correct_bundles( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mock_home: Path +) -> None: + monkeypatch.setattr("ai_rules.platform.detect_platform", lambda: Platform.MACOS) + sprout_dir = tmp_path / "sprout" + sprout_dir.mkdir() + _create_pack_manifest(sprout_dir, pack_id="com.test.my-pack") + tool = SproutTool(tmp_path, Config()) + + links = tool.symlinks + assert len(links) == 2 + + target_prod, source_prod = links[0] + target_dev, source_dev = links[1] + + assert "xyz.block.sprout.app" in target_prod.as_posix() + assert "xyz.block.sprout.app.dev" not in target_prod.as_posix() + assert "xyz.block.sprout.app.dev" in target_dev.as_posix() + assert target_prod.name == "com.test.my-pack" + assert target_dev.name == "com.test.my-pack" + + +@pytest.mark.unit +def test_sprout_tool_symlink_sources_point_to_config_dir_sprout( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr("ai_rules.platform.detect_platform", lambda: Platform.MACOS) + sprout_dir = tmp_path / "sprout" + sprout_dir.mkdir() + _create_pack_manifest(sprout_dir) + tool = SproutTool(tmp_path, Config()) + + for _target, source in tool.symlinks: + assert source == sprout_dir + + +@pytest.mark.unit +def test_sprout_tool_needs_cache_always_false(tmp_path: Path) -> None: + tool = SproutTool(tmp_path, Config()) + + assert tool.needs_cache is False + + +@pytest.mark.unit +def test_sprout_tool_symlinks_empty_when_manifest_missing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr("ai_rules.platform.detect_platform", lambda: Platform.MACOS) + # sprout/ dir exists but no .plugin/plugin.json + (tmp_path / "sprout").mkdir() + tool = SproutTool(tmp_path, Config()) + + assert tool.symlinks == [] + + +@pytest.mark.unit +def test_sprout_tool_symlinks_empty_when_manifest_malformed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr("ai_rules.platform.detect_platform", lambda: Platform.MACOS) + sprout_dir = tmp_path / "sprout" + sprout_dir.mkdir() + plugin_dir = sprout_dir / ".plugin" + plugin_dir.mkdir() + (plugin_dir / "plugin.json").write_text("not valid json {{{{") + tool = SproutTool(tmp_path, Config()) + + assert tool.symlinks == [] + + +@pytest.mark.unit +def test_sprout_tool_symlinks_use_pack_id_from_manifest( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mock_home: Path +) -> None: + monkeypatch.setattr("ai_rules.platform.detect_platform", lambda: Platform.MACOS) + sprout_dir = tmp_path / "sprout" + sprout_dir.mkdir() + _create_pack_manifest(sprout_dir, pack_id="com.example.custom-pack") + tool = SproutTool(tmp_path, Config()) + + links = tool.symlinks + assert len(links) == 2 + + for target, _source in links: + assert target.name == "com.example.custom-pack" + + +# --------------------------------------------------------------------------- +# _is_specialized_path +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_is_specialized_path_returns_false_for_tool(tmp_path: Path) -> None: + tool = SproutTool(tmp_path, Config()) + path_with_agents = tmp_path / "agents" / "something.md" + + assert _is_specialized_path(tool, path_with_agents) is False + + +@pytest.mark.unit +def test_is_specialized_path_returns_true_for_agent_with_agents_path( + tmp_path: Path, +) -> None: + agent = ClaudeAgent(tmp_path, Config()) + agents_path = Path("~/.claude/agents/foo.md") + + assert _is_specialized_path(agent, agents_path) is True + + +@pytest.mark.unit +def test_is_specialized_path_returns_false_for_agent_without_agents_path( + tmp_path: Path, +) -> None: + agent = ClaudeAgent(tmp_path, Config()) + settings_path = Path("~/.claude/settings.json") + + assert _is_specialized_path(agent, settings_path) is False diff --git a/tests/unit/test_target_registry.py b/tests/unit/test_target_registry.py index 52812c65..019551c6 100644 --- a/tests/unit/test_target_registry.py +++ b/tests/unit/test_target_registry.py @@ -27,7 +27,9 @@ def test_target_registry_returns_unique_targets_in_lifecycle_order( "statusline", ] assert len(target_ids) == len(set(target_ids)) - assert len(TARGET_CLASSES) == len(target_ids) + # SproutTool is macOS-only, so TARGET_CLASSES has one more entry than the + # Linux-filtered list. + assert len(TARGET_CLASSES) == len(target_ids) + 1 @pytest.mark.unit @@ -41,3 +43,15 @@ def test_get_targets_excludes_amp_on_windows( target_classes = [type(t) for t in targets] assert AmpAgent not in target_classes + + +@pytest.mark.unit +def test_get_targets_includes_sprout_on_macos( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr("ai_rules.platform.detect_platform", lambda: Platform.MACOS) + config = Config() + + target_ids = [target.target_id for target in get_targets(tmp_path, config)] + + assert "sprout" in target_ids