agentleFS
blueman82blueman82/ai-counsel/.claude/skills/config-schema-migrator/SKILL.md

config-schema-migrator

Skill1 starsChanged 12 months agoMITView on GitHub

Expert at evolving Pydantic configuration schemas with backward compatibility and automated migrations

  • Reads credentials

We split this skill into the rules it gives an agent

38 rules. About 0 are common, 23 are distinctive, and 15 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈23

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 15

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Write migration scripts to automate config updates

    line 13
  2. Distinctive:

    Add deprecation warnings for old config patterns

    line 15
  3. Distinctive:

    Create discriminated unions for config type discrimination

    line 17
  4. Distinctive:

    Backward Compatibility First: Never break existing configs without a migration path

    line 21
  5. Distinctive:

    Type Safety: Use Pydantic validators to catch config errors at load time

    line 24
  6. Very specific:

    Use Optional for both old and new sections

    line 70
  7. Very specific:

    Use Literal["value"] for discriminator field with default value

    line 126
  8. Very specific:

    Use @field_validator on fields that may contain env vars

    line 193
  9. Very specific:

    Use info.field_name to customize behavior per field

    line 194
  10. Very specific:

    Always load .env file first with dotenv.load_dotenv() in load_config()

    line 198
  11. Very specific:

    Always create backup: before modifying config file (shutil.copy2)

    line 315
  12. Distinctive:

    Idempotent migrations: Check if already migrated, return early if so

    line 316
  13. Very specific:

    YAML preservation: Use sort_keys=False to preserve key order

    line 321
  14. Very specific:

    Use Path(__file__).parent.parent to find project root

    line 389
  15. Distinctive:

    Keep absolute paths unchanged

    line 391
  16. Distinctive:

    Keep deprecated field with default value

    line 451
  17. Distinctive:

    Add "DEPRECATED" to field description

    line 452
  18. Distinctive:

    Document migration in code comments and CLAUDE.md

    line 454
  19. Distinctive:

    When you need to evolve a config schema:

    line 526
  20. Very specific:

    Add new section/fields as Optional (don't break existing configs)

    line 530
  21. Distinctive:

    Keep old section/fields for backward compatibility

    line 531
  22. Very specific:

    Add @field_validator for new field validation

    line 532
  23. Very specific:

    Add deprecation warnings in model_post_init()

    line 533
  24. Very specific:

    Create scripts/migrate_*.py with clear docstring

    line 537
  25. Very specific:

    Add main() with CLI argument parsing

    line 540
  26. Very specific:

    Write unit tests for migrate_config_dict()

    line 545
  27. Distinctive:

    Test idempotency (running twice is safe)

    line 546
  28. Distinctive:

    Test edge cases (missing sections, already migrated)

    line 547
  29. Distinctive:

    Verify migrated config loads successfully

    line 549
  30. Distinctive:

    Update CLAUDE.md with migration instructions

    line 553
  31. Very specific:

    Add migration notes to config.yaml comments

    line 554
  32. Distinctive:

    Update README if needed

    line 556
  33. Distinctive:

    Commit schema changes + migration script together

    line 560
  34. Distinctive:

    Keep backward compatibility for at least one major version

    line 563
  35. Distinctive:

    Never break existing configs: - always provide migration path

    line 616
  36. Distinctive:

    Automate migrations: - don't force manual editing

    line 617
  37. Distinctive:

    Support env vars: - never hardcode secrets

    line 619
  38. Distinctive:

    When you detect config schema evolution needs, activate this skill and follow these patterns to ensure smooth, backward-compatible …

    line 626

≈ “Common” is an estimate: at least 1 in 5 of the 308,232 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Config Schema Migrator Skill

When to Use This Skill

Activate this skill when you need to:

  • Evolve Pydantic configuration schemas (add fields, change types, restructure sections)
  • Maintain backward compatibility with existing config files
  • Write migration scripts to automate config updates
  • Implement environment variable substitution in config fields
  • Add deprecation warnings for old config patterns
  • Validate config schemas with field validators
  • Create discriminated unions for config type discrimination

Key Principles

  1. Backward Compatibility First: Never break existing configs without a migration path
  2. Deprecation Before Removal: Warn users before removing old config sections
  3. Automatic Migration: Provide scripts to automate config updates (don't force manual editing)
  4. Type Safety: Use Pydantic validators to catch config errors at load time
  5. Environment Variables: Support ${VAR_NAME} substitution for secrets
  6. Clear Messaging: Provide helpful error messages with migration instructions

Pattern 1: Adding New Config Sections with Backward Compatibility

Example: Adding adapters Section While Keeping cli_tools

Problem: You need to add a new config section (adapters) to replace an old one (cli_tools) without breaking existing configs.

Solution Pattern (from models/config.py):

from typing import Optional
from pydantic import BaseModel
import warnings

class Config(BaseModel):
    """Root configuration model."""

    # New section (preferred)
    adapters: Optional[dict[str, AdapterConfig]] = None

    # Legacy section (deprecated)
    cli_tools: Optional[dict[str, CLIToolConfig]] = None

    def model_post_init(self, __context):
        """Post-initialization validation."""
        # Ensure at least one section exists
        if self.adapters is None and self.cli_tools is None:
            raise ValueError(
                "Configuration must include either 'adapters' or 'cli_tools' section"
            )

        # Emit deprecation warning for old section
        if self.cli_tools is not None and self.adapters is None:
            warnings.warn(
                "The 'cli_tools' configuration section is deprecated. "
                "Please migrate to 'adapters' section with explicit 'type' field. "
                "See migration guide: docs/migration/cli_tools_to_adapters.md",
                DeprecationWarning,
                stacklevel=2,
            )

Key Techniques:

  • Use Optional for both old and new sections
  • Validate in model_post_init() that at least one exists
  • Emit DeprecationWarning when old section is used
  • Reference migration documentation in warning message
  • Allow both sections temporarily for gradual migration

Pattern 2: Type Discrimination with Discriminated Unions

Example: CLI vs HTTP Adapters

Problem: You have config objects that can be one of several types (CLI adapter, HTTP adapter, etc).

Solution Pattern (from models/config.py):

from typing import Annotated, Literal, Union
from pydantic import BaseModel, Field

class CLIAdapterConfig(BaseModel):
    """Configuration for CLI-based adapter."""
    type: Literal["cli"] = "cli"
    command: str
    args: list[str]
    timeout: int = 60

class HTTPAdapterConfig(BaseModel):
    """Configuration for HTTP-based adapter."""
    type: Literal["http"] = "http"
    base_url: str
    api_key: Optional[str] = None
    timeout: int = 60
    max_retries: int = 3

# Discriminated union - Pydantic uses 'type' field to determine which model
AdapterConfig = Annotated[
    Union[CLIAdapterConfig, HTTPAdapterConfig],
    Field(discriminator="type")
]

YAML Usage:

adapters:
  claude:
    type: cli  # Discriminator field
    command: "claude"
    args: ["-p", "{prompt}"]
    timeout: 60

  ollama:
    type: http  # Different type triggers HTTPAdapterConfig
    base_url: "http://localhost:11434"
    timeout: 120

Key Techniques:

  • Use Literal["value"] for discriminator field with default value
  • Create Annotated[Union[...], Field(discriminator="type")]
  • Pydantic automatically routes to correct model based on type field
  • Each type has different required fields (validated automatically)

Pattern 3: Environment Variable Substitution

Example: API Keys and Secrets

Problem: You need to inject secrets from environment variables without hardcoding in YAML.

Solution Pattern (from models/config.py):

import os
import re
from pydantic import BaseModel, field_validator

class HTTPAdapterConfig(BaseModel):
    """Configuration for HTTP-based adapter."""
    base_url: str
    api_key: Optional[str] = None

    @field_validator("api_key", "base_url")
    @classmethod
    def resolve_env_vars(cls, v: Optional[str], info) -> Optional[str]:
        """Resolve ${ENV_VAR} references in string fields."""
        if v is None:
            return v

        # Pattern: ${VAR_NAME}
        pattern = r"\$\{([^}]+)\}"
        is_api_key = info.field_name == "api_key"

        def replacer(match):
            env_var = match.group(1)
            value = os.getenv(env_var)
            if value is None:
                # For optional fields like api_key, use sentinel
                if is_api_key:
                    return "__MISSING_API_KEY__"
                # For required fields, raise error
                raise ValueError(
                    f"Environment variable '{env_var}' is not set. "
                    f"Required for configuration."
                )
            return value

        result = re.sub(pattern, replacer, v)

        # If api_key has sentinel marker, return None (graceful degradation)
        if is_api_key and "__MISSING_API_KEY__" in result:
            return None

        return result

YAML Usage:

adapters:
  openrouter:
    type: http
    base_url: "https://openrouter.ai/api/v1"
    api_key: "${OPENROUTER_API_KEY}"  # Resolved from environment

Key Techniques:

  • Use @field_validator on fields that may contain env vars
  • Use info.field_name to customize behavior per field
  • Regex pattern r"\$\{([^}]+)\}" to find ${VAR_NAME}
  • For optional fields (api_key): return None if env var missing
  • For required fields (base_url): raise ValueError if env var missing
  • Always load .env file first with dotenv.load_dotenv() in load_config()

Pattern 4: Writing Migration Scripts

Example: CLI Tools to Adapters Migration

Problem: You need to migrate existing config files from old format to new format automatically.

Solution Pattern (from scripts/migrate_config.py):

#!/usr/bin/env python3
"""
Migration script: cli_tools → adapters

Migrates config.yaml from legacy cli_tools format to new adapters format
with explicit type fields.

Usage:
    python scripts/migrate_config.py [path/to/config.yaml]
"""

import shutil
import sys
from pathlib import Path
from typing import Any, Dict
import yaml

def migrate_config_dict(config: Dict[str, Any]) -> Dict[str, Any]:
    """
    Migrate config dictionary from cli_tools to adapters format.

    Returns:
        Migrated config with adapters section
    """
    # If already migrated, return as-is
    if "adapters" in config and "cli_tools" not in config:
        print("Info: Config already migrated (has 'adapters' section)")
        return config

    # If no cli_tools, nothing to migrate
    if "cli_tools" not in config:
        print("Warning: No 'cli_tools' section found, nothing to migrate")
        return config

    # Create new config with adapters
    migrated = config.copy()

    # Transform cli_tools → adapters
    adapters = {}
    for name, cli_config in config["cli_tools"].items():
        adapters[name] = {
            "type": "cli",  # Add explicit type discriminator
            "command": cli_config["command"],
            "args": cli_config["args"],
            "timeout": cli_config["timeout"],
        }

    migrated["adapters"] = adapters
    del migrated["cli_tools"]

    print(f"Success: Migrated {len(adapters)} CLI tools to adapters format")
    return migrated

def migrate_config_file(path: str) -> None:
    """
    Migrate config file from cli_tools to adapters format.

    Creates a backup at {path}.bak before modifying.
    """
    config_path = Path(path)

    if not config_path.exists():
        raise FileNotFoundError(f"Config file not found: {path}")

    # Create backup BEFORE modifying
    backup_path = Path(f"{path}.bak")
    shutil.copy2(config_path, backup_path)
    print(f"Created backup: {backup_path}")

    # Load config
    with open(config_path, "r") as f:
        config = yaml.safe_load(f)

    # Migrate
    migrated = migrate_config_dict(config)

    # Write migrated config
    with open(config_path, "w") as f:
        yaml.dump(migrated, f, default_flow_style=False, sort_keys=False)

    print(f"Migrated config written to: {config_path}")
    print(f"\nInfo: Review the changes and delete {backup_path} when satisfied.")

def main():
    """Main entry point."""
    config_path = sys.argv[1] if len(sys.argv) > 1 else "config.yaml"

    print(f"Migrating config: {config_path}")
    print("-" * 50)

    try:
        migrate_config_file(config_path)
        print("\nMigration complete!")
        print("\nNext steps:")
        print("1. Review the migrated config.yaml")
        print("2. Test loading: python -c 'from models.config import load_config; load_config()'")
        print("3. Delete backup if satisfied: rm config.yaml.bak")
    except Exception as e:
        print(f"\nError: Migration failed: {e}")
        sys.exit(1)

if __name__ == "__main__":
    main()

Key Techniques:

  • Always create backup before modifying config file (shutil.copy2)
  • Idempotent migrations: Check if already migrated, return early if so
  • Separate dict and file logic: migrate_config_dict() for logic, migrate_config_file() for I/O
  • Clear console output: Print status messages for user feedback
  • Testing instructions: Print validation commands after migration
  • Error handling: Catch exceptions, print helpful message, exit with code 1
  • YAML preservation: Use sort_keys=False to preserve key order

Pattern 5: Field Validation for Path Resolution

Example: Database Path with Environment Variables

Problem: You need to resolve relative paths and environment variables for config fields.

Solution Pattern (from models/config.py):

import os
import re
from pathlib import Path
from pydantic import BaseModel, field_validator

class DecisionGraphConfig(BaseModel):
    """Configuration for decision graph memory."""
    db_path: str = "decision_graph.db"

    @field_validator("db_path")
    @classmethod
    def resolve_db_path(cls, v: str) -> str:
        """
        Resolve db_path to absolute path relative to project root.

        Processing steps:
        1. Resolve ${ENV_VAR} environment variable references
        2. Convert relative paths to absolute paths relative to project root
        3. Keep absolute paths unchanged
        4. Return normalized absolute path as string

        Examples:
            "decision_graph.db" → "/path/to/project/decision_graph.db"
            "/tmp/foo.db" → "/tmp/foo.db" (unchanged)
            "${DATA_DIR}/graph.db" → "/var/data/graph.db" (if DATA_DIR=/var/data)
        """
        # Step 1: Resolve environment variables
        pattern = r"\$\{([^}]+)\}"

        def replacer(match):
            env_var = match.group(1)
            value = os.getenv(env_var)
            if value is None:
                raise ValueError(
                    f"Environment variable '{env_var}' is not set. "
                    f"Required for db_path configuration."
                )
            return value

        resolved = re.sub(pattern, replacer, v)

        # Step 2: Convert to Path object
        path = Path(resolved)

        # Step 3: If relative, make it relative to project root
        if not path.is_absolute():
            # This file is at: project_root/models/config.py
            # Project root is two levels up from this file
            project_root = Path(__file__).parent.parent
            path = (project_root / path).resolve()

        # Step 4: Return as string (normalized, absolute)
        return str(path)

Key Techniques:

  • Resolve env vars BEFORE path resolution
  • Use Path(__file__).parent.parent to find project root
  • Convert relative paths to absolute (prevents CWD issues)
  • Keep absolute paths unchanged
  • Return as string for serialization compatibility

Pattern 6: Deprecating Fields with Validation

Example: Deprecating similarity_threshold in Favor of tier_boundaries

Problem: You need to replace a single config field with a more complex structure.

Solution Pattern (from models/config.py):

from pydantic import BaseModel, Field, field_validator

class DecisionGraphConfig(BaseModel):
    """Configuration for decision graph memory."""

    # DEPRECATED field (kept for backward compatibility)
    similarity_threshold: float = Field(
        0.7,
        ge=0.0,
        le=1.0,
        description="DEPRECATED: Use tier_boundaries instead.",
    )

    # NEW field (preferred)
    tier_boundaries: dict[str, float] = Field(
        default_factory=lambda: {"strong": 0.75, "moderate": 0.60},
        description="Similarity score boundaries for tiered injection"
    )

    @field_validator("tier_boundaries")
    @classmethod
    def validate_tier_boundaries(cls, v: dict[str, float]) -> dict[str, float]:
        """Validate tier boundaries: strong > moderate > 0."""
        if not isinstance(v, dict) or "strong" not in v or "moderate" not in v:
            raise ValueError("tier_boundaries must have 'strong' and 'moderate' keys")

        if not (0.0 < v["moderate"] < v["strong"] <= 1.0):
            raise ValueError(
                f"tier_boundaries must satisfy: 0 < moderate ({v['moderate']}) "
                f"< strong ({v['strong']}) <= 1"
            )

        return v

YAML Usage:

decision_graph:
  # OLD (still works, but deprecated in field description)
  similarity_threshold: 0.7

  # NEW (preferred)
  tier_boundaries:
    strong: 0.75
    moderate: 0.60

Key Techniques:

  • Keep deprecated field with default value
  • Add "DEPRECATED" to field description
  • Validate new field structure with @field_validator
  • Document migration in code comments and CLAUDE.md
  • Eventually remove deprecated field in future major version

Testing Migration Scripts

Before Deployment Checklist

  1. Unit Test the Migration Logic:
    def test_migrate_config_dict():
        """Test migration transforms cli_tools to adapters."""
        old_config = {
            "cli_tools": {
                "claude": {
                    "command": "claude",
                    "args": ["-p", "{prompt}"],
                    "timeout": 60
                }
            }
        }
    
        migrated = migrate_config_dict(old_config)
    
        assert "adapters" in migrated
        assert "cli_tools" not in migrated
        assert migrated["adapters"]["claude"]["type"] == "cli"
        assert migrated["adapters"]["claude"]["command"] == "claude"
  2. Test Idempotency:
    def test_migrate_idempotent():
        """Test migrating already-migrated config is safe."""
        already_migrated = {
            "adapters": {
                "claude": {"type": "cli", "command": "claude"}
            }
        }
    
        result = migrate_config_dict(already_migrated)
        assert result == already_migrated  # No changes
  3. Manual Testing Steps:
    # 1. Create test config
    cp config.yaml config.test.yaml
    
    # 2. Run migration
    python scripts/migrate_config.py config.test.yaml
    
    # 3. Verify backup created
    ls -la config.test.yaml.bak
    
    # 4. Test loading migrated config
    python -c "from models.config import load_config; c = load_config('config.test.yaml'); print('OK')"
    
    # 5. Compare files
    diff config.test.yaml.bak config.test.yaml
    
    # 6. Clean up
    rm config.test.yaml config.test.yaml.bak
  4. Load-Time Validation:
    # After migration, always test that config loads without errors
    python -c "from models.config import load_config; load_config()"

Complete Migration Workflow

When you need to evolve a config schema:

Step 1: Update Pydantic Models

  1. Add new section/fields as Optional (don't break existing configs)
  2. Keep old section/fields for backward compatibility
  3. Add @field_validator for new field validation
  4. Add deprecation warnings in model_post_init()

Step 2: Write Migration Script

  1. Create scripts/migrate_*.py with clear docstring
  2. Implement migrate_config_dict() for logic (testable)
  3. Implement migrate_config_file() for I/O (backup, load, migrate, save)
  4. Add main() with CLI argument parsing
  5. Print clear instructions after migration

Step 3: Test Migration

  1. Write unit tests for migrate_config_dict()
  2. Test idempotency (running twice is safe)
  3. Test edge cases (missing sections, already migrated)
  4. Manually test on real config file
  5. Verify migrated config loads successfully

Step 4: Document Migration

  1. Update CLAUDE.md with migration instructions
  2. Add migration notes to config.yaml comments
  3. Reference migration script in deprecation warnings
  4. Update README if needed

Step 5: Deploy

  1. Commit schema changes + migration script together
  2. Announce deprecation to users
  3. Provide migration timeline (e.g., "deprecated in v2.0, removed in v3.0")
  4. Keep backward compatibility for at least one major version

Real-World Example: The cli_tools → adapters Migration

Context: AI Counsel needed to support both CLI and HTTP adapters, requiring type discrimination.

Changes Made:

  1. Schema Evolution (models/config.py):
    • Created CLIAdapterConfig and HTTPAdapterConfig with type discriminator
    • Made adapters and cli_tools both optional
    • Added validation that at least one exists
    • Added deprecation warning for cli_tools
  2. Migration Script (scripts/migrate_config.py):
    • Transforms cli_tools → adapters with type: "cli"
    • Creates backup before modifying
    • Idempotent (safe to run multiple times)
    • Clear user feedback and next steps
  3. Testing:
    • Unit tests for migrate_config_dict()
    • Integration tests for file I/O
    • Manual testing on production config
  4. Documentation:
    • Updated CLAUDE.md with migration guide
    • Added comments to config.yaml explaining both formats
    • Referenced migration script in deprecation warning

Result: Users can migrate seamlessly with one command, and old configs continue working with a warning.

Common Patterns Summary

PatternUse CaseKey Technique
Optional SectionsAdd new section while keeping oldOptional[T] + validation
Discriminated UnionType discrimination (CLI vs HTTP)Literal["type"] + Field(discriminator="type")
Env Var SubstitutionInject secrets from environment@field_validator + regex \$\{VAR\}
Path ResolutionResolve relative pathsPath(__file__).parent + resolve()
Deprecation WarningsSignal old patternswarnings.warn() in model_post_init()
Migration ScriptsAutomate config updatesBackup + dict transform + YAML dump
Field ValidationComplex field constraints@field_validator + custom logic

File References

  • Config Models: /Users/harrison/Github/ai-counsel/models/config.py
  • Migration Script: /Users/harrison/Github/ai-counsel/scripts/migrate_config.py
  • Config File: /Users/harrison/Github/ai-counsel/config.yaml
  • Project Docs: /Users/harrison/Github/ai-counsel/CLAUDE.md

Key Takeaways

  1. Never break existing configs - always provide migration path
  2. Automate migrations - don't force manual editing
  3. Use Pydantic validators - catch errors at load time
  4. Support env vars - never hardcode secrets
  5. Test thoroughly - unit + integration + manual testing
  6. Document clearly - in code, CLAUDE.md, and warnings
  7. Version carefully - deprecate → warn → remove (over multiple versions)

When you detect config schema evolution needs, activate this skill and follow these patterns to ensure smooth, backward-compatible migrations.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.