Skip to content

Commit 0fae727

Browse files
refactor(plugin): Improve installed.py readability based on code review
- Fix overly broad exception handling in InstalledPluginsMetadata.load_from_dir() - Add get_path(), load_from_dir(), save_to_dir() methods to InstalledPluginsMetadata - Extract _resolve_installed_dir() helper to reduce repetitive installed_dir defaulting - Split list_installed_plugins() into _validate_tracked_plugins() and _discover_untracked_plugins() for better separation of concerns - Trim verbose module docstring (remove redundant examples) - Rename internal constants to use underscore prefix - Update tests to use new class methods Co-authored-by: openhands <openhands@all-hands.dev>
1 parent 76cbeba commit 0fae727

File tree

2 files changed

+124
-139
lines changed

2 files changed

+124
-139
lines changed

openhands-sdk/openhands/sdk/plugin/installed.py

Lines changed: 108 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,10 @@
11
"""Installed plugins management for OpenHands SDK.
22
33
This module provides utilities for managing plugins installed in the user's
4-
home directory (~/.openhands/plugins/installed/). It supports:
4+
home directory (~/.openhands/plugins/installed/).
55
6-
- Installing plugins from GitHub repositories, git URLs, or local paths
7-
- Listing installed plugins with their metadata
8-
- Uninstalling plugins by name
9-
- Loading all installed plugins
6+
The installed plugins directory structure follows the Claude Code pattern::
107
11-
The installed plugins directory structure follows the Claude Code pattern:
128
~/.openhands/plugins/installed/
139
├── plugin-name-1/
1410
│ ├── .plugin/
@@ -18,28 +14,6 @@
1814
├── plugin-name-2/
1915
│ └── ...
2016
└── .installed.json # Metadata about installed plugins
21-
22-
Example usage:
23-
>>> from openhands.sdk.plugin.installed import (
24-
... install_plugin,
25-
... list_installed_plugins,
26-
... uninstall_plugin,
27-
... load_installed_plugins,
28-
... )
29-
>>>
30-
>>> # Install a plugin from GitHub
31-
>>> info = install_plugin("github:owner/my-plugin")
32-
>>> print(f"Installed {info.name} v{info.version}")
33-
>>>
34-
>>> # List all installed plugins
35-
>>> for plugin_info in list_installed_plugins():
36-
... print(f" - {plugin_info.name}: {plugin_info.description}")
37-
>>>
38-
>>> # Load plugins for use
39-
>>> plugins = load_installed_plugins()
40-
>>>
41-
>>> # Uninstall a plugin
42-
>>> uninstall_plugin("my-plugin")
4317
"""
4418

4519
from __future__ import annotations
@@ -65,17 +39,31 @@
6539
DEFAULT_INSTALLED_PLUGINS_DIR = Path.home() / ".openhands" / "plugins" / "installed"
6640

6741
# Metadata file for tracking installed plugins
68-
INSTALLED_METADATA_FILE = ".installed.json"
42+
_METADATA_FILENAME = ".installed.json"
43+
44+
_PLUGIN_NAME_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
45+
6946

70-
PLUGIN_NAME_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
47+
def _resolve_installed_dir(installed_dir: Path | None) -> Path:
48+
"""Return installed_dir or the default if None."""
49+
return installed_dir if installed_dir is not None else DEFAULT_INSTALLED_PLUGINS_DIR
50+
51+
52+
def get_installed_plugins_dir() -> Path:
53+
"""Get the default directory for installed plugins.
54+
55+
Returns:
56+
Path to ~/.openhands/plugins/installed/
57+
"""
58+
return DEFAULT_INSTALLED_PLUGINS_DIR
7159

7260

7361
def _validate_plugin_name(name: str) -> None:
7462
"""Validate plugin name is Claude-like kebab-case.
7563
7664
This protects filesystem operations (install/uninstall) from path traversal.
7765
"""
78-
if not PLUGIN_NAME_PATTERN.fullmatch(name):
66+
if not _PLUGIN_NAME_PATTERN.fullmatch(name):
7967
raise ValueError(
8068
f"Invalid plugin name. Expected kebab-case like 'my-plugin' (got {name!r})."
8169
)
@@ -138,53 +126,32 @@ class InstalledPluginsMetadata(BaseModel):
138126
)
139127

140128
@classmethod
141-
def load(cls, metadata_path: Path) -> InstalledPluginsMetadata:
142-
"""Load metadata from file, or return empty if not found."""
129+
def get_path(cls, installed_dir: Path) -> Path:
130+
"""Get the metadata file path for the given installed plugins directory."""
131+
return installed_dir / _METADATA_FILENAME
132+
133+
@classmethod
134+
def load_from_dir(cls, installed_dir: Path) -> InstalledPluginsMetadata:
135+
"""Load metadata from the installed plugins directory."""
136+
metadata_path = cls.get_path(installed_dir)
143137
if not metadata_path.exists():
144138
return cls()
145139
try:
146140
with open(metadata_path) as f:
147141
data = json.load(f)
148142
return cls.model_validate(data)
149-
except (json.JSONDecodeError, Exception) as e:
143+
except Exception as e:
150144
logger.warning(f"Failed to load installed plugins metadata: {e}")
151145
return cls()
152146

