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
16 changes: 10 additions & 6 deletions src/ai_rules/cli/components/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
Binary file removed src/ai_rules/config/sprout/avatars/duncan.png
Binary file not shown.
Binary file removed src/ai_rules/config/sprout/avatars/irulan.png
Binary file not shown.
Binary file removed src/ai_rules/config/sprout/avatars/jessica.png
Binary file not shown.
Binary file removed src/ai_rules/config/sprout/avatars/mohiam.png
Binary file not shown.
Binary file removed src/ai_rules/config/sprout/avatars/paul.png
Binary file not shown.
Binary file removed src/ai_rules/config/sprout/avatars/stilgar.png
Binary file not shown.
Binary file removed src/ai_rules/config/sprout/avatars/thufir.png
Binary file not shown.
5 changes: 5 additions & 0 deletions src/ai_rules/platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
4 changes: 4 additions & 0 deletions src/ai_rules/targets/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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], ...] = (
Expand All @@ -22,6 +23,7 @@
GooseAgent,
SharedAgent,
StatuslineTool,
SproutTool,
)


Expand All @@ -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
54 changes: 54 additions & 0 deletions src/ai_rules/tools/sprout.py
Original file line number Diff line number Diff line change
@@ -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),
]
189 changes: 189 additions & 0 deletions tests/unit/test_sprout_tool.py
Original file line number Diff line number Diff line change
@@ -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
16 changes: 15 additions & 1 deletion tests/unit/test_target_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Loading