Claude-skill-registry config-schema-migrator
Expert at evolving Pydantic configuration schemas with backward compatibility and automated migrations
git clone https://github.com/majiayu000/claude-skill-registry
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/config-schema-migrator" ~/.claude/skills/majiayu000-claude-skill-registry-config-schema-migrator && rm -rf "$T"
skills/data/config-schema-migrator/SKILL.mdConfig 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
- Backward Compatibility First: Never break existing configs without a migration path
- Deprecation Before Removal: Warn users before removing old config sections
- Automatic Migration: Provide scripts to automate config updates (don't force manual editing)
- Type Safety: Use Pydantic validators to catch config errors at load time
- Environment Variables: Support ${VAR_NAME} substitution for secrets
- 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
adapterscli_toolsProblem: 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
for both old and new sectionsOptional - Validate in
that at least one existsmodel_post_init() - Emit
when old section is usedDeprecationWarning - 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
for discriminator field with default valueLiteral["value"] - Create
Annotated[Union[...], Field(discriminator="type")] - Pydantic automatically routes to correct model based on
fieldtype - 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
on fields that may contain env vars@field_validator - Use
to customize behavior per fieldinfo.field_name - Regex pattern
to findr"\$\{([^}]+)\}"${VAR_NAME} - For optional fields (api_key): return
if env var missingNone - For required fields (base_url): raise
if env var missingValueError - Always load
file first with.env
indotenv.load_dotenv()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:
for logic,migrate_config_dict()
for I/Omigrate_config_file() - 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
to preserve key ordersort_keys=False
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
to find project rootPath(__file__).parent.parent - 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
similarity_thresholdtier_boundariesProblem: 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
-
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" -
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 -
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 -
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
- Add new section/fields as
(don't break existing configs)Optional - Keep old section/fields for backward compatibility
- Add
for new field validation@field_validator - Add deprecation warnings in
model_post_init()
Step 2: Write Migration Script
- Create
with clear docstringscripts/migrate_*.py - Implement
for logic (testable)migrate_config_dict() - Implement
for I/O (backup, load, migrate, save)migrate_config_file() - Add
with CLI argument parsingmain() - Print clear instructions after migration
Step 3: Test Migration
- Write unit tests for
migrate_config_dict() - Test idempotency (running twice is safe)
- Test edge cases (missing sections, already migrated)
- Manually test on real config file
- Verify migrated config loads successfully
Step 4: Document Migration
- Update CLAUDE.md with migration instructions
- Add migration notes to config.yaml comments
- Reference migration script in deprecation warnings
- Update README if needed
Step 5: Deploy
- Commit schema changes + migration script together
- Announce deprecation to users
- Provide migration timeline (e.g., "deprecated in v2.0, removed in v3.0")
- 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:
-
Schema Evolution (
):models/config.py- Created
andCLIAdapterConfig
withHTTPAdapterConfig
discriminatortype - Made
andadapters
both optionalcli_tools - Added validation that at least one exists
- Added deprecation warning for
cli_tools
- Created
-
Migration Script (
):scripts/migrate_config.py- Transforms
→cli_tools
withadapterstype: "cli" - Creates backup before modifying
- Idempotent (safe to run multiple times)
- Clear user feedback and next steps
- Transforms
-
Testing:
- Unit tests for
migrate_config_dict() - Integration tests for file I/O
- Manual testing on production config
- Unit tests for
-
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
| Pattern | Use Case | Key Technique |
|---|---|---|
| Optional Sections | Add new section while keeping old | + validation |
| Discriminated Union | Type discrimination (CLI vs HTTP) | + |
| Env Var Substitution | Inject secrets from environment | + regex |
| Path Resolution | Resolve relative paths | + |
| Deprecation Warnings | Signal old patterns | in |
| Migration Scripts | Automate config updates | Backup + dict transform + YAML dump |
| Field Validation | Complex field constraints | + 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
- Never break existing configs - always provide migration path
- Automate migrations - don't force manual editing
- Use Pydantic validators - catch errors at load time
- Support env vars - never hardcode secrets
- Test thoroughly - unit + integration + manual testing
- Document clearly - in code, CLAUDE.md, and warnings
- 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.