diff --git a/CLAUDE.md b/CLAUDE.md index 092481d..c235013 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,19 +28,21 @@ src/agentspaces/ │ └── workspace/ # Workspace management │ ├── service.py # Business logic │ └── worktree.py # Git worktree operations -└── infrastructure/ # Shared utilities - ├── git.py # Git subprocess wrapper - ├── naming.py # Name generation - ├── paths.py # Path resolution - ├── design.py # Template rendering - ├── frontmatter.py # YAML frontmatter parser - └── logging.py # structlog config - -templates/skeleton/ # Project skeleton templates -├── CLAUDE.md # Agent constitution template -├── TODO.md # Task list template -├── .claude/ # Agent/command templates -└── docs/ # ADR and design templates +├── infrastructure/ # Shared utilities +│ ├── git.py # Git subprocess wrapper +│ ├── naming.py # Name generation +│ ├── paths.py # Path resolution +│ ├── design.py # Template rendering +│ ├── resources.py # Package resource access +│ ├── frontmatter.py # YAML frontmatter parser +│ └── logging.py # structlog config +└── templates/ # Bundled project templates + ├── skeleton/ # Project skeleton templates + │ ├── CLAUDE.md # Agent constitution template + │ ├── TODO.md # Task list template + │ ├── .claude/ # Agent/command templates + │ └── docs/ # ADR and design templates + └── skills/ # Skill templates ``` ## Architecture diff --git a/pyproject.toml b/pyproject.toml index 2236299..615f584 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,16 @@ Repository = "https://github.com/ckrough/agentspaces" [tool.hatch.build.targets.wheel] packages = ["src/agentspaces"] +[tool.hatch.build.targets.wheel.sources] +"src" = "" + +# Include templates as package data +[tool.hatch.build] +include = [ + "src/agentspaces/**/*.py", + "src/agentspaces/templates/**/*.md", +] + [tool.ruff] target-version = "py312" line-length = 88 diff --git a/src/agentspaces/infrastructure/design.py b/src/agentspaces/infrastructure/design.py index a7c6d0b..40b14d7 100644 --- a/src/agentspaces/infrastructure/design.py +++ b/src/agentspaces/infrastructure/design.py @@ -12,14 +12,20 @@ from __future__ import annotations from dataclasses import dataclass -from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any import structlog import yaml from jinja2 import Environment, FileSystemLoader, TemplateNotFound, UndefinedError from agentspaces.infrastructure.frontmatter import FrontmatterError, parse_frontmatter +from agentspaces.infrastructure.resources import ( + ResourceError, + get_skeleton_templates_dir, +) + +if TYPE_CHECKING: + from pathlib import Path __all__ = [ "DesignError", @@ -70,26 +76,10 @@ def _get_design_template_dir() -> Path: Raises: DesignError: If templates directory not found or invalid. """ - # Start from package root (4 levels up from this file) - package_root = Path(__file__).parent.parent.parent.parent - templates_dir = package_root / "templates" / "skeleton" - - if not templates_dir.exists(): - raise DesignError( - "Skeleton templates directory not found. " - "Expected at: /templates/skeleton/" - ) - - # Ensure templates_dir is within package root (prevent symlink attacks) try: - resolved = templates_dir.resolve() - package_resolved = package_root.resolve() - if not str(resolved).startswith(str(package_resolved)): - raise DesignError("Templates directory escapes package root") - except OSError as e: - raise DesignError(f"Cannot resolve templates directory: {e}") from e - - return templates_dir + return get_skeleton_templates_dir() + except ResourceError as e: + raise DesignError(str(e)) from e def _parse_template_metadata(path: Path) -> DesignTemplate: diff --git a/src/agentspaces/infrastructure/resources.py b/src/agentspaces/infrastructure/resources.py new file mode 100644 index 0000000..bef2a6f --- /dev/null +++ b/src/agentspaces/infrastructure/resources.py @@ -0,0 +1,95 @@ +"""Package resource access for bundled templates. + +Provides a unified API for accessing template files that works correctly +whether running from source or from an installed package. + +Uses importlib.resources (Python 3.9+) which is the standard way to access +non-code files bundled with a Python package. +""" + +from __future__ import annotations + +from importlib.resources import files +from pathlib import Path + +__all__ = [ + "ResourceError", + "get_skeleton_templates_dir", + "get_skills_templates_dir", +] + + +class ResourceError(Exception): + """Raised when package resources cannot be accessed.""" + + +def _get_templates_dir() -> Path: + """Get the templates directory from package resources. + + Returns: + Path to the templates directory. + + Raises: + ResourceError: If the templates directory cannot be accessed. + """ + try: + # Access the templates directory as a package resource + templates_ref = files("agentspaces.templates") + + # Convert to a path - works for both source and installed packages + # Using as_file context manager ensures proper resource extraction + # for zip-imported packages, but for simplicity we use the traversable + # interface which works for most use cases + templates_path = Path(str(templates_ref)) + + if not templates_path.exists(): + raise ResourceError( + f"Templates directory not found at package location: {templates_path}" + ) + + return templates_path + + except ModuleNotFoundError as e: + raise ResourceError( + "Cannot access package templates. " + "Ensure agentspaces is installed correctly." + ) from e + except TypeError as e: + # Happens if files() returns something that can't be converted to Path + raise ResourceError(f"Cannot resolve templates path: {e}") from e + + +def get_skeleton_templates_dir() -> Path: + """Get the skeleton templates directory. + + Returns: + Path to the templates/skeleton directory. + + Raises: + ResourceError: If the directory cannot be accessed or doesn't exist. + """ + templates_dir = _get_templates_dir() + skeleton_dir = templates_dir / "skeleton" + + if not skeleton_dir.exists(): + raise ResourceError(f"Skeleton templates directory not found: {skeleton_dir}") + + return skeleton_dir + + +def get_skills_templates_dir() -> Path: + """Get the skills templates directory. + + Returns: + Path to the templates/skills directory. + + Raises: + ResourceError: If the directory cannot be accessed or doesn't exist. + """ + templates_dir = _get_templates_dir() + skills_dir = templates_dir / "skills" + + if not skills_dir.exists(): + raise ResourceError(f"Skills templates directory not found: {skills_dir}") + + return skills_dir diff --git a/src/agentspaces/infrastructure/skills.py b/src/agentspaces/infrastructure/skills.py index 4631bb3..83efa29 100644 --- a/src/agentspaces/infrastructure/skills.py +++ b/src/agentspaces/infrastructure/skills.py @@ -6,13 +6,16 @@ from __future__ import annotations import re -from pathlib import Path from typing import TYPE_CHECKING import structlog from jinja2 import Environment, FileSystemLoader, TemplateNotFound +from agentspaces.infrastructure.resources import ResourceError, get_skills_templates_dir + if TYPE_CHECKING: + from pathlib import Path + from agentspaces.infrastructure.metadata import WorkspaceMetadata __all__ = [ @@ -22,9 +25,6 @@ logger = structlog.get_logger() -# Expected template file for validation -_EXPECTED_TEMPLATE = "skills/workspace-context/SKILL.md" - class SkillError(Exception): """Raised when skill operations fail.""" @@ -51,39 +51,27 @@ def _sanitize_for_markdown(text: str) -> str: def _get_template_dir() -> Path: - """Get and validate the templates directory path. + """Get and validate the skills templates directory path. Returns: - Path to the validated templates directory. + Path to the validated templates/skills directory. Raises: SkillError: If templates directory not found or invalid. """ - # Start from package root (4 levels up from this file) - package_root = Path(__file__).parent.parent.parent.parent - templates_dir = package_root / "templates" - - if not templates_dir.exists(): - raise SkillError( - "Templates directory not found. " - "Expected at: /templates/skills/workspace-context/" - ) + try: + skills_dir = get_skills_templates_dir() + except ResourceError as e: + raise SkillError(str(e)) from e # Validate expected template structure exists - expected_template = templates_dir / _EXPECTED_TEMPLATE + expected_template = skills_dir / "workspace-context" / "SKILL.md" if not expected_template.exists(): - raise SkillError(f"Template structure invalid: missing {_EXPECTED_TEMPLATE}") - - # Ensure templates_dir is within package root (prevent symlink attacks) - try: - resolved = templates_dir.resolve() - package_resolved = package_root.resolve() - if not str(resolved).startswith(str(package_resolved)): - raise SkillError("Templates directory escapes package root") - except OSError as e: - raise SkillError(f"Cannot resolve templates directory: {e}") from e + raise SkillError( + "Template structure invalid: missing workspace-context/SKILL.md" + ) - return templates_dir + return skills_dir def generate_workspace_context_skill( @@ -106,8 +94,8 @@ def generate_workspace_context_skill( SkillError: If generation fails. """ try: - template_dir = _get_template_dir() - skill_template_dir = template_dir / "skills" / "workspace-context" + skills_dir = _get_template_dir() + skill_template_dir = skills_dir / "workspace-context" if not skill_template_dir.exists(): raise SkillError( diff --git a/src/agentspaces/templates/__init__.py b/src/agentspaces/templates/__init__.py new file mode 100644 index 0000000..3d9f1ae --- /dev/null +++ b/src/agentspaces/templates/__init__.py @@ -0,0 +1,8 @@ +"""Bundled template resources for agentspaces. + +This package contains template files for: +- skeleton/: Project skeleton templates (CLAUDE.md, TODO.md, docs, etc.) +- skills/: Agent skill templates (workspace-context, etc.) + +Templates are accessed via the infrastructure.resources module. +""" diff --git a/templates/skeleton/.claude/agents/README.md b/src/agentspaces/templates/skeleton/.claude/agents/README.md similarity index 100% rename from templates/skeleton/.claude/agents/README.md rename to src/agentspaces/templates/skeleton/.claude/agents/README.md diff --git a/templates/skeleton/.claude/commands/README.md b/src/agentspaces/templates/skeleton/.claude/commands/README.md similarity index 100% rename from templates/skeleton/.claude/commands/README.md rename to src/agentspaces/templates/skeleton/.claude/commands/README.md diff --git a/templates/skeleton/CLAUDE.md b/src/agentspaces/templates/skeleton/CLAUDE.md similarity index 100% rename from templates/skeleton/CLAUDE.md rename to src/agentspaces/templates/skeleton/CLAUDE.md diff --git a/templates/skeleton/README.md b/src/agentspaces/templates/skeleton/README.md similarity index 100% rename from templates/skeleton/README.md rename to src/agentspaces/templates/skeleton/README.md diff --git a/templates/skeleton/TODO.md b/src/agentspaces/templates/skeleton/TODO.md similarity index 100% rename from templates/skeleton/TODO.md rename to src/agentspaces/templates/skeleton/TODO.md diff --git a/templates/skeleton/docs/adr/000-template.md b/src/agentspaces/templates/skeleton/docs/adr/000-template.md similarity index 100% rename from templates/skeleton/docs/adr/000-template.md rename to src/agentspaces/templates/skeleton/docs/adr/000-template.md diff --git a/templates/skeleton/docs/adr/001-example.md b/src/agentspaces/templates/skeleton/docs/adr/001-example.md similarity index 100% rename from templates/skeleton/docs/adr/001-example.md rename to src/agentspaces/templates/skeleton/docs/adr/001-example.md diff --git a/templates/skeleton/docs/design/architecture.md b/src/agentspaces/templates/skeleton/docs/design/architecture.md similarity index 100% rename from templates/skeleton/docs/design/architecture.md rename to src/agentspaces/templates/skeleton/docs/design/architecture.md diff --git a/templates/skeleton/docs/design/development-standards.md b/src/agentspaces/templates/skeleton/docs/design/development-standards.md similarity index 100% rename from templates/skeleton/docs/design/development-standards.md rename to src/agentspaces/templates/skeleton/docs/design/development-standards.md diff --git a/templates/skeleton/docs/planning/deployment.md b/src/agentspaces/templates/skeleton/docs/planning/deployment.md similarity index 100% rename from templates/skeleton/docs/planning/deployment.md rename to src/agentspaces/templates/skeleton/docs/planning/deployment.md diff --git a/templates/skills/project-standards/SKILL.md b/src/agentspaces/templates/skills/project-standards/SKILL.md similarity index 100% rename from templates/skills/project-standards/SKILL.md rename to src/agentspaces/templates/skills/project-standards/SKILL.md diff --git a/templates/skills/workspace-context/SKILL.md b/src/agentspaces/templates/skills/workspace-context/SKILL.md similarity index 100% rename from templates/skills/workspace-context/SKILL.md rename to src/agentspaces/templates/skills/workspace-context/SKILL.md diff --git a/tests/unit/infrastructure/test_resources.py b/tests/unit/infrastructure/test_resources.py new file mode 100644 index 0000000..673e70f --- /dev/null +++ b/tests/unit/infrastructure/test_resources.py @@ -0,0 +1,168 @@ +"""Tests for the package resource access module.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import patch + +import pytest + +from agentspaces.infrastructure.resources import ( + ResourceError, + get_skeleton_templates_dir, + get_skills_templates_dir, +) + +if TYPE_CHECKING: + from pathlib import Path + + +class TestGetSkeletonTemplatesDir: + """Tests for get_skeleton_templates_dir function.""" + + def test_returns_skeleton_directory(self) -> None: + """Should return path to skeleton templates directory.""" + result = get_skeleton_templates_dir() + + assert result.exists() + assert result.name == "skeleton" + assert result.is_dir() + + def test_skeleton_contains_expected_templates(self) -> None: + """Should contain expected template files.""" + result = get_skeleton_templates_dir() + + # Check for key template files + assert (result / "CLAUDE.md").exists() + assert (result / "TODO.md").exists() + assert (result / "README.md").exists() + + def test_raises_error_when_skeleton_missing(self, tmp_path: Path) -> None: + """Should raise ResourceError when skeleton directory doesn't exist.""" + # Create a mock templates directory without skeleton + mock_templates = tmp_path / "templates" + mock_templates.mkdir() + + with patch( + "agentspaces.infrastructure.resources._get_templates_dir", + return_value=mock_templates, + ): + with pytest.raises(ResourceError) as exc_info: + get_skeleton_templates_dir() + + assert "Skeleton templates directory not found" in str(exc_info.value) + + +class TestGetSkillsTemplatesDir: + """Tests for get_skills_templates_dir function.""" + + def test_returns_skills_directory(self) -> None: + """Should return path to skills templates directory.""" + result = get_skills_templates_dir() + + assert result.exists() + assert result.name == "skills" + assert result.is_dir() + + def test_skills_contains_expected_templates(self) -> None: + """Should contain expected skill templates.""" + result = get_skills_templates_dir() + + # Check for workspace-context skill + workspace_context = result / "workspace-context" + assert workspace_context.exists() + assert (workspace_context / "SKILL.md").exists() + + def test_raises_error_when_skills_missing(self, tmp_path: Path) -> None: + """Should raise ResourceError when skills directory doesn't exist.""" + # Create a mock templates directory without skills + mock_templates = tmp_path / "templates" + mock_templates.mkdir() + + with patch( + "agentspaces.infrastructure.resources._get_templates_dir", + return_value=mock_templates, + ): + with pytest.raises(ResourceError) as exc_info: + get_skills_templates_dir() + + assert "Skills templates directory not found" in str(exc_info.value) + + +class TestGetTemplatesDir: + """Tests for _get_templates_dir internal function.""" + + def test_raises_error_on_module_not_found(self) -> None: + """Should raise ResourceError when package module not found.""" + with patch( + "agentspaces.infrastructure.resources.files", + side_effect=ModuleNotFoundError("No module named 'agentspaces.templates'"), + ): + with pytest.raises(ResourceError) as exc_info: + get_skeleton_templates_dir() + + assert "Cannot access package templates" in str(exc_info.value) + assert "installed correctly" in str(exc_info.value) + + def test_raises_error_on_type_error(self) -> None: + """Should raise ResourceError when files() returns unconvertible type.""" + + class BadTraversable: + """Mock that raises TypeError when converted to string.""" + + def __str__(self) -> str: + raise TypeError("Cannot convert to string") + + with patch( + "agentspaces.infrastructure.resources.files", + return_value=BadTraversable(), + ): + with pytest.raises(ResourceError) as exc_info: + get_skeleton_templates_dir() + + assert "Cannot resolve templates path" in str(exc_info.value) + + def test_raises_error_when_templates_dir_missing(self, tmp_path: Path) -> None: + """Should raise ResourceError when templates directory doesn't exist.""" + nonexistent_path = tmp_path / "nonexistent" + + class FakeTraversable: + """Mock that returns a nonexistent path.""" + + def __init__(self, path: Path) -> None: + self._path = path + + def __str__(self) -> str: + return str(self._path) + + with patch( + "agentspaces.infrastructure.resources.files", + return_value=FakeTraversable(nonexistent_path), + ): + with pytest.raises(ResourceError) as exc_info: + get_skeleton_templates_dir() + + assert "Templates directory not found at package location" in str( + exc_info.value + ) + + +class TestResourceError: + """Tests for ResourceError exception.""" + + def test_error_message(self) -> None: + """Should store and display error message.""" + error = ResourceError("Test error message") + + assert str(error) == "Test error message" + + def test_is_exception(self) -> None: + """Should be a proper Exception subclass.""" + error = ResourceError("Test") + + assert isinstance(error, Exception) + + def test_can_be_raised_and_caught(self) -> None: + """Should be raisable and catchable.""" + with pytest.raises(ResourceError): + raise ResourceError("Test error")