"""Bootstrap helpers for creating user-global and project-local config files.
Auto-creates the user-global Ralph config set on first run, including
~/.config/ralph-workflow.toml, ~/.config/ralph-workflow-mcp.toml,
~/.config/ralph-workflow-pipeline.toml, and
~/.config/ralph-workflow-artifacts.toml from bundled templates.
Also supports regenerating configs with .bak backups via --regenerate-config.
Bootstrap creates the standard first-run config set:
- User-global: ~/.config/ralph-workflow.toml, ~/.config/ralph-workflow-mcp.toml,
~/.config/ralph-workflow-pipeline.toml,
~/.config/ralph-workflow-artifacts.toml
- Project-local: .agent/ralph-workflow.toml, .agent/mcp.toml,
.agent/pipeline.toml, .agent/artifacts.toml
- Advanced optional: .agent/agents.toml (only regenerated when already present)
"""
from __future__ import annotations
import os
import shutil
from collections.abc import Mapping
from dataclasses import dataclass
from importlib import import_module
from pathlib import Path
from typing import TYPE_CHECKING, Literal, cast
from ralph.config.loader import load_toml
from ralph.git.operations import append_to_gitignore
if TYPE_CHECKING:
from types import ModuleType
_GLOBAL_CONFIG_FILENAME = "ralph-workflow.toml"
_GLOBAL_MCP_FILENAME = "ralph-workflow-mcp.toml"
_GLOBAL_PIPELINE_FILENAME = "ralph-workflow-pipeline.toml"
_GLOBAL_ARTIFACTS_FILENAME = "ralph-workflow-artifacts.toml"
_LOCAL_CONFIG_FILENAME = "ralph-workflow.toml"
_LOCAL_MCP_FILENAME = "mcp.toml"
_LOCAL_POLICY_FILENAMES = ("pipeline.toml", "artifacts.toml")
_GLOBAL_POLICY_FILENAME_MAP = {
"pipeline.toml": _GLOBAL_PIPELINE_FILENAME,
"artifacts.toml": _GLOBAL_ARTIFACTS_FILENAME,
}
_ADVANCED_LOCAL_POLICY_FILENAMES = ("agents.toml",)
_LOCAL_CONFIG_SOURCE = "ralph-workflow-local.toml"
_DEFAULT_GITIGNORE_PATTERNS = (".agent/", "/PROMPT*", "wt-*/")
def _module_attr_or_none(module: ModuleType, attribute: str) -> object | None:
namespace = cast("dict[str, object]", module.__dict__)
return namespace.get(attribute)
def _get_bundled_defaults_dir() -> Path:
"""Return the path to the bundled default policy files.
Computed lazily to avoid circular import: ralph.policy.loader imports
ralph.phases which imports ralph.pipeline which imports ralph.config.
"""
policy_module = import_module("ralph.policy")
policy_file = _module_attr_or_none(policy_module, "__file__")
if not isinstance(policy_file, str):
raise RuntimeError("ralph.policy module has no __file__")
return Path(policy_file).parent / "defaults"
[docs]
@dataclass(frozen=True)
class BootstrapResult:
"""Result of a bootstrap operation.
Attributes:
path: Target file path that was acted on.
action: What happened: created, skipped, or regenerated.
backup: Path to the .bak file if the original was backed up, else None.
"""
path: Path
action: Literal["created", "skipped", "regenerated"]
backup: Path | None = None
[docs]
def resolve_global_config_dir(env: Mapping[str, str] | None = None) -> Path:
"""Resolve the user-global config directory.
Honors XDG_CONFIG_HOME when set; falls back to ~/.config.
Args:
env: Environment mapping to read from. Uses os.environ when None.
Returns:
Path to the config directory.
"""
env_map: Mapping[str, str] = os.environ if env is None else env
xdg = env_map.get("XDG_CONFIG_HOME", "")
if xdg:
return Path(xdg)
return Path.home() / ".config"
[docs]
def ensure_global_config(global_dir: Path | None = None, *, force: bool = False) -> BootstrapResult:
"""Ensure ~/.config/ralph-workflow.toml exists, creating it from the bundled template.
Args:
global_dir: Override the global config directory. Defaults to resolve_global_config_dir().
force: When True, overwrite an existing file (backs it up to <name>.bak first).
Returns:
BootstrapResult describing the action taken.
"""
if global_dir is None:
global_dir = resolve_global_config_dir()
target = global_dir / _GLOBAL_CONFIG_FILENAME
source = _get_bundled_defaults_dir() / _GLOBAL_CONFIG_FILENAME
result = _copy_with_backup(source, target, force)
if result.action == "skipped":
migrated = _migrate_legacy_global_config(target)
if migrated is not None:
return migrated
return result
[docs]
def ensure_global_mcp_config(
global_dir: Path | None = None, *, force: bool = False
) -> BootstrapResult:
"""Ensure ~/.config/ralph-workflow-mcp.toml exists, creating it from the bundled template.
Args:
global_dir: Override the global config directory. Defaults to resolve_global_config_dir().
force: When True, overwrite an existing file (backs it up to <name>.bak first).
Returns:
BootstrapResult describing the action taken.
"""
if global_dir is None:
global_dir = resolve_global_config_dir()
target = global_dir / _GLOBAL_MCP_FILENAME
source = _get_bundled_defaults_dir() / "mcp.toml"
return _copy_with_backup(source, target, force)
[docs]
def ensure_global_policy_configs(
global_dir: Path | None = None, *, force: bool = False
) -> list[BootstrapResult]:
"""Ensure the user-global policy defaults exist.
Args:
global_dir: Override the global config directory. Defaults to resolve_global_config_dir().
force: When True, overwrite existing files (backs them up first).
Returns:
List of BootstrapResult, one per global policy file.
"""
if global_dir is None:
global_dir = resolve_global_config_dir()
return [
_copy_with_backup(
_resolve_global_policy_source(global_dir, policy_filename, force=force),
global_dir / _global_policy_target_name(policy_filename),
force,
)
for policy_filename in _LOCAL_POLICY_FILENAMES
]
[docs]
def ensure_local_main_config(agent_dir: Path, *, force: bool = False) -> BootstrapResult:
"""Ensure the project-local main override exists.
Args:
agent_dir: The .agent directory to write configs into.
force: When True, overwrite an existing file (backing it up first).
Returns:
BootstrapResult describing the action taken for `.agent/ralph-workflow.toml`.
"""
agent_dir.mkdir(parents=True, exist_ok=True)
global_source = resolve_global_config_dir() / _GLOBAL_CONFIG_FILENAME
source = (
global_source
if global_source.exists()
else _get_bundled_defaults_dir() / _LOCAL_CONFIG_SOURCE
)
return _copy_with_backup(
source,
agent_dir / _LOCAL_CONFIG_FILENAME,
force,
)
[docs]
def ensure_local_support_configs(agent_dir: Path, *, force: bool = False) -> list[BootstrapResult]:
"""Ensure the standard project-local policy and MCP files exist.
This scaffolds the `.agent/` files Ralph needs for project-local runtime behavior
without creating the optional project-local main override.
Args:
agent_dir: The .agent directory to write configs into.
force: When True, overwrite existing files (backs them up first).
Returns:
List of BootstrapResult, one per support file.
"""
agent_dir.mkdir(parents=True, exist_ok=True)
global_dir = resolve_global_config_dir()
global_mcp_source = global_dir / _GLOBAL_MCP_FILENAME
mcp_source = (
global_mcp_source
if global_mcp_source.exists()
else _get_bundled_defaults_dir() / "mcp.toml"
)
results: list[BootstrapResult] = [
_copy_with_backup(
mcp_source,
agent_dir / _LOCAL_MCP_FILENAME,
force,
)
]
results.extend(
_copy_with_backup(
_resolve_global_policy_source(global_dir, policy_filename, force=False),
agent_dir / policy_filename,
force,
)
for policy_filename in _LOCAL_POLICY_FILENAMES
)
_ensure_default_gitignore(agent_dir.parent)
return results
[docs]
def ensure_local_configs(agent_dir: Path, *, force: bool = False) -> list[BootstrapResult]:
"""Ensure the full project-local config set exists.
Args:
agent_dir: The .agent directory to write configs into.
force: When True, overwrite existing files (backs them up first).
Returns:
List of BootstrapResult, one per config file.
"""
return [
ensure_local_main_config(agent_dir, force=force),
*ensure_local_support_configs(agent_dir, force=force),
]
def _ensure_default_gitignore(repo_root: Path) -> None:
append_to_gitignore(repo_root, list(_DEFAULT_GITIGNORE_PATTERNS))
def _regenerate_existing_advanced_local_configs(agent_dir: Path) -> list[BootstrapResult]:
"""Regenerate advanced local configs only when they already exist."""
results: list[BootstrapResult] = []
for policy_filename in _ADVANCED_LOCAL_POLICY_FILENAMES:
target = agent_dir / policy_filename
if target.exists():
results.append(
_copy_with_backup(
_get_bundled_defaults_dir() / policy_filename,
target,
True,
)
)
return results
[docs]
def regenerate_all(
*,
global_dir: Path | None = None,
agent_dir: Path | None = None,
) -> list[BootstrapResult]:
"""Regenerate all configs from bundled defaults, backing up existing files.
Args:
global_dir: Override the global config directory. Defaults to resolve_global_config_dir().
agent_dir: The .agent directory to regenerate local configs in. Skipped when None.
Returns:
Flat list of BootstrapResult for every file touched.
"""
results: list[BootstrapResult] = [
ensure_global_config(global_dir, force=True),
ensure_global_mcp_config(global_dir, force=True),
*ensure_global_policy_configs(global_dir, force=True),
]
if agent_dir is not None:
results.extend(ensure_local_configs(agent_dir, force=True))
results.extend(_regenerate_existing_advanced_local_configs(agent_dir))
return results
def _backup_path(target: Path) -> Path:
return target.with_suffix(target.suffix + ".bak")
def _global_policy_target_name(local_policy_filename: str) -> str:
return _GLOBAL_POLICY_FILENAME_MAP.get(local_policy_filename, local_policy_filename)
def _resolve_global_policy_source(
global_dir: Path, local_policy_filename: str, *, force: bool
) -> Path:
if force:
return _get_bundled_defaults_dir() / local_policy_filename
preferred_global_path = global_dir / _global_policy_target_name(local_policy_filename)
if preferred_global_path.exists():
return preferred_global_path
legacy_global_path = global_dir / local_policy_filename
if legacy_global_path.exists():
return _get_bundled_defaults_dir() / local_policy_filename
return _get_bundled_defaults_dir() / local_policy_filename
def _migrate_legacy_global_config(target: Path) -> BootstrapResult | None:
try:
text = target.read_text(encoding="utf-8")
except OSError:
return None
data = cast("Mapping[str, object]", load_toml(target))
raw_drains_obj: object = data.get("agent_drains")
if not isinstance(raw_drains_obj, Mapping):
return None
raw_drains = cast("Mapping[str, object]", raw_drains_obj)
drains: dict[str, object] = {
key: value for key, value in raw_drains.items() if isinstance(key, str)
}
missing: list[tuple[str, str]] = []
analysis_chain: object = drains.get("analysis")
commit_chain: object = drains.get("commit")
if isinstance(analysis_chain, str):
missing.extend(
(drain_name, analysis_chain)
for drain_name in ("planning_analysis", "development_analysis")
if drain_name not in drains
)
if isinstance(commit_chain, str) and "development_commit" not in drains:
missing.append(("development_commit", commit_chain))
section_start = text.find("[agent_drains]")
if not missing or section_start == -1:
return None
next_section = text.find("\n[", section_start + len("[agent_drains]"))
insert_at = len(text) if next_section == -1 else next_section + 1
insert_lines = "".join(f'{name} = "{chain}"\n' for name, chain in missing)
if insert_at == len(text) and not text.endswith("\n"):
insert_lines = "\n" + insert_lines
backup = _backup_path(target)
if backup.exists():
backup.unlink()
shutil.copy2(str(target), str(backup))
target.write_text(text[:insert_at] + insert_lines + text[insert_at:], encoding="utf-8")
return BootstrapResult(target, "regenerated", backup)
def _copy_with_backup(source: Path, target: Path, force: bool) -> BootstrapResult:
"""Copy source to target, optionally backing up an existing target first.
The backup (.bak) is always in the same directory as target, so a
cross-device move is impossible and shutil.move is safe.
Args:
source: Bundled template to copy.
target: Destination path.
force: When True, overwrite target (with backup). When False, skip if target exists.
Returns:
BootstrapResult describing what happened.
"""
target.parent.mkdir(parents=True, exist_ok=True)
pre_existed = target.exists()
if pre_existed and not force:
return BootstrapResult(target, "skipped", None)
backup: Path | None = None
if pre_existed and force:
backup = _backup_path(target)
if backup.exists():
backup.unlink()
shutil.move(str(target), str(backup))
shutil.copy2(str(source), str(target))
action: Literal["created", "skipped", "regenerated"] = (
"regenerated" if pre_existed else "created"
)
return BootstrapResult(target, action, backup)