11"""Installed plugins management for OpenHands SDK.
22
33This 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/
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
4519from __future__ import annotations
6539DEFAULT_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
7361def _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-
188155def 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
420412def 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