From ae565b0d68d2e75be3cf7c79322953245f328ab5 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Sun, 17 May 2026 12:49:22 +0200 Subject: [PATCH] feat(config): directory bootstrap and config manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - config/schema.py: Pydantic v2 models — no API keys, only provider_id/model/base_url - config/manager.py: ruamel.yaml round-trip load/save, chmod 600 enforced on write - config/dirs.py: bootstrap() creates ~/.pyra/ tree, vault sentinel checked every startup Co-Authored-By: Claude Sonnet 4.6 --- src/pyra/config/dirs.py | 61 ++++++++++++++++++++++++++++++++++++++ src/pyra/config/manager.py | 39 ++++++++++++++++++++++++ src/pyra/config/schema.py | 24 +++++++++++++++ 3 files changed, 124 insertions(+) create mode 100644 src/pyra/config/dirs.py create mode 100644 src/pyra/config/manager.py create mode 100644 src/pyra/config/schema.py diff --git a/src/pyra/config/dirs.py b/src/pyra/config/dirs.py new file mode 100644 index 0000000..a866197 --- /dev/null +++ b/src/pyra/config/dirs.py @@ -0,0 +1,61 @@ +from pathlib import Path + +from pyra.security.boundaries import PyraSecurityError, check_vault_lock +from pyra.utils.paths import ensure_dir, pyra_home, safe_chmod + +_MEMORY_INDEX_TEMPLATE = """\ +# Memory Index + +| File | Category | Last Modified | +|------|----------|---------------| + +_Auto-maintained by Pyra. Do not edit manually._ +""" + +_USER_PROFILE_TEMPLATE = """\ +# User Profile + +_Add information about yourself here. Pyra will use this context in every session._ + +## Preferences + +## Background + +## Goals +""" + + +def bootstrap() -> None: + """Create ~/.pyra/ tree and verify vault sentinel. Called at every startup.""" + home = pyra_home() + + ensure_dir(home, 0o700) + ensure_dir(home / "memory" / "user", 0o700) + ensure_dir(home / "memory" / "context", 0o700) + ensure_dir(home / "memory" / "knowledge", 0o700) + ensure_dir(home / "skills" / "bash", 0o700) + ensure_dir(home / "skills" / "powershell", 0o700) + ensure_dir(home / "skills" / "python", 0o700) + ensure_dir(home / "vault" / "secrets", 0o700) + + _create_vault_lock(home / "vault" / ".vault_lock") + check_vault_lock() + + _create_if_missing(home / "memory" / "MEMORY_INDEX.md", _MEMORY_INDEX_TEMPLATE, 0o600) + _create_if_missing(home / "memory" / "user" / "profile.md", _USER_PROFILE_TEMPLATE, 0o600) + + config = home / "config.yaml" + if config.exists(): + safe_chmod(config, 0o600) + + +def _create_vault_lock(lock_path: Path) -> None: + if not lock_path.exists(): + lock_path.touch(mode=0o400) + safe_chmod(lock_path, 0o400) + + +def _create_if_missing(path: Path, content: str, mode: int) -> None: + if not path.exists(): + path.write_text(content) + safe_chmod(path, mode) diff --git a/src/pyra/config/manager.py b/src/pyra/config/manager.py new file mode 100644 index 0000000..de0b805 --- /dev/null +++ b/src/pyra/config/manager.py @@ -0,0 +1,39 @@ +import os +from pathlib import Path + +from ruamel.yaml import YAML + +from pyra.config.schema import PyraConfig +from pyra.utils.paths import pyra_home, safe_chmod + +_yaml = YAML() +_yaml.default_flow_style = False +_yaml.width = 80 + +_CONFIG_PATH = pyra_home() / "config.yaml" + + +def config_path() -> Path: + return _CONFIG_PATH + + +def load_config() -> PyraConfig: + if not _CONFIG_PATH.exists(): + raise FileNotFoundError( + "Pyra is not configured. Run 'pyra setup' to get started." + ) + with _CONFIG_PATH.open("r") as fh: + data = _yaml.load(fh) + return PyraConfig.model_validate(data) + + +def save_config(cfg: PyraConfig) -> None: + _CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + data = cfg.model_dump() + with _CONFIG_PATH.open("w") as fh: + _yaml.dump(data, fh) + safe_chmod(_CONFIG_PATH, 0o600) + + +def config_exists() -> bool: + return _CONFIG_PATH.exists() diff --git a/src/pyra/config/schema.py b/src/pyra/config/schema.py new file mode 100644 index 0000000..6948372 --- /dev/null +++ b/src/pyra/config/schema.py @@ -0,0 +1,24 @@ +from pydantic import BaseModel, Field + + +class ProviderConfig(BaseModel): + provider_id: str + model: str + base_url: str | None = None + + +class MemoryConfig(BaseModel): + max_tokens_in_context: int = 4000 + auto_load: bool = True + + +class SecurityConfig(BaseModel): + injection_detection: bool = True + log_injections: bool = True + + +class PyraConfig(BaseModel): + version: int = 1 + ai: ProviderConfig + memory: MemoryConfig = Field(default_factory=MemoryConfig) + security: SecurityConfig = Field(default_factory=SecurityConfig)