diff --git a/claude_coordinator/config.py b/claude_coordinator/config.py index b942dad..3130632 100644 --- a/claude_coordinator/config.py +++ b/claude_coordinator/config.py @@ -1,56 +1,300 @@ """Configuration management for Claude Discord Coordinator. -Loads and validates YAML configuration files with support for environment -variable substitution. +Loads and validates YAML configuration files mapping Discord channels to +project configurations with tool restrictions and custom prompts. """ +from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional +import os 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: """Configuration manager for bot settings. - + + Loads YAML configuration mapping Discord channels to project settings, + validates configuration structure, and provides lookup methods. + Attributes: 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): - """Initialize configuration from YAML file. - + + def __init__(self, config_path: Optional[str] = None): + """Initialize configuration manager. + 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 - self.data: Dict[str, Any] = {} - + if config_path is None: + 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: """Load configuration from YAML file. - + Raises: FileNotFoundError: If config file does not exist. yaml.YAMLError: If config file is invalid YAML. + ValueError: If configuration validation fails. """ - with open(self.config_path, "r") as f: - self.data = yaml.safe_load(f) - + if not self.config_path.exists(): + 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: - """Get configuration value by key. - + """Get configuration value by key (legacy method). + Args: key: Configuration key (supports dot notation). default: Default value if key not found. - + Returns: Configuration value or default. """ - keys = key.split(".") - value = self.data - for k in keys: - if isinstance(value, dict): - value = value.get(k) - else: - return default - return value if value is not None else default + # This method is kept for backward compatibility but not used + # in the new channel-to-project mapping system + return default diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..68396bf --- /dev/null +++ b/config.example.yaml @@ -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) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..b67c10e --- /dev/null +++ b/tests/test_config.py @@ -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