Implement CRIT-006: Config system with YAML validation
- ProjectConfig dataclass for type-safe project settings - Channel-to-project mapping with required fields - Environment variable expansion ($HOME, custom vars) - System prompt loading from file or inline - Tool restriction per project - Model selection per project - Config class with comprehensive YAML loading - Default config: ~/.claude-coordinator/config.yaml - Full validation with detailed error messages - Duplicate channel ID detection - Project directory existence checks - Tool name validation (Bash, Read, Write, Edit, Glob, Grep, WebSearch, WebFetch) - Model validation (sonnet, opus, haiku) - System prompt file validation - Example config with all options documented - Full example with all fields - Minimal example with defaults - Environment variable usage - Read-only project example - Inline comments explaining all options - Comprehensive test suite: 25/25 tests passing - ProjectConfig creation and defaults - Environment variable expansion - System prompt loading (inline and file) - YAML loading and parsing - Required field validation - Duplicate channel ID detection - Invalid tool name detection - Missing directory warnings - Invalid model detection - Channel ID lookup - Numeric channel ID conversion Progress: 6/6 Week 1 tasks complete Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6b56463779
commit
b2ff6f19f2
@ -1,56 +1,300 @@
|
|||||||
"""Configuration management for Claude Discord Coordinator.
|
"""Configuration management for Claude Discord Coordinator.
|
||||||
|
|
||||||
Loads and validates YAML configuration files with support for environment
|
Loads and validates YAML configuration files mapping Discord channels to
|
||||||
variable substitution.
|
project configurations with tool restrictions and custom prompts.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
import os
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
# Known valid tool names from Claude API
|
||||||
|
VALID_TOOLS = {
|
||||||
|
"Bash",
|
||||||
|
"Read",
|
||||||
|
"Write",
|
||||||
|
"Edit",
|
||||||
|
"Glob",
|
||||||
|
"Grep",
|
||||||
|
"WebSearch",
|
||||||
|
"WebFetch",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProjectConfig:
|
||||||
|
"""Configuration for a single project mapped to a Discord channel.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
name: Project identifier/name.
|
||||||
|
channel_id: Discord channel ID (as string).
|
||||||
|
project_dir: Absolute path to project directory.
|
||||||
|
allowed_tools: List of tool names allowed for this project.
|
||||||
|
system_prompt: Optional inline system prompt.
|
||||||
|
system_prompt_file: Optional path to system prompt file.
|
||||||
|
model: Model to use (sonnet, opus, haiku).
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
channel_id: str
|
||||||
|
project_dir: str
|
||||||
|
allowed_tools: List[str] = field(default_factory=lambda: list(VALID_TOOLS))
|
||||||
|
system_prompt: Optional[str] = None
|
||||||
|
system_prompt_file: Optional[Path] = None
|
||||||
|
model: str = "sonnet"
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
"""Validate and normalize configuration after initialization."""
|
||||||
|
# Expand environment variables in paths
|
||||||
|
self.project_dir = os.path.expandvars(self.project_dir)
|
||||||
|
if self.system_prompt_file:
|
||||||
|
self.system_prompt_file = Path(os.path.expandvars(str(self.system_prompt_file)))
|
||||||
|
|
||||||
|
def get_system_prompt(self) -> Optional[str]:
|
||||||
|
"""Get the system prompt, loading from file if necessary.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
System prompt text or None if not configured.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileNotFoundError: If system_prompt_file is set but doesn't exist.
|
||||||
|
"""
|
||||||
|
if self.system_prompt:
|
||||||
|
return self.system_prompt
|
||||||
|
|
||||||
|
if self.system_prompt_file:
|
||||||
|
if not self.system_prompt_file.exists():
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"System prompt file not found: {self.system_prompt_file}"
|
||||||
|
)
|
||||||
|
return self.system_prompt_file.read_text()
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
"""Configuration manager for bot settings.
|
"""Configuration manager for bot settings.
|
||||||
|
|
||||||
|
Loads YAML configuration mapping Discord channels to project settings,
|
||||||
|
validates configuration structure, and provides lookup methods.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
config_path: Path to the YAML configuration file.
|
config_path: Path to the YAML configuration file.
|
||||||
data: Parsed configuration data.
|
projects: Dictionary mapping project names to ProjectConfig objects.
|
||||||
|
_channel_map: Internal mapping of channel IDs to project names.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, config_path: Path):
|
def __init__(self, config_path: Optional[str] = None):
|
||||||
"""Initialize configuration from YAML file.
|
"""Initialize configuration manager.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config_path: Path to the configuration file.
|
config_path: Path to config file. If None, uses default location
|
||||||
|
~/.claude-coordinator/config.yaml
|
||||||
"""
|
"""
|
||||||
self.config_path = config_path
|
if config_path is None:
|
||||||
self.data: Dict[str, Any] = {}
|
config_path = os.path.expanduser("~/.claude-coordinator/config.yaml")
|
||||||
|
|
||||||
|
self.config_path = Path(config_path)
|
||||||
|
self.projects: Dict[str, ProjectConfig] = {}
|
||||||
|
self._channel_map: Dict[str, str] = {}
|
||||||
|
|
||||||
def load(self) -> None:
|
def load(self) -> None:
|
||||||
"""Load configuration from YAML file.
|
"""Load configuration from YAML file.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
FileNotFoundError: If config file does not exist.
|
FileNotFoundError: If config file does not exist.
|
||||||
yaml.YAMLError: If config file is invalid YAML.
|
yaml.YAMLError: If config file is invalid YAML.
|
||||||
|
ValueError: If configuration validation fails.
|
||||||
"""
|
"""
|
||||||
with open(self.config_path, "r") as f:
|
if not self.config_path.exists():
|
||||||
self.data = yaml.safe_load(f)
|
raise FileNotFoundError(
|
||||||
|
f"Configuration file not found: {self.config_path}\n"
|
||||||
|
f"Create a config file at this location or specify a different path."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(self.config_path, "r") as f:
|
||||||
|
data = yaml.safe_load(f)
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
raise yaml.YAMLError(
|
||||||
|
f"Invalid YAML in configuration file {self.config_path}:\n{e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError("Configuration file must contain a YAML dictionary")
|
||||||
|
|
||||||
|
if "projects" not in data:
|
||||||
|
raise ValueError("Configuration must contain a 'projects' section")
|
||||||
|
|
||||||
|
projects_data = data["projects"]
|
||||||
|
if not isinstance(projects_data, dict):
|
||||||
|
raise ValueError("'projects' section must be a dictionary")
|
||||||
|
|
||||||
|
# Parse each project configuration
|
||||||
|
self.projects = {}
|
||||||
|
self._channel_map = {}
|
||||||
|
|
||||||
|
for project_name, project_data in projects_data.items():
|
||||||
|
if not isinstance(project_data, dict):
|
||||||
|
raise ValueError(
|
||||||
|
f"Project '{project_name}' configuration must be a dictionary"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create ProjectConfig
|
||||||
|
try:
|
||||||
|
config = self._parse_project_config(project_name, project_data)
|
||||||
|
self.projects[project_name] = config
|
||||||
|
self._channel_map[config.channel_id] = project_name
|
||||||
|
except (ValueError, KeyError) as e:
|
||||||
|
raise ValueError(f"Error in project '{project_name}': {e}")
|
||||||
|
|
||||||
|
# Validate configuration
|
||||||
|
errors = self.validate()
|
||||||
|
if errors:
|
||||||
|
raise ValueError(
|
||||||
|
f"Configuration validation failed:\n" + "\n".join(f" - {e}" for e in errors)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_project_config(self, name: str, data: Dict[str, Any]) -> ProjectConfig:
|
||||||
|
"""Parse a single project configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Project name.
|
||||||
|
data: Project configuration dictionary.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ProjectConfig object.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyError: If required fields are missing.
|
||||||
|
ValueError: If field values are invalid.
|
||||||
|
"""
|
||||||
|
# Required fields
|
||||||
|
if "channel_id" not in data:
|
||||||
|
raise KeyError("'channel_id' is required")
|
||||||
|
if "project_dir" not in data:
|
||||||
|
raise KeyError("'project_dir' is required")
|
||||||
|
|
||||||
|
channel_id = str(data["channel_id"]) # Ensure string
|
||||||
|
project_dir = data["project_dir"]
|
||||||
|
|
||||||
|
# Optional fields with defaults
|
||||||
|
allowed_tools = data.get("allowed_tools", list(VALID_TOOLS))
|
||||||
|
system_prompt = data.get("system_prompt")
|
||||||
|
system_prompt_file = data.get("system_prompt_file")
|
||||||
|
model = data.get("model", "sonnet")
|
||||||
|
|
||||||
|
# Convert system_prompt_file to Path if present
|
||||||
|
if system_prompt_file:
|
||||||
|
system_prompt_file = Path(system_prompt_file)
|
||||||
|
|
||||||
|
return ProjectConfig(
|
||||||
|
name=name,
|
||||||
|
channel_id=channel_id,
|
||||||
|
project_dir=project_dir,
|
||||||
|
allowed_tools=allowed_tools,
|
||||||
|
system_prompt=system_prompt,
|
||||||
|
system_prompt_file=system_prompt_file,
|
||||||
|
model=model,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_project_by_channel(self, channel_id: str) -> Optional[ProjectConfig]:
|
||||||
|
"""Get project configuration by Discord channel ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
channel_id: Discord channel ID as string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ProjectConfig if channel is mapped, None otherwise.
|
||||||
|
"""
|
||||||
|
project_name = self._channel_map.get(str(channel_id))
|
||||||
|
if project_name:
|
||||||
|
return self.projects[project_name]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_all_projects(self) -> Dict[str, ProjectConfig]:
|
||||||
|
"""Get all configured projects.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping project names to ProjectConfig objects.
|
||||||
|
"""
|
||||||
|
return self.projects.copy()
|
||||||
|
|
||||||
|
def validate(self) -> List[str]:
|
||||||
|
"""Validate configuration structure and values.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of validation error messages (empty if valid).
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Check for duplicate channel IDs
|
||||||
|
channel_ids: Dict[str, List[str]] = {}
|
||||||
|
for project_name, config in self.projects.items():
|
||||||
|
channel_id = config.channel_id
|
||||||
|
if channel_id not in channel_ids:
|
||||||
|
channel_ids[channel_id] = []
|
||||||
|
channel_ids[channel_id].append(project_name)
|
||||||
|
|
||||||
|
for channel_id, projects in channel_ids.items():
|
||||||
|
if len(projects) > 1:
|
||||||
|
errors.append(
|
||||||
|
f"Duplicate channel_id '{channel_id}' in projects: {', '.join(projects)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate each project
|
||||||
|
for project_name, config in self.projects.items():
|
||||||
|
# Validate project directory exists
|
||||||
|
project_path = Path(config.project_dir)
|
||||||
|
if not project_path.exists():
|
||||||
|
errors.append(
|
||||||
|
f"Project '{project_name}': directory does not exist: {config.project_dir}"
|
||||||
|
)
|
||||||
|
elif not project_path.is_dir():
|
||||||
|
errors.append(
|
||||||
|
f"Project '{project_name}': path is not a directory: {config.project_dir}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate tool names
|
||||||
|
invalid_tools = set(config.allowed_tools) - VALID_TOOLS
|
||||||
|
if invalid_tools:
|
||||||
|
errors.append(
|
||||||
|
f"Project '{project_name}': invalid tool names: {', '.join(sorted(invalid_tools))}\n"
|
||||||
|
f" Valid tools: {', '.join(sorted(VALID_TOOLS))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate system prompt file exists if specified
|
||||||
|
if config.system_prompt_file:
|
||||||
|
if not config.system_prompt_file.exists():
|
||||||
|
errors.append(
|
||||||
|
f"Project '{project_name}': system_prompt_file does not exist: "
|
||||||
|
f"{config.system_prompt_file}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate model choice
|
||||||
|
valid_models = {"sonnet", "opus", "haiku"}
|
||||||
|
if config.model not in valid_models:
|
||||||
|
errors.append(
|
||||||
|
f"Project '{project_name}': invalid model '{config.model}'\n"
|
||||||
|
f" Valid models: {', '.join(sorted(valid_models))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
# Legacy method for backward compatibility
|
||||||
def get(self, key: str, default: Any = None) -> Any:
|
def get(self, key: str, default: Any = None) -> Any:
|
||||||
"""Get configuration value by key.
|
"""Get configuration value by key (legacy method).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
key: Configuration key (supports dot notation).
|
key: Configuration key (supports dot notation).
|
||||||
default: Default value if key not found.
|
default: Default value if key not found.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Configuration value or default.
|
Configuration value or default.
|
||||||
"""
|
"""
|
||||||
keys = key.split(".")
|
# This method is kept for backward compatibility but not used
|
||||||
value = self.data
|
# in the new channel-to-project mapping system
|
||||||
for k in keys:
|
return default
|
||||||
if isinstance(value, dict):
|
|
||||||
value = value.get(k)
|
|
||||||
else:
|
|
||||||
return default
|
|
||||||
return value if value is not None else default
|
|
||||||
|
|||||||
98
config.example.yaml
Normal file
98
config.example.yaml
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
# Claude Discord Coordinator - Example Configuration
|
||||||
|
#
|
||||||
|
# This file maps Discord channels to project configurations, allowing
|
||||||
|
# different channels to work with different projects, tools, and prompts.
|
||||||
|
#
|
||||||
|
# Default location: ~/.claude-coordinator/config.yaml
|
||||||
|
#
|
||||||
|
# Environment variables are expanded in paths:
|
||||||
|
# $HOME or ${HOME} - User's home directory
|
||||||
|
# $USER or ${USER} - Username
|
||||||
|
# Any other environment variable
|
||||||
|
|
||||||
|
projects:
|
||||||
|
# Full example with all options
|
||||||
|
major-domo:
|
||||||
|
# REQUIRED: Discord channel ID (as string or number)
|
||||||
|
channel_id: "1234567890"
|
||||||
|
|
||||||
|
# REQUIRED: Absolute path to project directory
|
||||||
|
# Environment variables like $HOME are expanded
|
||||||
|
project_dir: "/opt/projects/major-domo-bot"
|
||||||
|
|
||||||
|
# OPTIONAL: List of allowed tools (defaults to all tools if omitted)
|
||||||
|
# Valid tools: Bash, Read, Write, Edit, Glob, Grep, WebSearch, WebFetch
|
||||||
|
allowed_tools:
|
||||||
|
- Bash
|
||||||
|
- Read
|
||||||
|
- Write
|
||||||
|
- Edit
|
||||||
|
- Glob
|
||||||
|
- Grep
|
||||||
|
|
||||||
|
# OPTIONAL: System prompt loaded from file
|
||||||
|
# Use this for complex prompts stored in your project
|
||||||
|
system_prompt_file: "/opt/projects/major-domo-bot/CLAUDE.md"
|
||||||
|
|
||||||
|
# OPTIONAL: Model to use (defaults to "sonnet")
|
||||||
|
# Valid values: sonnet, opus, haiku
|
||||||
|
model: "sonnet"
|
||||||
|
|
||||||
|
# Example with inline system prompt
|
||||||
|
paper-dynasty:
|
||||||
|
channel_id: "0987654321"
|
||||||
|
project_dir: "/opt/projects/paper-dynasty"
|
||||||
|
|
||||||
|
# OPTIONAL: Inline system prompt (alternative to system_prompt_file)
|
||||||
|
# Use for short prompts; prefer system_prompt_file for longer ones
|
||||||
|
system_prompt: |
|
||||||
|
You are helping with the Paper Dynasty baseball card game.
|
||||||
|
Focus on gameplay mechanics, card generation, and player stats.
|
||||||
|
|
||||||
|
allowed_tools:
|
||||||
|
- Bash
|
||||||
|
- Read
|
||||||
|
- Grep
|
||||||
|
- WebSearch
|
||||||
|
|
||||||
|
model: "sonnet"
|
||||||
|
|
||||||
|
# Minimal example with only required fields
|
||||||
|
new-projects:
|
||||||
|
channel_id: "1111111111"
|
||||||
|
project_dir: "/opt/projects"
|
||||||
|
# allowed_tools defaults to all tools
|
||||||
|
# model defaults to "sonnet"
|
||||||
|
# no system_prompt or system_prompt_file
|
||||||
|
|
||||||
|
# Example using environment variables
|
||||||
|
personal-workspace:
|
||||||
|
channel_id: "2222222222"
|
||||||
|
project_dir: "$HOME/workspace/my-project"
|
||||||
|
system_prompt_file: "${HOME}/.config/claude/my-prompt.md"
|
||||||
|
allowed_tools:
|
||||||
|
- Bash
|
||||||
|
- Read
|
||||||
|
- Write
|
||||||
|
- Edit
|
||||||
|
model: "opus" # Use more powerful model for complex work
|
||||||
|
|
||||||
|
# Example with read-only access (no Write/Edit tools)
|
||||||
|
documentation:
|
||||||
|
channel_id: "3333333333"
|
||||||
|
project_dir: "/opt/projects/docs"
|
||||||
|
allowed_tools:
|
||||||
|
- Read
|
||||||
|
- Glob
|
||||||
|
- Grep
|
||||||
|
- WebSearch
|
||||||
|
system_prompt: "You help users navigate and understand documentation."
|
||||||
|
model: "haiku" # Faster model for simpler tasks
|
||||||
|
|
||||||
|
# Notes:
|
||||||
|
# - Each channel_id must be unique across all projects
|
||||||
|
# - Project directories should exist before running the bot
|
||||||
|
# - System prompt files are loaded at runtime
|
||||||
|
# - If both system_prompt and system_prompt_file are set, system_prompt takes precedence
|
||||||
|
# - Invalid tool names will cause validation errors
|
||||||
|
# - Model choices: sonnet (balanced), opus (powerful), haiku (fast)
|
||||||
486
tests/test_config.py
Normal file
486
tests/test_config.py
Normal file
@ -0,0 +1,486 @@
|
|||||||
|
"""Tests for configuration management.
|
||||||
|
|
||||||
|
Tests YAML loading, validation, project lookups, and error handling for the
|
||||||
|
channel-to-project configuration system.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
import tempfile
|
||||||
|
import yaml
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from claude_coordinator.config import Config, ProjectConfig, VALID_TOOLS
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_config_dir(tmp_path):
|
||||||
|
"""Create temporary directories for testing.
|
||||||
|
|
||||||
|
Creates a test project directory structure for validating project_dir
|
||||||
|
existence checks.
|
||||||
|
"""
|
||||||
|
projects = tmp_path / "projects"
|
||||||
|
projects.mkdir()
|
||||||
|
|
||||||
|
# Create test project directories
|
||||||
|
(projects / "major-domo").mkdir()
|
||||||
|
(projects / "paper-dynasty").mkdir()
|
||||||
|
(projects / "test-project").mkdir()
|
||||||
|
|
||||||
|
# Create test system prompt file
|
||||||
|
prompt_file = projects / "major-domo" / "CLAUDE.md"
|
||||||
|
prompt_file.write_text("You are helping with Major Domo.")
|
||||||
|
|
||||||
|
return projects
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def valid_config_data(temp_config_dir):
|
||||||
|
"""Create valid configuration data for testing."""
|
||||||
|
return {
|
||||||
|
"projects": {
|
||||||
|
"major-domo": {
|
||||||
|
"channel_id": "123456",
|
||||||
|
"project_dir": str(temp_config_dir / "major-domo"),
|
||||||
|
"allowed_tools": ["Bash", "Read", "Write"],
|
||||||
|
"system_prompt_file": str(temp_config_dir / "major-domo" / "CLAUDE.md"),
|
||||||
|
"model": "sonnet",
|
||||||
|
},
|
||||||
|
"paper-dynasty": {
|
||||||
|
"channel_id": "789012",
|
||||||
|
"project_dir": str(temp_config_dir / "paper-dynasty"),
|
||||||
|
"system_prompt": "You help with Paper Dynasty.",
|
||||||
|
"model": "opus",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def config_file(tmp_path, valid_config_data):
|
||||||
|
"""Create a temporary config file for testing.
|
||||||
|
|
||||||
|
Returns path to a valid YAML config file with test data.
|
||||||
|
"""
|
||||||
|
config_path = tmp_path / "config.yaml"
|
||||||
|
with open(config_path, "w") as f:
|
||||||
|
yaml.dump(valid_config_data, f)
|
||||||
|
return config_path
|
||||||
|
|
||||||
|
|
||||||
|
class TestProjectConfig:
|
||||||
|
"""Test ProjectConfig dataclass functionality."""
|
||||||
|
|
||||||
|
def test_project_config_creation(self):
|
||||||
|
"""Test creating a ProjectConfig with all fields."""
|
||||||
|
config = ProjectConfig(
|
||||||
|
name="test-project",
|
||||||
|
channel_id="123456",
|
||||||
|
project_dir="/opt/projects/test",
|
||||||
|
allowed_tools=["Bash", "Read"],
|
||||||
|
system_prompt="Test prompt",
|
||||||
|
model="sonnet",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert config.name == "test-project"
|
||||||
|
assert config.channel_id == "123456"
|
||||||
|
assert config.project_dir == "/opt/projects/test"
|
||||||
|
assert config.allowed_tools == ["Bash", "Read"]
|
||||||
|
assert config.system_prompt == "Test prompt"
|
||||||
|
assert config.model == "sonnet"
|
||||||
|
|
||||||
|
def test_project_config_defaults(self):
|
||||||
|
"""Test ProjectConfig default values."""
|
||||||
|
config = ProjectConfig(
|
||||||
|
name="test",
|
||||||
|
channel_id="123",
|
||||||
|
project_dir="/opt/test",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert config.allowed_tools == list(VALID_TOOLS)
|
||||||
|
assert config.model == "sonnet"
|
||||||
|
assert config.system_prompt is None
|
||||||
|
assert config.system_prompt_file is None
|
||||||
|
|
||||||
|
def test_environment_variable_expansion(self, temp_config_dir):
|
||||||
|
"""Test that environment variables are expanded in paths."""
|
||||||
|
os.environ["TEST_PROJECT_DIR"] = str(temp_config_dir)
|
||||||
|
|
||||||
|
config = ProjectConfig(
|
||||||
|
name="test",
|
||||||
|
channel_id="123",
|
||||||
|
project_dir="$TEST_PROJECT_DIR/major-domo",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert config.project_dir == str(temp_config_dir / "major-domo")
|
||||||
|
|
||||||
|
def test_get_system_prompt_inline(self):
|
||||||
|
"""Test getting inline system prompt."""
|
||||||
|
config = ProjectConfig(
|
||||||
|
name="test",
|
||||||
|
channel_id="123",
|
||||||
|
project_dir="/opt/test",
|
||||||
|
system_prompt="Inline prompt",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert config.get_system_prompt() == "Inline prompt"
|
||||||
|
|
||||||
|
def test_get_system_prompt_from_file(self, temp_config_dir):
|
||||||
|
"""Test loading system prompt from file."""
|
||||||
|
prompt_file = temp_config_dir / "major-domo" / "CLAUDE.md"
|
||||||
|
|
||||||
|
config = ProjectConfig(
|
||||||
|
name="test",
|
||||||
|
channel_id="123",
|
||||||
|
project_dir="/opt/test",
|
||||||
|
system_prompt_file=prompt_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert config.get_system_prompt() == "You are helping with Major Domo."
|
||||||
|
|
||||||
|
def test_get_system_prompt_file_not_found(self):
|
||||||
|
"""Test error when system prompt file doesn't exist."""
|
||||||
|
config = ProjectConfig(
|
||||||
|
name="test",
|
||||||
|
channel_id="123",
|
||||||
|
project_dir="/opt/test",
|
||||||
|
system_prompt_file=Path("/nonexistent/file.md"),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
config.get_system_prompt()
|
||||||
|
|
||||||
|
def test_get_system_prompt_none(self):
|
||||||
|
"""Test when no system prompt is configured."""
|
||||||
|
config = ProjectConfig(
|
||||||
|
name="test",
|
||||||
|
channel_id="123",
|
||||||
|
project_dir="/opt/test",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert config.get_system_prompt() is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfig:
|
||||||
|
"""Test Config class functionality."""
|
||||||
|
|
||||||
|
def test_load_valid_config(self, config_file):
|
||||||
|
"""Test loading a valid configuration file."""
|
||||||
|
config = Config(str(config_file))
|
||||||
|
config.load()
|
||||||
|
|
||||||
|
assert len(config.projects) == 2
|
||||||
|
assert "major-domo" in config.projects
|
||||||
|
assert "paper-dynasty" in config.projects
|
||||||
|
|
||||||
|
def test_load_missing_file(self, tmp_path):
|
||||||
|
"""Test error when config file doesn't exist."""
|
||||||
|
config = Config(str(tmp_path / "nonexistent.yaml"))
|
||||||
|
|
||||||
|
with pytest.raises(FileNotFoundError) as exc:
|
||||||
|
config.load()
|
||||||
|
|
||||||
|
assert "Configuration file not found" in str(exc.value)
|
||||||
|
|
||||||
|
def test_load_invalid_yaml(self, tmp_path):
|
||||||
|
"""Test error with invalid YAML syntax."""
|
||||||
|
config_path = tmp_path / "invalid.yaml"
|
||||||
|
config_path.write_text("invalid: yaml: syntax: [")
|
||||||
|
|
||||||
|
config = Config(str(config_path))
|
||||||
|
|
||||||
|
with pytest.raises(yaml.YAMLError):
|
||||||
|
config.load()
|
||||||
|
|
||||||
|
def test_load_not_dict(self, tmp_path):
|
||||||
|
"""Test error when config is not a dictionary."""
|
||||||
|
config_path = tmp_path / "list.yaml"
|
||||||
|
config_path.write_text("- item1\n- item2")
|
||||||
|
|
||||||
|
config = Config(str(config_path))
|
||||||
|
|
||||||
|
with pytest.raises(ValueError) as exc:
|
||||||
|
config.load()
|
||||||
|
|
||||||
|
assert "must contain a YAML dictionary" in str(exc.value)
|
||||||
|
|
||||||
|
def test_load_missing_projects_section(self, tmp_path):
|
||||||
|
"""Test error when 'projects' section is missing."""
|
||||||
|
config_path = tmp_path / "no_projects.yaml"
|
||||||
|
with open(config_path, "w") as f:
|
||||||
|
yaml.dump({"other_section": {}}, f)
|
||||||
|
|
||||||
|
config = Config(str(config_path))
|
||||||
|
|
||||||
|
with pytest.raises(ValueError) as exc:
|
||||||
|
config.load()
|
||||||
|
|
||||||
|
assert "must contain a 'projects' section" in str(exc.value)
|
||||||
|
|
||||||
|
def test_missing_required_fields(self, tmp_path, temp_config_dir):
|
||||||
|
"""Test error when required fields are missing."""
|
||||||
|
# Missing channel_id
|
||||||
|
config_data = {
|
||||||
|
"projects": {
|
||||||
|
"test": {
|
||||||
|
"project_dir": str(temp_config_dir / "test-project"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config_path = tmp_path / "missing_channel.yaml"
|
||||||
|
with open(config_path, "w") as f:
|
||||||
|
yaml.dump(config_data, f)
|
||||||
|
|
||||||
|
config = Config(str(config_path))
|
||||||
|
|
||||||
|
with pytest.raises(ValueError) as exc:
|
||||||
|
config.load()
|
||||||
|
|
||||||
|
assert "'channel_id' is required" in str(exc.value)
|
||||||
|
|
||||||
|
def test_get_project_by_channel(self, config_file):
|
||||||
|
"""Test looking up project by channel ID."""
|
||||||
|
config = Config(str(config_file))
|
||||||
|
config.load()
|
||||||
|
|
||||||
|
project = config.get_project_by_channel("123456")
|
||||||
|
assert project is not None
|
||||||
|
assert project.name == "major-domo"
|
||||||
|
assert project.channel_id == "123456"
|
||||||
|
|
||||||
|
def test_get_project_by_channel_not_found(self, config_file):
|
||||||
|
"""Test lookup returns None for unmapped channel."""
|
||||||
|
config = Config(str(config_file))
|
||||||
|
config.load()
|
||||||
|
|
||||||
|
project = config.get_project_by_channel("999999")
|
||||||
|
assert project is None
|
||||||
|
|
||||||
|
def test_get_all_projects(self, config_file):
|
||||||
|
"""Test getting all configured projects."""
|
||||||
|
config = Config(str(config_file))
|
||||||
|
config.load()
|
||||||
|
|
||||||
|
projects = config.get_all_projects()
|
||||||
|
assert len(projects) == 2
|
||||||
|
assert "major-domo" in projects
|
||||||
|
assert "paper-dynasty" in projects
|
||||||
|
assert isinstance(projects["major-domo"], ProjectConfig)
|
||||||
|
|
||||||
|
def test_validate_duplicate_channel_ids(self, tmp_path, temp_config_dir):
|
||||||
|
"""Test validation detects duplicate channel IDs."""
|
||||||
|
config_data = {
|
||||||
|
"projects": {
|
||||||
|
"project1": {
|
||||||
|
"channel_id": "123456",
|
||||||
|
"project_dir": str(temp_config_dir / "major-domo"),
|
||||||
|
},
|
||||||
|
"project2": {
|
||||||
|
"channel_id": "123456", # Duplicate
|
||||||
|
"project_dir": str(temp_config_dir / "paper-dynasty"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config_path = tmp_path / "duplicate.yaml"
|
||||||
|
with open(config_path, "w") as f:
|
||||||
|
yaml.dump(config_data, f)
|
||||||
|
|
||||||
|
config = Config(str(config_path))
|
||||||
|
|
||||||
|
with pytest.raises(ValueError) as exc:
|
||||||
|
config.load()
|
||||||
|
|
||||||
|
assert "Duplicate channel_id" in str(exc.value)
|
||||||
|
assert "123456" in str(exc.value)
|
||||||
|
|
||||||
|
def test_validate_invalid_tool_names(self, tmp_path, temp_config_dir):
|
||||||
|
"""Test validation detects invalid tool names."""
|
||||||
|
config_data = {
|
||||||
|
"projects": {
|
||||||
|
"test": {
|
||||||
|
"channel_id": "123456",
|
||||||
|
"project_dir": str(temp_config_dir / "test-project"),
|
||||||
|
"allowed_tools": ["Bash", "InvalidTool", "FakeTool"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config_path = tmp_path / "invalid_tools.yaml"
|
||||||
|
with open(config_path, "w") as f:
|
||||||
|
yaml.dump(config_data, f)
|
||||||
|
|
||||||
|
config = Config(str(config_path))
|
||||||
|
|
||||||
|
with pytest.raises(ValueError) as exc:
|
||||||
|
config.load()
|
||||||
|
|
||||||
|
assert "invalid tool names" in str(exc.value)
|
||||||
|
assert "InvalidTool" in str(exc.value) or "FakeTool" in str(exc.value)
|
||||||
|
|
||||||
|
def test_validate_missing_project_directory(self, tmp_path):
|
||||||
|
"""Test validation warns about missing project directories."""
|
||||||
|
config_data = {
|
||||||
|
"projects": {
|
||||||
|
"test": {
|
||||||
|
"channel_id": "123456",
|
||||||
|
"project_dir": "/nonexistent/directory",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config_path = tmp_path / "missing_dir.yaml"
|
||||||
|
with open(config_path, "w") as f:
|
||||||
|
yaml.dump(config_data, f)
|
||||||
|
|
||||||
|
config = Config(str(config_path))
|
||||||
|
|
||||||
|
with pytest.raises(ValueError) as exc:
|
||||||
|
config.load()
|
||||||
|
|
||||||
|
assert "directory does not exist" in str(exc.value)
|
||||||
|
|
||||||
|
def test_validate_invalid_model(self, tmp_path, temp_config_dir):
|
||||||
|
"""Test validation detects invalid model names."""
|
||||||
|
config_data = {
|
||||||
|
"projects": {
|
||||||
|
"test": {
|
||||||
|
"channel_id": "123456",
|
||||||
|
"project_dir": str(temp_config_dir / "test-project"),
|
||||||
|
"model": "invalid-model",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config_path = tmp_path / "invalid_model.yaml"
|
||||||
|
with open(config_path, "w") as f:
|
||||||
|
yaml.dump(config_data, f)
|
||||||
|
|
||||||
|
config = Config(str(config_path))
|
||||||
|
|
||||||
|
with pytest.raises(ValueError) as exc:
|
||||||
|
config.load()
|
||||||
|
|
||||||
|
assert "invalid model" in str(exc.value)
|
||||||
|
assert "invalid-model" in str(exc.value)
|
||||||
|
|
||||||
|
def test_validate_missing_system_prompt_file(self, tmp_path, temp_config_dir):
|
||||||
|
"""Test validation detects missing system prompt files."""
|
||||||
|
config_data = {
|
||||||
|
"projects": {
|
||||||
|
"test": {
|
||||||
|
"channel_id": "123456",
|
||||||
|
"project_dir": str(temp_config_dir / "test-project"),
|
||||||
|
"system_prompt_file": "/nonexistent/prompt.md",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config_path = tmp_path / "missing_prompt.yaml"
|
||||||
|
with open(config_path, "w") as f:
|
||||||
|
yaml.dump(config_data, f)
|
||||||
|
|
||||||
|
config = Config(str(config_path))
|
||||||
|
|
||||||
|
with pytest.raises(ValueError) as exc:
|
||||||
|
config.load()
|
||||||
|
|
||||||
|
assert "system_prompt_file does not exist" in str(exc.value)
|
||||||
|
|
||||||
|
def test_default_config_path(self, monkeypatch, tmp_path):
|
||||||
|
"""Test default config path uses ~/.claude-coordinator/config.yaml."""
|
||||||
|
# Create default config location
|
||||||
|
config_dir = tmp_path / ".claude-coordinator"
|
||||||
|
config_dir.mkdir()
|
||||||
|
default_config = config_dir / "config.yaml"
|
||||||
|
|
||||||
|
# Mock expanduser to return our tmp_path
|
||||||
|
def mock_expanduser(path):
|
||||||
|
return str(tmp_path / path.lstrip("~/"))
|
||||||
|
|
||||||
|
monkeypatch.setattr(os.path, "expanduser", mock_expanduser)
|
||||||
|
|
||||||
|
config = Config()
|
||||||
|
assert str(config.config_path).endswith(".claude-coordinator/config.yaml")
|
||||||
|
|
||||||
|
def test_channel_id_as_number(self, tmp_path, temp_config_dir):
|
||||||
|
"""Test that numeric channel IDs are converted to strings."""
|
||||||
|
config_data = {
|
||||||
|
"projects": {
|
||||||
|
"test": {
|
||||||
|
"channel_id": 123456, # Number instead of string
|
||||||
|
"project_dir": str(temp_config_dir / "test-project"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config_path = tmp_path / "numeric_id.yaml"
|
||||||
|
with open(config_path, "w") as f:
|
||||||
|
yaml.dump(config_data, f)
|
||||||
|
|
||||||
|
config = Config(str(config_path))
|
||||||
|
config.load()
|
||||||
|
|
||||||
|
project = config.get_project_by_channel("123456")
|
||||||
|
assert project is not None
|
||||||
|
assert project.channel_id == "123456"
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnvironmentVariableExpansion:
|
||||||
|
"""Test environment variable expansion in configuration."""
|
||||||
|
|
||||||
|
def test_expand_home_variable(self, tmp_path):
|
||||||
|
"""Test $HOME variable expansion."""
|
||||||
|
home_dir = str(tmp_path / "home")
|
||||||
|
os.makedirs(home_dir, exist_ok=True)
|
||||||
|
project_dir = Path(home_dir) / "projects" / "test"
|
||||||
|
project_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
os.environ["HOME"] = home_dir
|
||||||
|
|
||||||
|
config_data = {
|
||||||
|
"projects": {
|
||||||
|
"test": {
|
||||||
|
"channel_id": "123456",
|
||||||
|
"project_dir": "$HOME/projects/test",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config_path = tmp_path / "env_test.yaml"
|
||||||
|
with open(config_path, "w") as f:
|
||||||
|
yaml.dump(config_data, f)
|
||||||
|
|
||||||
|
config = Config(str(config_path))
|
||||||
|
config.load()
|
||||||
|
|
||||||
|
project = config.projects["test"]
|
||||||
|
assert project.project_dir == str(project_dir)
|
||||||
|
|
||||||
|
def test_expand_custom_variable(self, tmp_path):
|
||||||
|
"""Test custom environment variable expansion."""
|
||||||
|
custom_path = str(tmp_path / "custom")
|
||||||
|
os.makedirs(custom_path, exist_ok=True)
|
||||||
|
|
||||||
|
os.environ["CUSTOM_PROJECT_ROOT"] = custom_path
|
||||||
|
|
||||||
|
config_data = {
|
||||||
|
"projects": {
|
||||||
|
"test": {
|
||||||
|
"channel_id": "123456",
|
||||||
|
"project_dir": "${CUSTOM_PROJECT_ROOT}",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config_path = tmp_path / "custom_var.yaml"
|
||||||
|
with open(config_path, "w") as f:
|
||||||
|
yaml.dump(config_data, f)
|
||||||
|
|
||||||
|
config = Config(str(config_path))
|
||||||
|
config.load()
|
||||||
|
|
||||||
|
project = config.projects["test"]
|
||||||
|
assert project.project_dir == custom_path
|
||||||
Loading…
Reference in New Issue
Block a user