153-
def save(self, metadata_path: Path) -> None:
154-
"""Save metadata to file."""
147+
def save_to_dir(self, installed_dir: Path) -> None:
148+
"""Save metadata to the installed plugins directory."""
149+
metadata_path = self.get_path(installed_dir)
155150
metadata_path.parent.mkdir(parents=True, exist_ok=True)
156151
with open(metadata_path, "w") as f:
157152
json.dump(self.model_dump(), f, indent=2)
158153

159154

160-
def get_installed_plugins_dir() -> Path:
161-
"""Get the directory for installed plugins.
162-
163-
Returns:
164-
Path to ~/.openhands/plugins/installed/
165-
"""
166-
return DEFAULT_INSTALLED_PLUGINS_DIR
167-
168-
169-
def _get_metadata_path(installed_dir: Path | None = None) -> Path:
170-
"""Get the path to the installed plugins metadata file."""
171-
if installed_dir is None:
172-
installed_dir = get_installed_plugins_dir()
173-
return installed_dir / INSTALLED_METADATA_FILE
174-
175-
176-
def _load_metadata(installed_dir: Path | None = None) -> InstalledPluginsMetadata:
177-
"""Load the installed plugins metadata."""
178-
return InstalledPluginsMetadata.load(_get_metadata_path(installed_dir))
179-
180-
181-
def _save_metadata(
182-
metadata: InstalledPluginsMetadata, installed_dir: Path | None = None
183-
) -> None:
184-
"""Save the installed plugins metadata."""
185-
metadata.save(_get_metadata_path(installed_dir))
186-
187-
188155
def install_plugin(
189156
source: str,
190157
ref: str | None = None,
@@ -221,8 +188,7 @@ def install_plugin(
221188
>>> info = install_plugin("github:owner/my-plugin", ref="v1.0.0")
222189
>>> print(f"Installed {info.name} from {info.source}")
223190
"""
224-
if installed_dir is None:
225-
installed_dir = get_installed_plugins_dir()
191+
installed_dir = _resolve_installed_dir(installed_dir)
226192

227193
# Fetch the plugin (downloads to cache if remote)
228194
logger.info(f"Fetching plugin from {source}")
@@ -266,9 +232,9 @@ def install_plugin(
266232
)
267233

268234
# Update metadata
269-
metadata = _load_metadata(installed_dir)
235+
metadata = InstalledPluginsMetadata.load_from_dir(installed_dir)
270236
metadata.plugins[plugin_name] = info
271-
_save_metadata(metadata, installed_dir)
237+
metadata.save_to_dir(installed_dir)
272238

273239
logger.info(f"Successfully installed plugin '{plugin_name}' v{plugin.version}")
274240
return info
@@ -298,11 +264,9 @@ def uninstall_plugin(
298264
... print("Plugin was not installed")
299265
"""
300266
_validate_plugin_name(name)
267+
installed_dir = _resolve_installed_dir(installed_dir)
301268

302-
if installed_dir is None:
303-
installed_dir = get_installed_plugins_dir()
304-
305-
metadata = _load_metadata(installed_dir)
269+
metadata = InstalledPluginsMetadata.load_from_dir(installed_dir)
306270
if name not in metadata.plugins:
307271
logger.warning(f"Plugin '{name}' is not installed")
308272
return False
@@ -317,62 +281,54 @@ def uninstall_plugin(
317281
)
318282

319283
del metadata.plugins[name]
320-
_save_metadata(metadata, installed_dir)
284+
metadata.save_to_dir(installed_dir)
321285

322286
logger.info(f"Successfully uninstalled plugin '{name}'")
323287
return True
324288

325289

326-
def list_installed_plugins(
327-
installed_dir: Path | None = None,
328-
) -> list[InstalledPluginInfo]:
329-
"""List all installed plugins.
330-
331-
This function is self-healing: it may update the installed plugins metadata
332-
file to remove entries whose directories were deleted, and to add entries for
333-
plugin directories that were manually copied into the installed dir.
334-
335-
Args:
336-
installed_dir: Directory for installed plugins.
337-
Defaults to ~/.openhands/plugins/installed/
290+
def _validate_tracked_plugins(
291+
metadata: InstalledPluginsMetadata, installed_dir: Path
292+
) -> tuple[list[InstalledPluginInfo], bool]:
293+
"""Validate tracked plugins exist on disk.
338294
339295
Returns:
340-
List of InstalledPluginInfo for each installed plugin.
341-
342-
Example:
343-
>>> for info in list_installed_plugins():
344-
... print(f"{info.name} v{info.version} - {info.description}")
296+
Tuple of (valid plugins list, whether metadata was modified).
345297
"""
346-
if installed_dir is None:
347-
installed_dir = get_installed_plugins_dir()
348-
349-
if not installed_dir.exists():
350-
return []
351-
352-
metadata = _load_metadata(installed_dir)
353-
metadata_changed = False
354-
installed_plugins: list[InstalledPluginInfo] = []
298+
valid_plugins: list[InstalledPluginInfo] = []
299+
changed = False
355300

356-
# Verify each tracked plugin still exists and collect info.
357301
for name, info in list(metadata.plugins.items()):
358302
try:
359303
_validate_plugin_name(name)
360304
except ValueError as e:
361305
logger.warning(f"Invalid tracked plugin name {name!r}, removing: {e}")
362306
del metadata.plugins[name]
363-
metadata_changed = True
307+
changed = True
364308
continue
365309

366310
plugin_path = installed_dir / name
367311
if plugin_path.exists():
368-
installed_plugins.append(info)
369-
continue
312+
valid_plugins.append(info)
313+
else:
314+
logger.warning(f"Plugin '{name}' directory missing, removing from metadata")
315+
del metadata.plugins[name]
316+
changed = True
317+
318+
return valid_plugins, changed
319+
320+
321+
def _discover_untracked_plugins(
322+
metadata: InstalledPluginsMetadata, installed_dir: Path
323+
) -> tuple[list[InstalledPluginInfo], bool]:
324+
"""Discover plugin directories not tracked in metadata.
370325
371-
logger.warning(f"Plugin '{name}' directory missing, removing from metadata")
372-
del metadata.plugins[name]
373-
metadata_changed = True
326+
Returns:
327+
Tuple of (discovered plugins list, whether metadata was modified).
328+
"""
329+
discovered: list[InstalledPluginInfo] = []
330+
changed = False
374331

375-
# Discover plugins that exist but aren't in metadata (e.g., manual copies).
376332
for item in installed_dir.iterdir():
377333
if not item.is_dir() or item.name.startswith("."):
378334
continue
@@ -406,15 +362,51 @@ def list_installed_plugins(
406362
installed_at=datetime.now(UTC).isoformat(),
407363
install_path=str(item),
408364
)
409-
installed_plugins.append(info)
365+
discovered.append(info)
410366
metadata.plugins[item.name] = info
411-
metadata_changed = True
367+
changed = True
412368
logger.info(f"Discovered untracked plugin: {plugin.name}")
413369

414-
if metadata_changed:
415-
_save_metadata(metadata, installed_dir)
370+
return discovered, changed
371+
372+
373+
def list_installed_plugins(
374+
installed_dir: Path | None = None,
375+
) -> list[InstalledPluginInfo]:
376+
"""List all installed plugins.
377+
378+
This function is self-healing: it may update the installed plugins metadata
379+
file to remove entries whose directories were deleted, and to add entries for
380+
plugin directories that were manually copied into the installed dir.
381+
382+
Args:
383+
installed_dir: Directory for installed plugins.
384+
Defaults to ~/.openhands/plugins/installed/
385+
386+
Returns:
387+
List of InstalledPluginInfo for each installed plugin.
388+
389+
Example:
390+
>>> for info in list_installed_plugins():
391+
... print(f"{info.name} v{info.version} - {info.description}")
392+
"""
393+
installed_dir = _resolve_installed_dir(installed_dir)
394+
395+
if not installed_dir.exists():
396+
return []
416397

417-
return installed_plugins
398+
metadata = InstalledPluginsMetadata.load_from_dir(installed_dir)
399+
400+
# Validate tracked plugins and discover untracked ones
401+
valid_plugins, tracked_changed = _validate_tracked_plugins(metadata, installed_dir)
402+
discovered, discovered_changed = _discover_untracked_plugins(
403+
metadata, installed_dir
404+
)
405+
406+
if tracked_changed or discovered_changed:
407+
metadata.save_to_dir(installed_dir)
408+
409+
return valid_plugins + discovered
418410

419411

420412
def load_installed_plugins(
@@ -437,8 +429,7 @@ def load_installed_plugins(
437429
>>> for plugin in plugins:
438430
... print(f"Loaded {plugin.name} with {len(plugin.skills)} skills")
439431
"""
440-
if installed_dir is None:
441-
installed_dir = get_installed_plugins_dir()
432+
installed_dir = _resolve_installed_dir(installed_dir)
442433

443434
if not installed_dir.exists():
444435
return []
@@ -466,11 +457,9 @@ def get_installed_plugin(
466457
... print(f"Installed from {info.source} at {info.installed_at}")
467458
"""
468459
_validate_plugin_name(name)
460+
installed_dir = _resolve_installed_dir(installed_dir)
469461

470-
if installed_dir is None:
471-
installed_dir = get_installed_plugins_dir()
472-
473-
metadata = _load_metadata(installed_dir)
462+
metadata = InstalledPluginsMetadata.load_from_dir(installed_dir)
474463
info = metadata.plugins.get(name)
475464

476465
# Verify the plugin directory still exists
@@ -510,9 +499,7 @@ def update_plugin(
510499
... print(f"Updated to v{info.version}")
511500
"""
512501
_validate_plugin_name(name)
513-
514-
if installed_dir is None:
515-
installed_dir = get_installed_plugins_dir()
502+
installed_dir = _resolve_installed_dir(installed_dir)
516503

517504
# Get current installation info
518505
current_info = get_installed_plugin(name, installed_dir)

0 commit comments

Comments
 (0)