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
28 changes: 15 additions & 13 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 11 additions & 21 deletions src/agentspaces/infrastructure/design.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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: <project>/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:
Expand Down
95 changes: 95 additions & 0 deletions src/agentspaces/infrastructure/resources.py
Original file line number Diff line number Diff line change
@@ -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
46 changes: 17 additions & 29 deletions src/agentspaces/infrastructure/skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__ = [
Expand 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."""
Expand All @@ -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: <project>/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(
Expand All @@ -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(
Expand Down
8 changes: 8 additions & 0 deletions src/agentspaces/templates/__init__.py
Original file line number Diff line number Diff line change
@@ -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.
"""
File renamed without changes.
Loading