repo_config.py•11 kB
"""Repository discovery and configuration management for global Scribe deployment.
This module enables Scribe to automatically detect the current repository root
and load per-repository configuration, making it a true drop-in MCP solution.
"""
from __future__ import annotations
import logging
import os
import yaml
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
# Setup structured logging for repository configuration operations
repo_config_logger = logging.getLogger(__name__)
@dataclass
class RepoConfig:
"""Per-repository configuration for Scribe."""
# Core repository identification
repo_slug: str
repo_root: Path
# Documentation structure
dev_plans_dir: Path = field(default_factory=lambda: Path("docs/dev_plans"))
progress_log_name: str = "PROGRESS_LOG.md"
# Template and customization
templates_pack: str = "default"
custom_templates_dir: Optional[Path] = None
# Permissions and constraints
permissions: Dict[str, bool] = field(default_factory=dict)
# Plugin configuration
plugins_dir: Optional[Path] = None
plugin_config: Dict[str, Any] = field(default_factory=dict)
# Project defaults
default_emoji: str = "📋"
default_agent: str = "Agent"
reminder_config: Dict[str, Any] = field(default_factory=dict)
# Hooks configuration
hooks: Dict[str, Optional[str]] = field(default_factory=dict)
# Scribe MCP specific settings
mcp_server_name: str = "scribe.mcp"
storage_backend: str = "sqlite" # sqlite or postgres
db_path: Optional[Path] = None # for sqlite
@classmethod
def from_dict(cls, data: Dict[str, Any], repo_root: Path) -> "RepoConfig":
"""Create RepoConfig from dictionary data."""
# Resolve path fields relative to repo root
dev_plans_dir = repo_root / Path(data.get("dev_plans_dir", "docs/dev_plans"))
custom_templates_dir = None
if data.get("custom_templates_dir"):
custom_templates_dir = repo_root / Path(data["custom_templates_dir"])
plugins_dir = None
if data.get("plugins_dir"):
plugins_dir = repo_root / Path(data["plugins_dir"])
db_path = None
if data.get("db_path"):
db_path = repo_root / Path(data["db_path"])
return cls(
repo_slug=data.get("repo_slug", repo_root.name),
repo_root=repo_root,
dev_plans_dir=dev_plans_dir,
progress_log_name=data.get("progress_log_name", "PROGRESS_LOG.md"),
templates_pack=data.get("templates_pack", "default"),
custom_templates_dir=custom_templates_dir,
permissions=data.get("permissions", {}),
plugins_dir=plugins_dir,
plugin_config=data.get("plugin_config", {}),
default_emoji=data.get("default_emoji", "📋"),
default_agent=data.get("default_agent", "Agent"),
reminder_config=data.get("reminder_config", {}),
hooks=data.get("hooks", {}),
mcp_server_name=data.get("mcp_server_name", "scribe.mcp"),
storage_backend=data.get("storage_backend", "sqlite"),
db_path=db_path,
)
@classmethod
def defaults_for_repo(cls, repo_root: Path) -> "RepoConfig":
"""Create default RepoConfig for a repository."""
return cls(
repo_slug=repo_root.name,
repo_root=repo_root,
dev_plans_dir=repo_root / "docs/dev_plans",
)
def to_dict(self) -> Dict[str, Any]:
"""Convert RepoConfig to dictionary for serialization."""
result = {
"repo_slug": self.repo_slug,
"repo_root": str(self.repo_root),
"dev_plans_dir": str(self.dev_plans_dir.relative_to(self.repo_root)),
"progress_log_name": self.progress_log_name,
"templates_pack": self.templates_pack,
"permissions": self.permissions,
"plugin_config": self.plugin_config,
"default_emoji": self.default_emoji,
"default_agent": self.default_agent,
"reminder_config": self.reminder_config,
"hooks": self.hooks,
"mcp_server_name": self.mcp_server_name,
"storage_backend": self.storage_backend,
}
if self.custom_templates_dir:
result["custom_templates_dir"] = str(self.custom_templates_dir.relative_to(self.repo_root))
if self.plugins_dir:
result["plugins_dir"] = str(self.plugins_dir.relative_to(self.repo_root))
if self.db_path:
result["db_path"] = str(self.db_path.relative_to(self.repo_root))
return result
def get_progress_log_path(self, project_name: Optional[str] = None) -> Path:
"""Get the full path to the progress log for a project."""
if project_name:
return self.dev_plans_dir / project_name / self.progress_log_name
return self.dev_plans_dir / self.repo_slug / self.progress_log_name
def get_project_docs_dir(self, project_name: str) -> Path:
"""Get the full path to a project's documentation directory."""
return self.dev_plans_dir / project_name
class RepoDiscovery:
"""Repository discovery and configuration loading."""
@staticmethod
def find_repo_root(start_path: Optional[Path] = None) -> Optional[Path]:
"""
Find the repository root by searching up from start_path.
Looks for:
- .git directory
- .scribe directory (Scribe-specific marker)
- pyproject.toml (Python project marker)
- package.json (Node.js project marker)
Args:
start_path: Path to start searching from (defaults to current working directory)
Returns:
Repository root path or None if not found
"""
if start_path is None:
start_path = Path.cwd()
current = start_path.resolve()
# Walk up the directory tree
while current != current.parent:
# Check for repository markers
markers = [
".git",
".scribe", # Scribe-specific marker
"pyproject.toml",
"package.json",
"Cargo.toml",
"go.mod",
]
for marker in markers:
if (current / marker).exists():
return current
# Check for scribe config file directly
if (current / ".scribe" / "scribe.yaml").exists():
return current
current = current.parent
# Check root directory as last resort
for marker in [".git", ".scribe", "pyproject.toml", "package.json"]:
if (current / marker).exists():
return current
return None
@staticmethod
def load_config(repo_root: Path) -> RepoConfig:
"""
Load Scribe configuration for a repository.
Search order:
1. .scribe/scribe.yaml
2. .scribe/scribe.yml
3. docs/dev_plans/scribe.yaml
4. .scribe/config.json
5. Create default config
Args:
repo_root: Repository root path
Returns:
Loaded or default RepoConfig
"""
config_paths = [
repo_root / ".scribe" / "scribe.yaml",
repo_root / ".scribe" / "scribe.yml",
repo_root / "docs" / "dev_plans" / "scribe.yaml",
repo_root / ".scribe" / "config.json",
]
for config_path in config_paths:
if config_path.exists():
try:
if config_path.suffix in ['.yaml', '.yml']:
with open(config_path, 'r') as f:
data = yaml.safe_load(f) or {}
else: # JSON
import json
with open(config_path, 'r') as f:
data = json.load(f)
repo_config_logger.info(f"Successfully loaded config from {config_path}")
return RepoConfig.from_dict(data, repo_root)
except Exception as e:
repo_config_logger.warning(f"Failed to load config from {config_path}: {e}")
continue
# No config found, return defaults
return RepoConfig.defaults_for_repo(repo_root)
@staticmethod
def ensure_config(repo_root: Path, config: RepoConfig) -> None:
"""
Ensure Scribe configuration exists in repository.
Creates .scribe directory and scribe.yaml if they don't exist.
Args:
repo_root: Repository root path
config: Configuration to save
"""
scribe_dir = repo_root / ".scribe"
scribe_dir.mkdir(parents=True, exist_ok=True)
config_file = scribe_dir / "scribe.yaml"
if not config_file.exists():
try:
with open(config_file, 'w') as f:
yaml.dump(config.to_dict(), f, default_flow_style=False, indent=2)
repo_config_logger.info(f"Successfully created Scribe config at {config_file}")
except Exception as e:
repo_config_logger.error(f"Failed to create config file: {e}")
raise
@staticmethod
def discover_or_create(start_path: Optional[Path] = None) -> Tuple[Path, RepoConfig]:
"""
Discover repository and load or create configuration.
Args:
start_path: Path to start discovery from (defaults to cwd)
Returns:
Tuple of (repo_root, config)
Raises:
RuntimeError: If no repository root can be found
"""
repo_root = RepoDiscovery.find_repo_root(start_path)
if not repo_root:
raise RuntimeError(
f"Could not find repository root starting from {start_path or Path.cwd()}. "
"Create a .git repository or add a .scribe directory to mark this as a project."
)
config = RepoDiscovery.load_config(repo_root)
# Ensure basic structure exists
config.dev_plans_dir.mkdir(parents=True, exist_ok=True)
return repo_root, config
# Global cache for discovered configuration
_current_repo_config: Optional[Tuple[Path, RepoConfig]] = None
def get_current_repo_config(refresh: bool = False) -> Tuple[Path, RepoConfig]:
"""
Get the current repository configuration, with caching.
Args:
refresh: Force rediscovery even if cached
Returns:
Tuple of (repo_root, config)
"""
global _current_repo_config
if refresh or _current_repo_config is None:
_current_repo_config = RepoDiscovery.discover_or_create()
return _current_repo_config
def reload_repo_config() -> Tuple[Path, RepoConfig]:
"""Force reload of repository configuration."""
return get_current_repo_config(refresh=True)