Add /add-project command for dynamic project setup

New Features:
- /add-project slash command for adding projects without restart
- Clones git repository with shallow clone (--depth 1)
- Updates config.yaml atomically with rollback on failure
- Live config reload (no bot restart needed)
- Administrator permission required
- Comprehensive validation and error handling

Implementation:
- config.py: add_project() and save() methods
- commands.py: add_project command (193 lines)
- 12 new tests covering all scenarios
- Full documentation in COMMANDS_USAGE.md

Test Results: 30/30 passing (100%)

Usage:
  /add-project
    project_name: my-project
    git_url: https://git.manticorum.com/cal/my-project.git
    model: sonnet

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Discord Bot 2026-02-13 19:47:47 +00:00
parent 3ccfbe5f99
commit 48c93adade
7 changed files with 1580 additions and 1 deletions

View File

@ -0,0 +1,33 @@
# Enhancement: /add-project Slash Command Implementation
**Status**: ✅ Complete
**Date**: 2026-02-13
**Deployed to**: discord-coordinator (10.10.0.230)
## Summary
Implemented /add-project slash command for dynamic project configuration without manual config editing or bot restarts. Command allows administrators to add new project configurations by providing a git repository URL and project details.
## Test Results
All 30 tests passing (18 existing + 12 new):
- test_add_project_success
- test_add_project_duplicate_channel
- test_add_project_invalid_name
- test_add_project_directory_exists
- test_add_project_git_clone_failure
- test_add_project_git_timeout
- test_add_project_not_git_repo
- test_add_project_config_rollback
- test_add_project_permission_check
- test_add_project_error_handler
- test_add_project_with_target_channel
- test_config_structure
## Deployment Instructions
Bot will automatically register the new command on next startup.
## Implementation Complete
All code changes deployed, tested, and documented.

View File

@ -20,6 +20,11 @@ from discord.ext import commands
from claude_coordinator.session_manager import SessionManager
from claude_coordinator.config import Config
import asyncio
import os
import re
import shutil
logger = logging.getLogger(__name__)
@ -300,6 +305,199 @@ class ClaudeCommands(commands.Cog):
ephemeral=True
)
@app_commands.command(
name="add-project",
description="Add a new project configuration dynamically (admin only)"
)
@app_commands.describe(
project_name="Short name for the project (e.g., 'major-domo')",
git_url="Git repository URL (e.g., 'https://git.manticorum.com/cal/my-project.git')",
channel="Channel to map (defaults to current channel)",
model="Claude model to use (default: sonnet)"
)
@app_commands.choices(model=[
app_commands.Choice(name="Sonnet", value="sonnet"),
app_commands.Choice(name="Opus", value="opus"),
app_commands.Choice(name="Haiku", value="haiku")
])
@app_commands.checks.has_permissions(administrator=True)
async def add_project_command(
self,
interaction: discord.Interaction,
project_name: str,
git_url: str,
channel: Optional[discord.TextChannel] = None,
model: str = "sonnet"
):
"""Add a new project configuration dynamically.
Clones the specified Git repository and adds it to bot configuration,
allowing Claude coordination in the specified channel without restart.
Requires administrator permission.
Args:
interaction: Discord interaction object.
project_name: Short name for the project (alphanumeric, hyphens, underscores).
git_url: Git repository URL to clone.
channel: Optional channel to map (defaults to current channel).
model: Claude model to use (sonnet, opus, haiku).
"""
# 1. Validate inputs
channel = channel or interaction.channel
channel_id = str(channel.id)
# Check if channel already configured
if self.config.get_project_by_channel(channel_id):
await interaction.response.send_message(
f"❌ Channel {channel.mention} is already configured!",
ephemeral=True
)
return
# Validate project name (alphanumeric, hyphens, underscores)
if not re.match(r'^[a-z0-9_-]+$', project_name):
await interaction.response.send_message(
"❌ Project name must be lowercase alphanumeric with hyphens/underscores only",
ephemeral=True
)
return
# 2. Defer response (this might take a while)
await interaction.response.defer(ephemeral=True)
# 3. Clone repository
project_dir = f"/opt/projects/{project_name}"
if os.path.exists(project_dir):
await interaction.followup.send(
f"❌ Directory already exists: `{project_dir}`"
)
return
try:
# Clone with timeout and shallow clone
process = await asyncio.create_subprocess_exec(
"git", "clone", "--depth", "1", git_url, project_dir,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=120.0 # 2 minutes
)
except asyncio.TimeoutError:
process.kill()
await process.wait()
await interaction.followup.send(
"❌ Git clone timed out (>2 minutes)"
)
return
if process.returncode != 0:
error_msg = stderr.decode('utf-8', errors='replace')[:500]
await interaction.followup.send(
f"❌ Git clone failed:\\n```\\n{error_msg}\\n```"
)
return
except Exception as e:
logger.exception(f"Error during git clone: {e}")
await interaction.followup.send(
f"❌ Git clone failed: {str(e)}"
)
return
# 4. Validate cloned repository
if not os.path.exists(os.path.join(project_dir, '.git')):
# Clean up
try:
shutil.rmtree(project_dir)
except Exception as e:
logger.error(f"Failed to clean up invalid clone: {e}")
await interaction.followup.send(
"❌ Cloned directory doesn't appear to be a git repository"
)
return
# 5. Add to config.yaml
new_project = {
'name': project_name,
'channel_id': channel_id,
'project_dir': project_dir,
'allowed_tools': ['Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep'],
'model': model,
'system_prompt': None
}
try:
# Update config atomically
self.config.add_project(new_project)
self.config.save() # Write to disk
# 6. Reload config (no restart needed!)
self.config.load()
logger.info(
f"Project '{project_name}' added by user {interaction.user.id} "
f"for channel {channel_id}"
)
except Exception as e:
logger.exception(f"Error adding project to config: {e}")
# Rollback: delete cloned directory
try:
shutil.rmtree(project_dir)
logger.info(f"Rolled back: deleted {project_dir}")
except Exception as cleanup_error:
logger.error(f"Failed to clean up after config error: {cleanup_error}")
await interaction.followup.send(
f"❌ Failed to update configuration: {str(e)}\\n\\nRolled back changes."
)
return
# 7. Success message
embed = discord.Embed(
title="✅ Project Added Successfully",
color=discord.Color.green()
)
embed.add_field(name="Project", value=project_name, inline=True)
embed.add_field(name="Channel", value=channel.mention, inline=True)
embed.add_field(name="Model", value=model, inline=True)
embed.add_field(name="Directory", value=f"`{project_dir}`", inline=False)
embed.add_field(name="Repository", value=git_url, inline=False)
embed.set_footer(text="You can now @mention the bot in this channel!")
await interaction.followup.send(embed=embed)
@add_project_command.error
async def add_project_error(self, interaction: discord.Interaction, error: app_commands.AppCommandError):
"""Handle errors for add-project command."""
if isinstance(error, app_commands.MissingPermissions):
await interaction.response.send_message(
"❌ You need **Administrator** permission to use this command.",
ephemeral=True
)
else:
logger.exception(f"Unhandled error in add-project command: {error}")
# Try to send error message
if interaction.response.is_done():
await interaction.followup.send(
f"❌ An error occurred: {str(error)}",
ephemeral=True
)
else:
await interaction.response.send_message(
f"❌ An error occurred: {str(error)}",
ephemeral=True
)
@reset_command.error
async def reset_error(self, interaction: discord.Interaction, error: app_commands.AppCommandError):
"""Handle errors for reset command."""

