Skip to content

Commit 8c58ce8

Browse files
fix: migrate legacy plugins/skills metadata keys to extensions
Add a model_validator that transparently maps the old 'plugins' or 'skills' key to 'extensions' when loading existing .installed.json files, preserving enabled/disabled state for existing installations. Co-authored-by: openhands <openhands@all-hands.dev>
1 parent a307e53 commit 8c58ce8

2 files changed

Lines changed: 75 additions & 2 deletions

File tree

openhands-sdk/openhands/sdk/extensions/installation/metadata.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
from pathlib import Path
44
from types import TracebackType
5-
from typing import ClassVar
5+
from typing import Any, ClassVar
66

7-
from pydantic import BaseModel, Field
7+
from pydantic import BaseModel, Field, model_validator
88

99
from openhands.sdk.extensions.installation.info import InstallationInfo
1010
from openhands.sdk.extensions.installation.interface import (
@@ -94,6 +94,18 @@ class InstallationMetadata(BaseModel):
9494
)
9595

9696
metadata_filename: ClassVar[str] = ".installed.json"
97+
_LEGACY_KEYS: ClassVar[tuple[str, ...]] = ("plugins", "skills")
98+
99+
@model_validator(mode="before")
100+
@classmethod
101+
def _migrate_legacy_keys(cls, data: Any) -> Any:
102+
"""Migrate old ``plugins`` / ``skills`` keys to ``extensions``."""
103+
if isinstance(data, dict) and "extensions" not in data:
104+
for legacy_key in cls._LEGACY_KEYS:
105+
if legacy_key in data:
106+
data["extensions"] = data.pop(legacy_key)
107+
break
108+
return data
97109

98110
@classmethod
99111
def open(

tests/sdk/extensions/installation/test_installation_metadata.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,67 @@ def _write_mock_extension(
3737
return directory
3838

3939

40+
# ============================================================================
41+
# Legacy Key Migration Tests
42+
# ============================================================================
43+
44+
45+
def test_migrate_legacy_plugins_key():
46+
"""Test that old {"plugins": {...}} format is migrated to extensions."""
47+
data = {
48+
"plugins": {
49+
"my-plugin": {
50+
"name": "my-plugin",
51+
"source": "github:owner/repo",
52+
"install_path": "/tmp/installed/my-plugin",
53+
}
54+
}
55+
}
56+
metadata = InstallationMetadata.model_validate(data)
57+
assert "my-plugin" in metadata.extensions
58+
assert metadata.extensions["my-plugin"].name == "my-plugin"
59+
60+
61+
def test_migrate_legacy_skills_key():
62+
"""Test that old {"skills": {...}} format is migrated to extensions."""
63+
data = {
64+
"skills": {
65+
"my-skill": {
66+
"name": "my-skill",
67+
"source": "local",
68+
"install_path": "/tmp/installed/my-skill",
69+
"enabled": False,
70+
}
71+
}
72+
}
73+
metadata = InstallationMetadata.model_validate(data)
74+
assert "my-skill" in metadata.extensions
75+
assert metadata.extensions["my-skill"].enabled is False
76+
77+
78+
def test_migrate_does_not_overwrite_extensions_key():
79+
"""Test that migration is skipped when 'extensions' key already exists."""
80+
data = {
81+
"extensions": {
82+
"new-ext": {
83+
"name": "new-ext",
84+
"source": "local",
85+
"install_path": "/tmp/installed/new-ext",
86+
}
87+
},
88+
"plugins": {
89+
"old-plugin": {
90+
"name": "old-plugin",
91+
"source": "local",
92+
"install_path": "/tmp/installed/old-plugin",
93+
}
94+
},
95+
}
96+
metadata = InstallationMetadata.model_validate(data)
97+
assert "new-ext" in metadata.extensions
98+
assert "old-plugin" not in metadata.extensions
99+
100+
40101
# ============================================================================
41102
# Load / Save Tests
42103
# ============================================================================

0 commit comments

Comments
 (0)