#!/usr/bin/env python3 """ config-validate — Pre-deploy validation for timmy-config YAML files. Validates YAML syntax, required keys, and value types before writing config to disk. Prevents broken deploys from bad config. Usage: python scripts/config_validate.py config.yaml python scripts/config_validate.py config.yaml --deploy # Validate + write python scripts/config_validate.py --schema # Show expected schema """ import argparse import json import sys from pathlib import Path from typing import Any, Dict, List, Optional, Tuple try: import yaml HAS_YAML = True except ImportError: HAS_YAML = False # Expected schema for hermes config.yaml REQUIRED_KEYS = { "model": {"type": str, "required": True, "description": "Default model name"}, } OPTIONAL_KEYS = { "provider": {"type": str, "required": False, "description": "Default provider"}, "providers": {"type": dict, "required": False, "description": "Provider configuration"}, "fallback_providers": {"type": list, "required": False, "description": "Fallback chain"}, "toolsets": {"type": list, "required": False, "description": "Enabled toolsets"}, "agent": {"type": dict, "required": False, "description": "Agent configuration"}, "display": {"type": dict, "required": False, "description": "Display settings"}, } # Keys that should NOT be present FORBIDDEN_KEYS = { "anthropic_api_key": "Use ANTHROPIC_API_KEY env var instead", "openai_api_key": "Use OPENAI_API_KEY env var instead", "password": "Never put passwords in config", "secret": "Never put secrets in config", } class ValidationError: def __init__(self, path: str, message: str, severity: str = "error"): self.path = path self.message = message self.severity = severity def __str__(self): return f"[{self.severity.upper()}] {self.path}: {self.message}" def validate_yaml_syntax(content: str) -> Tuple[Optional[Dict], List[ValidationError]]: """Validate YAML can be parsed.""" errors = [] if not HAS_YAML: return None, [ValidationError("root", "PyYAML not installed", "error")] try: data = yaml.safe_load(content) if data is None: return {}, [] if not isinstance(data, dict): errors.append(ValidationError("root", f"Expected dict, got {type(data).__name__}", "error")) return None, errors return data, errors except yaml.YAMLError as e: errors.append(ValidationError("syntax", str(e), "error")) return None, errors def validate_required_keys(data: Dict[str, Any]) -> List[ValidationError]: """Check required keys exist.""" errors = [] for key, spec in REQUIRED_KEYS.items(): if key not in data: errors.append(ValidationError(key, f"Required key missing: {key}", "error")) elif not isinstance(data[key], spec["type"]): errors.append ValidationError(key, f"Expected {spec['type'].__name__}, got {type(data[key]).__name__}", "error")) return errors def validate_value_types(data: Dict[str, Any], schema: Dict[str, Dict]) -> List[ValidationError]: """Check value types match schema.""" errors = [] for key, spec in schema.items(): if key in data: expected_type = spec["type"] actual = data[key] if not isinstance(actual, expected_type): errors.append(ValidationError(key, f"Expected {expected_type.__name__}, got {type(actual).__name__}", "error")) return errors def validate_no_forbidden_keys(data: Dict[str, Any]) -> List[ValidationError]: """Check for keys that should not be in config.""" errors = [] for key, reason in FORBIDDEN_KEYS.items(): if key in data: errors.append(ValidationError(key, f"Forbidden key: {reason}", "error")) return errors def validate_nested(data: Dict[str, Any], path: str = "") -> List[ValidationError]: """Recursively validate nested structures.""" errors = [] # Check providers dict if "providers" in data and isinstance(data["providers"], dict): for provider_name, provider_config in data["providers"].items(): if not isinstance(provider_config, dict): errors.append(ValidationError(f"providers.{provider_name}", "Provider config must be a dict", "error")) # Check agent dict if "agent" in data and isinstance(data["agent"], dict): agent = data["agent"] if "max_turns" in agent and not isinstance(agent["max_turns"], int): errors.append(ValidationError("agent.max_turns", "Must be an integer", "error")) if "reasoning_effort" in agent and agent["reasoning_effort"] not in (None, "low", "medium", "high"): errors.append(ValidationError("agent.reasoning_effort", "Must be low/medium/high", "error")) # Check toolsets is list of strings if "toolsets" in data and isinstance(data["toolsets"], list): for i, ts in enumerate(data["toolsets"]): if not isinstance(ts, str): errors.append(ValidationError(f"toolsets[{i}]", "Toolset must be a string", "error")) return errors def validate_config(content: str) -> Tuple[bool, List[ValidationError]]: """Full validation pipeline. Returns (valid, errors).""" all_errors = [] # Step 1: YAML syntax data, errors = validate_yaml_syntax(content) all_errors.extend(errors) if data is None: return False, all_errors # Step 2: Required keys all_errors.extend(validate_required_keys(data)) # Step 3: Value types (required + optional) all_errors.extend(validate_value_types(data, {**REQUIRED_KEYS, **OPTIONAL_KEYS})) # Step 4: Forbidden keys all_errors.extend(validate_no_forbidden_keys(data)) # Step 5: Nested validation all_errors.extend(validate_nested(data)) # Any errors = invalid has_errors = any(e.severity == "error" for e in all_errors) return not has_errors, all_errors def print_schema(): """Print expected config schema.""" print("Required keys:") for key, spec in REQUIRED_KEYS.items(): print(f" {key}: {spec['type'].__name__} — {spec['description']}") print("\nOptional keys:") for key, spec in OPTIONAL_KEYS.items(): print(f" {key}: {spec['type'].__name__} — {spec['description']}") print("\nForbidden keys:") for key, reason in FORBIDDEN_KEYS.items(): print(f" {key} — {reason}") def main(): parser = argparse.ArgumentParser(description="Validate timmy-config YAML files") parser.add_argument("file", nargs="?", help="Config file to validate") parser.add_argument("--deploy", action="store_true", help="Validate then deploy (write)") parser.add_argument("--schema", action="store_true", help="Show expected schema") args = parser.parse_args() if args.schema: print_schema() return if not args.file: parser.print_help() return path = Path(args.file) if not path.exists(): print(f"Error: {path} not found") sys.exit(1) content = path.read_text() valid, errors = validate_config(content) if errors: print(f"Validation results for {path}:") for err in errors: print(f" {err}") print() if valid: print(f"✓ {path} is valid") if args.deploy: print(f"Deploying {path}...") # In real usage, this would write to ~/.hermes/config.yaml print("Deploy complete.") else: print(f"✗ {path} has {sum(1 for e in errors if e.severity == 'error')} error(s)") print("Fix errors before deploying.") sys.exit(1) if __name__ == "__main__": main()