View File

@ -0,0 +1,411 @@
"""Slash commands for Discord bot management.
This module implements application commands (slash commands) for managing
Claude sessions across Discord channels. Provides administrative controls
for session reset, status monitoring, and model configuration.
Commands:
- /reset: Clear Claude session for a channel (admin only)
- /status: Show all active Claude sessions
- /model: Switch Claude model for the current channel
"""
import logging
from datetime import datetime
from typing import Optional
import discord
from discord import app_commands
from discord.ext import commands
from claude_coordinator.session_manager import SessionManager
from claude_coordinator.config import Config
logger = logging.getLogger(__name__)
class ClaudeCommands(commands.Cog):
"""Slash commands for Claude Coordinator bot management.
Provides administrative and monitoring commands for Claude sessions:
- Session management (reset)
- Status monitoring (all active sessions)
- Model configuration (switch between Claude models)
"""
def __init__(self, bot: commands.Bot):
"""Initialize commands cog.
Args:
bot: The Discord bot instance.
"""
self.bot = bot
self.session_manager: SessionManager = bot.session_manager
self.config: Config = bot.config
logger.info("ClaudeCommands cog initialized")
@app_commands.command(
name="reset",
description="Clear Claude session for this channel (admin only)"
)
@app_commands.describe(
channel="Optional: Channel to reset (defaults to current channel)"
)
@app_commands.checks.has_permissions(manage_messages=True)
async def reset_command(
self,
interaction: discord.Interaction,
channel: Optional[discord.TextChannel] = None
):
"""Reset Claude session for a channel.
Clears the session history, causing the next message to start
a fresh conversation. Requires manage_messages permission.
Args:
interaction: Discord interaction object.
channel: Optional channel to reset (defaults to current channel).
"""
# Determine target channel
target_channel = channel or interaction.channel
channel_id = str(target_channel.id)
try:
# Check if channel is configured
project = self.config.get_project_by_channel(channel_id)
if not project:
await interaction.response.send_message(
f"❌ Channel {target_channel.mention} is not configured for Claude Coordinator.",
ephemeral=True
)
return
# Check if session exists
session = await self.session_manager.get_session(channel_id)
if not session:
await interaction.response.send_message(
f" No active session found for {target_channel.mention}.",
ephemeral=True
)
return
# Create confirmation view
view = ResetConfirmView(
self.session_manager,
channel_id,
target_channel,
session
)
message_count = session.get('message_count', 0)
await interaction.response.send_message(
f"⚠️ **Reset Session Confirmation**\n\n"
f"Channel: {target_channel.mention}\n"
f"Project: **{session.get('project_name', 'Unknown')}**\n"
f"Messages: **{message_count}**\n\n"
f"Are you sure you want to clear this session? This will delete all conversation history.",
view=view,
ephemeral=True
)
logger.info(
f"Reset confirmation requested for channel {channel_id} "
f"by user {interaction.user.id}"
)
except Exception as e:
logger.exception(f"Error in reset command: {e}")
await interaction.response.send_message(
f"❌ **Error:** {str(e)}",
ephemeral=True
)
@app_commands.command(
name="status",
description="Show all active Claude sessions"
)
async def status_command(self, interaction: discord.Interaction):
"""Display status of all active Claude sessions.
Shows channel name, project, message count, and last activity
time for each active session across all configured channels.
Args:
interaction: Discord interaction object.
"""
try:
# Defer response since we might take a moment
await interaction.response.defer(ephemeral=True)
# Get all sessions
sessions = await self.session_manager.list_sessions()
stats = await self.session_manager.get_stats()
if not sessions:
embed = discord.Embed(
title="📊 Claude Coordinator Status",
description="No active sessions.",
color=discord.Color.blue()
)
embed.set_footer(text=f"Database: {self.session_manager.db_path}")
await interaction.followup.send(embed=embed, ephemeral=True)
return
# Build embed with session information
embed = discord.Embed(
title="📊 Claude Coordinator Status",
description=f"**{stats['total_sessions']}** active session(s)",
color=discord.Color.green()
)
# Add session details
for session in sessions:
channel_id = session['channel_id']
# Try to get channel name
try:
channel = self.bot.get_channel(int(channel_id))
channel_name = channel.mention if channel else f"Unknown ({channel_id})"
except (ValueError, AttributeError):
channel_name = f"Unknown ({channel_id})"
project_name = session.get('project_name') or 'Unknown'
message_count = session.get('message_count', 0)
last_active = session.get('last_active', '')
# Calculate time since last activity
try:
last_active_dt = datetime.fromisoformat(last_active)
now = datetime.now()
delta = now - last_active_dt
if delta.days > 0:
time_ago = f"{delta.days}d ago"
elif delta.seconds >= 3600:
hours = delta.seconds // 3600
time_ago = f"{hours}h ago"
elif delta.seconds >= 60:
minutes = delta.seconds // 60
time_ago = f"{minutes}m ago"
else:
time_ago = "just now"
except (ValueError, TypeError):
time_ago = "unknown"
embed.add_field(
name=f"{channel_name}",
value=(
f"**Project:** {project_name}\n"
f"**Messages:** {message_count}\n"
f"**Last Active:** {time_ago}"
),
inline=False
)
# Add summary footer
total_messages = stats.get('total_messages', 0)
embed.set_footer(
text=f"Total messages: {total_messages} | Database: {self.session_manager.db_path}"
)
await interaction.followup.send(embed=embed, ephemeral=True)
logger.info(f"Status command executed by user {interaction.user.id}")
except Exception as e:
logger.exception(f"Error in status command: {e}")
await interaction.followup.send(
f"❌ **Error:** {str(e)}",
ephemeral=True
)
@app_commands.command(
name="model",
description="Switch Claude model for this channel"
)
@app_commands.describe(
model_name="Claude model to use (sonnet, opus, haiku)"
)
@app_commands.choices(model_name=[
app_commands.Choice(name="Claude Sonnet (default)", value="sonnet"),
app_commands.Choice(name="Claude Opus (most capable)", value="opus"),
app_commands.Choice(name="Claude Haiku (fastest)", value="haiku"),
])
async def model_command(
self,
interaction: discord.Interaction,
model_name: str
):
"""Switch Claude model for the current channel.
Changes the model used for future Claude CLI invocations in this channel.
Does not affect existing session history.
Args:
interaction: Discord interaction object.
model_name: Model identifier (sonnet, opus, haiku).
"""
channel_id = str(interaction.channel.id)
try:
# Check if channel is configured
project = self.config.get_project_by_channel(channel_id)
if not project:
await interaction.response.send_message(
"❌ This channel is not configured for Claude Coordinator.",
ephemeral=True
)
return
# Map model names to Claude CLI model identifiers
model_mapping = {
"sonnet": "claude-sonnet-4-5",
"opus": "claude-opus-4-6",
"haiku": "claude-3-5-haiku"
}
if model_name not in model_mapping:
await interaction.response.send_message(
f"❌ Invalid model: {model_name}. Use: sonnet, opus, or haiku",
ephemeral=True
)
return
# Update project config with new model
old_model = project.model
project.model = model_mapping[model_name]
# Save configuration
self.config.save()
await interaction.response.send_message(
f"✅ **Model Updated**\n\n"
f"Channel: {interaction.channel.mention}\n"
f"Previous: `{old_model or 'default'}`\n"
f"New: `{project.model}`\n\n"
f"The new model will be used for the next Claude request.",
ephemeral=True
)
logger.info(
f"Model switched to {model_name} for channel {channel_id} "
f"by user {interaction.user.id}"
)
except Exception as e:
logger.exception(f"Error in model command: {e}")
await interaction.response.send_message(
f"❌ **Error:** {str(e)}",
ephemeral=True
)
@reset_command.error
async def reset_error(self, interaction: discord.Interaction, error: app_commands.AppCommandError):
"""Handle errors for reset command."""
if isinstance(error, app_commands.MissingPermissions):
await interaction.response.send_message(
"❌ You need **Manage Messages** permission to use this command.",
ephemeral=True
)
else:
logger.exception(f"Unhandled error in reset command: {error}")
await interaction.response.send_message(
f"❌ An error occurred: {str(error)}",
ephemeral=True
)
class ResetConfirmView(discord.ui.View):
"""Confirmation view for /reset command.
Provides Yes/No buttons for session reset confirmation.
"""
def __init__(
self,
session_manager: SessionManager,
channel_id: str,
channel: discord.TextChannel,
session: dict
):
"""Initialize confirmation view.
Args:
session_manager: SessionManager instance.
channel_id: ID of channel to reset.
channel: Discord channel object.
session: Session data dictionary.
"""
super().__init__(timeout=60.0) # 60 second timeout
self.session_manager = session_manager
self.channel_id = channel_id
self.channel = channel
self.session = session
@discord.ui.button(label="Yes, Reset", style=discord.ButtonStyle.danger)
async def confirm_button(
self,
interaction: discord.Interaction,
button: discord.ui.Button
):
"""Handle confirmation button click."""
try:
# Perform reset
deleted = await self.session_manager.reset_session(self.channel_id)
if deleted:
await interaction.response.edit_message(
content=(
f"✅ **Session Reset**\n\n"
f"Channel: {self.channel.mention}\n"
f"Project: **{self.session.get('project_name', 'Unknown')}**\n\n"
f"Session history cleared. The next message will start a fresh conversation."
),
view=None # Remove buttons
)
logger.info(
f"Session reset for channel {self.channel_id} "
f"by user {interaction.user.id}"
)
else:
await interaction.response.edit_message(
content="❌ Session not found or already deleted.",
view=None
)
except Exception as e:
logger.exception(f"Error during session reset: {e}")
await interaction.response.edit_message(
content=f"❌ Error resetting session: {str(e)}",
view=None
)
@discord.ui.button(label="Cancel", style=discord.ButtonStyle.secondary)
async def cancel_button(
self,
interaction: discord.Interaction,
button: discord.ui.Button
):
"""Handle cancel button click."""
await interaction.response.edit_message(
content="❌ Reset cancelled. Session remains active.",
view=None # Remove buttons
)
async def on_timeout(self):
"""Handle view timeout (60 seconds)."""
# Disable all buttons on timeout
for item in self.children:
item.disabled = True
async def setup(bot: commands.Bot):
"""Setup function to add cog to bot.
Args:
bot: The Discord bot instance.
"""
await bot.add_cog(ClaudeCommands(bot))
logger.info("ClaudeCommands cog loaded")

View File

@ -284,6 +284,96 @@ class Config:
return errors
def add_project(self, project_config: dict) -> None:
"""Add a new project to configuration.
Args:
project_config: Dictionary with project configuration fields:
- name: Project name
- channel_id: Discord channel ID
- project_dir: Absolute path to project directory
- allowed_tools: List of allowed tool names
- model: Claude model to use
- system_prompt: Optional inline system prompt
- system_prompt_file: Optional path to system prompt file
Raises:
ValueError: If required fields are missing or channel_id already configured.
"""
# Validate project_config has required fields
required = ['name', 'channel_id', 'project_dir', 'allowed_tools', 'model']
for field in required:
if field not in project_config:
raise ValueError(f"Missing required field: {field}")
# Check for duplicate channel_id
if any(p.channel_id == project_config['channel_id'] for p in self.projects.values()):
raise ValueError(f"Channel {project_config['channel_id']} already configured")
# Create ProjectConfig instance
project = ProjectConfig(
name=project_config['name'],
channel_id=project_config['channel_id'],
project_dir=project_config['project_dir'],
allowed_tools=project_config['allowed_tools'],
system_prompt=project_config.get('system_prompt'),
system_prompt_file=Path(project_config['system_prompt_file']) if project_config.get('system_prompt_file') else None,
model=project_config['model']
)
# Add to projects dict and channel map
self.projects[project_config['name']] = project
self._channel_map[project_config['channel_id']] = project_config['name']
logger.info(f"Added project '{project_config['name']}' for channel {project_config['channel_id']}")
def save(self) -> None:
"""Save configuration back to YAML file.
Writes the current configuration to disk atomically (write to temp file,
then rename) to prevent corruption if interrupted.
Raises:
IOError: If unable to write configuration file.
"""
config_data = {
'projects': {}
}
# Build projects section
for name, project in self.projects.items():
config_data['projects'][name] = {
'channel_id': project.channel_id,
'project_dir': project.project_dir,
'allowed_tools': project.allowed_tools,
'model': project.model,
}
# Add optional fields if present
if project.system_prompt:
config_data['projects'][name]['system_prompt'] = project.system_prompt
if project.system_prompt_file:
config_data['projects'][name]['system_prompt_file'] = str(project.system_prompt_file)
# Write atomically (write to temp file, then rename)
temp_path = Path(str(self.config_path) + '.tmp')
try:
with open(temp_path, 'w') as f:
yaml.dump(config_data, f, default_flow_style=False, sort_keys=False)
# Atomic rename
os.replace(temp_path, self.config_path)
logger.info(f"Saved configuration to {self.config_path}")
except Exception as e:
# Clean up temp file on error
if temp_path.exists():
temp_path.unlink()
raise IOError(f"Failed to save configuration: {e}")
# Legacy method for backward compatibility
def get(self, key: str, default: Any = None) -> Any:
"""Get configuration value by key (legacy method).

View File

@ -0,0 +1,300 @@
"""Configuration management for Claude Discord Coordinator.
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, 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.
projects: Dictionary mapping project names to ProjectConfig objects.
_channel_map: Internal mapping of channel IDs to project names.
"""
def __init__(self, config_path: Optional[str] = None):
"""Initialize configuration manager.
Args:
config_path: Path to config file. If None, uses default location
~/.claude-coordinator/config.yaml
"""
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.
"""
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 (legacy method).
Args:
key: Configuration key (supports dot notation).
default: Default value if key not found.
Returns:
Configuration value or default.
"""
# This method is kept for backward compatibility but not used
# in the new channel-to-project mapping system
return default

View File

@ -123,12 +123,116 @@ New: claude-opus-4-6
The new model will be used for the next Claude request.
```
---
### `/add-project <project_name> <git_url> [channel] [model]`
Dynamically add a new project configuration without restarting the bot.
**Usage:**
```
/add-project project_name:my-project git_url:https://git.manticorum.com/cal/my-project.git
/add-project project_name:paper-dynasty git_url:https://git.example.com/pd.git channel:#dev model:opus
```
**Parameters:**
- `project_name` (required) - Short name for the project (lowercase, alphanumeric, hyphens/underscores only)
- `git_url` (required) - Git repository URL to clone
- `channel` (optional) - Channel to map (defaults to current channel)
- `model` (optional) - Claude model to use: sonnet (default), opus, or haiku
**Features:**
- Requires **Administrator** permission
- Clones repository to `/opt/projects/{project_name}`
- Uses shallow clone (`--depth 1`) for faster cloning
- Validates project name format
- Checks for duplicate channel configuration
- Validates cloned repository structure
- Updates configuration file automatically
- No bot restart required - changes take effect immediately
- Rolls back changes on failure
**Workflow:**
1. Validates inputs (project name format, channel not already configured)
2. Clones git repository with 2-minute timeout
3. Validates cloned directory is a valid git repository
4. Adds project to configuration
5. Saves configuration atomically to disk
6. Reloads configuration (live update!)
**Example Output:**
```
✅ Project Added Successfully
Project: paper-dynasty
Channel: #development
Model: opus
Directory: /opt/projects/paper-dynasty
Repository: https://git.manticorum.com/cal/paper-dynasty.git
You can now @mention the bot in this channel!
```
**Error Handling:**
**Invalid Project Name:**
```
❌ Project name must be lowercase alphanumeric with hyphens/underscores only
```
**Channel Already Configured:**
```
❌ Channel #development is already configured!
```
**Directory Already Exists:**
```
❌ Directory already exists: /opt/projects/paper-dynasty
```
**Git Clone Failure:**
```
❌ Git clone failed:
fatal: repository 'https://git.example.com/invalid.git' not found
```
**Git Clone Timeout:**
```
❌ Git clone timed out (>2 minutes)
```
**Not a Git Repository:**
```
❌ Cloned directory doesn't appear to be a git repository
```
**Configuration Error (with Rollback):**
```
❌ Failed to update configuration: Disk full
Rolled back changes.
```
**Important Notes:**
- Repository must be publicly accessible or SSH keys must be configured on the bot server
- Project name becomes the directory name in `/opt/projects/`
- Default allowed tools: Bash, Read, Write, Edit, Glob, Grep
- Configuration is saved to `~/.claude-coordinator/config.yaml`
- Use `--depth 1` shallow clone to save disk space and time
- Rollback automatically removes cloned directory if configuration fails
---
## Permission Requirements
### `/reset`
- **Required:** Manage Messages permission
### `/add-project`
- **Required:** Administrator permission
- **Scope:** Server-wide administration
- **Scope:** Per-channel or server-wide
### `/status`
@ -210,4 +314,4 @@ Test coverage:
- ✅ Permission checks and error handlers
- ✅ Cog initialization and setup
**Total:** 18 test cases, all passing
**Total:** 30 test cases, all passing (18 original + 12 for /add-project)

View File

@ -382,3 +382,446 @@ class TestCogSetup:
mock_bot.add_cog.assert_called_once()
call_args = mock_bot.add_cog.call_args
assert isinstance(call_args[0][0], ClaudeCommands)
"""
Tests for /add-project command.
Tests cover project addition workflow with:
- Success scenarios
- Validation (project name, duplicate channel)
- Git clone handling (success, failure, timeout)
- Configuration management
- Rollback on errors
- Permission checks
"""
import asyncio
import os
import shutil
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch, call
import discord
import pytest
from discord import app_commands
from claude_coordinator.commands import ClaudeCommands
@pytest.fixture
def mock_bot():
"""Create a mock Discord bot."""
bot = MagicMock()
bot.session_manager = AsyncMock()
bot.config = MagicMock()
return bot
@pytest.fixture
def mock_interaction():
"""Create a mock Discord interaction."""
interaction = MagicMock(spec=discord.Interaction)
interaction.response = AsyncMock()
interaction.followup = AsyncMock()
interaction.channel = MagicMock()
interaction.channel.id = 123456789
interaction.channel.mention = "#test-channel"
interaction.user = MagicMock()
interaction.user.id = 987654321
return interaction
@pytest.fixture
def commands_cog(mock_bot):
"""Create ClaudeCommands cog instance."""
return ClaudeCommands(mock_bot)
class TestAddProjectCommand:
"""Tests for /add-project command."""
@pytest.mark.asyncio
async def test_add_project_success(self, commands_cog, mock_interaction):
"""Test successful project addition with all steps completing."""
# Setup mocks
commands_cog.config.get_project_by_channel.return_value = None
# Mock git clone subprocess
mock_process = AsyncMock()
mock_process.returncode = 0
mock_process.communicate.return_value = (b'', b'')
# Mock file system operations
with patch('asyncio.create_subprocess_exec', return_value=mock_process), \
patch('asyncio.wait_for', return_value=(b'', b'')), \
patch('os.path.exists') as mock_exists, \
patch('os.path.join', return_value='/opt/projects/test-project/.git'):
# First exists() check: directory doesn't exist (False)
# Second exists() check: .git directory exists (True)
mock_exists.side_effect = [False, True]
# Mock config operations
commands_cog.config.add_project = MagicMock()
commands_cog.config.save = MagicMock()
commands_cog.config.load = MagicMock()
# Execute command
await commands_cog.add_project_command.callback(
commands_cog,
mock_interaction,
"test-project",
"https://git.example.com/test.git",
None,
"sonnet"
)
# Verify defer was called
mock_interaction.response.defer.assert_called_once_with(ephemeral=True)
# Verify config was updated
commands_cog.config.add_project.assert_called_once()
commands_cog.config.save.assert_called_once()
commands_cog.config.load.assert_called_once()
# Verify success message
mock_interaction.followup.send.assert_called_once()
call_args = mock_interaction.followup.send.call_args
assert 'embed' in call_args[1]
@pytest.mark.asyncio
async def test_add_project_duplicate_channel(self, commands_cog, mock_interaction):
"""Test add-project rejects if channel already configured."""
# Setup: channel already has a project
mock_project = MagicMock()
commands_cog.config.get_project_by_channel.return_value = mock_project
# Execute command
await commands_cog.add_project_command.callback(
commands_cog,
mock_interaction,
"test-project",
"https://git.example.com/test.git",
None,
"sonnet"
)
# Verify error message
mock_interaction.response.send_message.assert_called_once()
call_args = mock_interaction.response.send_message.call_args
assert "already configured" in call_args[0][0]
assert call_args[1]['ephemeral'] is True
@pytest.mark.asyncio
async def test_add_project_invalid_name(self, commands_cog, mock_interaction):
"""Test add-project rejects invalid project names."""
# Setup
commands_cog.config.get_project_by_channel.return_value = None
invalid_names = [
"Test-Project", # Uppercase
"test project", # Space
"test@project", # Special char
"test.project", # Dot
]
for invalid_name in invalid_names:
mock_interaction.response.reset_mock()
# Execute command
await commands_cog.add_project_command.callback(
commands_cog,
mock_interaction,
invalid_name,
"https://git.example.com/test.git",
None,
"sonnet"
)
# Verify error message
mock_interaction.response.send_message.assert_called_once()
call_args = mock_interaction.response.send_message.call_args
assert "lowercase alphanumeric" in call_args[0][0]
@pytest.mark.asyncio
async def test_add_project_directory_exists(self, commands_cog, mock_interaction):
"""Test add-project fails if directory already exists."""
# Setup
commands_cog.config.get_project_by_channel.return_value = None
with patch('os.path.exists', return_value=True):
# Execute command
await commands_cog.add_project_command.callback(
commands_cog,
mock_interaction,
"test-project",
"https://git.example.com/test.git",
None,
"sonnet"
)
# Verify defer was called
mock_interaction.response.defer.assert_called_once()
# Verify error message
mock_interaction.followup.send.assert_called_once()
call_args = mock_interaction.followup.send.call_args
assert "already exists" in call_args[0][0]
@pytest.mark.asyncio
async def test_add_project_git_clone_failure(self, commands_cog, mock_interaction):
"""Test add-project handles git clone failure gracefully."""
# Setup
commands_cog.config.get_project_by_channel.return_value = None
# Mock git clone subprocess with failure
mock_process = AsyncMock()
mock_process.returncode = 1
mock_process.communicate.return_value = (b'', b'fatal: repository not found')
with patch('asyncio.create_subprocess_exec', return_value=mock_process), \
patch('asyncio.wait_for', return_value=(b'', b'fatal: repository not found')), \
patch('os.path.exists', return_value=False):
# Execute command
await commands_cog.add_project_command.callback(
commands_cog,
mock_interaction,
"test-project",
"https://git.example.com/test.git",
None,
"sonnet"
)
# Verify error message
mock_interaction.followup.send.assert_called_once()
call_args = mock_interaction.followup.send.call_args
assert "Git clone failed" in call_args[0][0]
@pytest.mark.asyncio
async def test_add_project_git_timeout(self, commands_cog, mock_interaction):
"""Test add-project handles git clone timeout."""
# Setup
commands_cog.config.get_project_by_channel.return_value = None
# Mock git clone subprocess
mock_process = AsyncMock()
mock_process.kill = MagicMock()
mock_process.wait = AsyncMock()
with patch('asyncio.create_subprocess_exec', return_value=mock_process), \
patch('asyncio.wait_for', side_effect=asyncio.TimeoutError), \
patch('os.path.exists', return_value=False):
# Execute command
await commands_cog.add_project_command.callback(
commands_cog,
mock_interaction,
"test-project",
"https://git.example.com/test.git",
None,
"sonnet"
)
# Verify process was killed
mock_process.kill.assert_called_once()
# Verify error message
mock_interaction.followup.send.assert_called_once()
call_args = mock_interaction.followup.send.call_args
assert "timed out" in call_args[0][0]
@pytest.mark.asyncio
async def test_add_project_not_git_repo(self, commands_cog, mock_interaction):
"""Test add-project validates cloned directory is a git repo."""
# Setup
commands_cog.config.get_project_by_channel.return_value = None
# Mock git clone subprocess (success)
mock_process = AsyncMock()
mock_process.returncode = 0
mock_process.communicate.return_value = (b'', b'')
with patch('asyncio.create_subprocess_exec', return_value=mock_process), \
patch('asyncio.wait_for', return_value=(b'', b'')), \
patch('os.path.exists') as mock_exists, \
patch('os.path.join', return_value='/opt/projects/test-project/.git'), \
patch('shutil.rmtree') as mock_rmtree:
# First exists(): directory doesn't exist (False)
# Second exists(): .git directory doesn't exist (False)
mock_exists.side_effect = [False, False]
# Execute command
await commands_cog.add_project_command.callback(
commands_cog,
mock_interaction,
"test-project",
"https://git.example.com/test.git",
None,
"sonnet"
)
# Verify cleanup was attempted
mock_rmtree.assert_called_once()
# Verify error message
mock_interaction.followup.send.assert_called_once()
call_args = mock_interaction.followup.send.call_args
assert "doesn't appear to be a git repository" in call_args[0][0]
@pytest.mark.asyncio
async def test_add_project_config_rollback(self, commands_cog, mock_interaction):
"""Test add-project rolls back on config save failure."""
# Setup
commands_cog.config.get_project_by_channel.return_value = None
# Mock git clone subprocess (success)
mock_process = AsyncMock()
mock_process.returncode = 0
mock_process.communicate.return_value = (b'', b'')
with patch('asyncio.create_subprocess_exec', return_value=mock_process), \
patch('asyncio.wait_for', return_value=(b'', b'')), \
patch('os.path.exists') as mock_exists, \
patch('os.path.join', return_value='/opt/projects/test-project/.git'), \
patch('shutil.rmtree') as mock_rmtree:
# exists() checks: directory doesn't exist, .git exists
mock_exists.side_effect = [False, True]
# Mock config operations with save() failing
commands_cog.config.add_project = MagicMock()
commands_cog.config.save = MagicMock(side_effect=IOError("Disk full"))
# Execute command
await commands_cog.add_project_command.callback(
commands_cog,
mock_interaction,
"test-project",
"https://git.example.com/test.git",
None,
"sonnet"
)
# Verify rollback was performed
mock_rmtree.assert_called_once_with('/opt/projects/test-project')
# Verify error message mentions rollback
mock_interaction.followup.send.assert_called_once()
call_args = mock_interaction.followup.send.call_args
assert "Rolled back" in call_args[0][0]
@pytest.mark.asyncio
async def test_add_project_permission_check(self, commands_cog, mock_interaction):
"""Test add-project command has administrator permission requirement."""
# Verify the command has the permission decorator
assert hasattr(commands_cog.add_project_command, 'checks')
@pytest.mark.asyncio
async def test_add_project_error_handler(self, commands_cog, mock_interaction):
"""Test add-project permission error handler."""
# Create permission error
error = app_commands.MissingPermissions(['administrator'])
# Call error handler
await commands_cog.add_project_error(mock_interaction, error)
# Verify error message was sent
mock_interaction.response.send_message.assert_called_once()
call_args = mock_interaction.response.send_message.call_args
assert "Administrator" in call_args[0][0]
assert call_args[1]['ephemeral'] is True
@pytest.mark.asyncio
async def test_add_project_with_target_channel(self, commands_cog, mock_interaction):
"""Test add-project with explicit target channel parameter."""
# Setup target channel
target_channel = MagicMock()
target_channel.id = 999888777
target_channel.mention = "#target-channel"
# Setup mocks
commands_cog.config.get_project_by_channel.return_value = None
# Mock git clone subprocess
mock_process = AsyncMock()
mock_process.returncode = 0
mock_process.communicate.return_value = (b'', b'')
with patch('asyncio.create_subprocess_exec', return_value=mock_process), \
patch('asyncio.wait_for', return_value=(b'', b'')), \
patch('os.path.exists') as mock_exists, \
patch('os.path.join', return_value='/opt/projects/test-project/.git'):
mock_exists.side_effect = [False, True]
# Mock config operations
commands_cog.config.add_project = MagicMock()
commands_cog.config.save = MagicMock()
commands_cog.config.load = MagicMock()
# Execute command with target channel
await commands_cog.add_project_command.callback(
commands_cog,
mock_interaction,
"test-project",
"https://git.example.com/test.git",
target_channel, # Explicit channel
"opus"
)
# Verify target channel was used
add_project_call = commands_cog.config.add_project.call_args
assert add_project_call[0][0]['channel_id'] == '999888777'
assert add_project_call[0][0]['model'] == 'opus'
class TestAddProjectIntegration:
"""Integration tests for add-project configuration."""
@pytest.mark.asyncio
async def test_config_structure(self, commands_cog, mock_interaction):
"""Test add-project creates correct config structure."""
# Setup
commands_cog.config.get_project_by_channel.return_value = None
# Capture the add_project call
captured_config = None
def capture_config(config):
nonlocal captured_config
captured_config = config
commands_cog.config.add_project = MagicMock(side_effect=capture_config)
commands_cog.config.save = MagicMock()
commands_cog.config.load = MagicMock()
# Mock git operations
mock_process = AsyncMock()
mock_process.returncode = 0
mock_process.communicate.return_value = (b'', b'')
with patch('asyncio.create_subprocess_exec', return_value=mock_process), \
patch('asyncio.wait_for', return_value=(b'', b'')), \
patch('os.path.exists') as mock_exists, \
patch('os.path.join', return_value='/opt/projects/my-project/.git'):
mock_exists.side_effect = [False, True]
# Execute
await commands_cog.add_project_command.callback(
commands_cog,
mock_interaction,
"my-project",
"https://git.example.com/my-project.git",
None,
"haiku"
)
# Verify config structure
assert captured_config is not None
assert captured_config['name'] == 'my-project'
assert captured_config['channel_id'] == '123456789'
assert captured_config['project_dir'] == '/opt/projects/my-project'
assert captured_config['model'] == 'haiku'
assert 'Bash' in captured_config['allowed_tools']
assert 'Read' in captured_config['allowed_tools']