4545from __future__ import annotations
4646
4747import json
48+ import re
4849import shutil
4950from datetime import UTC , datetime
5051from pathlib import Path
6667# Metadata file for tracking installed plugins
6768INSTALLED_METADATA_FILE = ".installed.json"
6869
70+ PLUGIN_NAME_PATTERN = re .compile (r"^[a-z0-9]+(?:-[a-z0-9]+)*$" )
71+
72+
73+ def _validate_plugin_name (name : str ) -> None :
74+ """Validate plugin name is Claude-like kebab-case.
75+
76+ This protects filesystem operations (install/uninstall) from path traversal.
77+ """
78+ if not PLUGIN_NAME_PATTERN .fullmatch (name ):
79+ raise ValueError (
80+ f"Invalid plugin name. Expected kebab-case like 'my-plugin' (got { name !r} )."
81+ )
82+
6983
7084class InstalledPluginInfo (BaseModel ):
7185 """Information about an installed plugin.
@@ -222,6 +236,7 @@ def install_plugin(
222236 # Load the plugin to get its metadata
223237 plugin = Plugin .load (fetched_path )
224238 plugin_name = plugin .name
239+ _validate_plugin_name (plugin_name )
225240
226241 # Check if already installed
227242 install_path = installed_dir / plugin_name
@@ -265,7 +280,8 @@ def uninstall_plugin(
265280) -> bool :
266281 """Uninstall a plugin by name.
267282
268- Removes the plugin directory and updates the metadata file.
283+ Only plugins tracked in the installed plugins metadata file can be uninstalled.
284+ This avoids deleting arbitrary directories in the installed plugins directory.
269285
270286 Args:
271287 name: Name of the plugin to uninstall.
@@ -281,25 +297,27 @@ def uninstall_plugin(
281297 ... else:
282298 ... print("Plugin was not installed")
283299 """
300+ _validate_plugin_name (name )
301+
284302 if installed_dir is None :
285303 installed_dir = get_installed_plugins_dir ()
286304
287- plugin_path = installed_dir / name
288-
289- # Check if plugin exists
290- if not plugin_path .exists ():
305+ metadata = _load_metadata (installed_dir )
306+ if name not in metadata .plugins :
291307 logger .warning (f"Plugin '{ name } ' is not installed" )
292308 return False
293309
294- # Remove plugin directory
295- logger .info (f"Uninstalling plugin '{ name } ' from { plugin_path } " )
296- shutil .rmtree (plugin_path )
310+ plugin_path = installed_dir / name
311+ if plugin_path .exists ():
312+ logger .info (f"Uninstalling plugin '{ name } ' from { plugin_path } " )
313+ shutil .rmtree (plugin_path )
314+ else :
315+ logger .warning (
316+ f"Plugin '{ name } ' was tracked but its directory is missing: { plugin_path } "
317+ )
297318
298- # Update metadata
299- metadata = _load_metadata (installed_dir )
300- if name in metadata .plugins :
301- del metadata .plugins [name ]
302- _save_metadata (metadata , installed_dir )
319+ del metadata .plugins [name ]
320+ _save_metadata (metadata , installed_dir )
303321
304322 logger .info (f"Successfully uninstalled plugin '{ name } '" )
305323 return True
@@ -310,9 +328,9 @@ def list_installed_plugins(
310328) -> list [InstalledPluginInfo ]:
311329 """List all installed plugins.
312330
313- Returns information about all plugins installed in the installed plugins
314- directory. This reads from the metadata file and verifies that the
315- plugin directories still exist .
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 .
316334
317335 Args:
318336 installed_dir: Directory for installed plugins.
@@ -332,47 +350,69 @@ def list_installed_plugins(
332350 return []
333351
334352 metadata = _load_metadata (installed_dir )
353+ metadata_changed = False
335354 installed_plugins : list [InstalledPluginInfo ] = []
336355
337- # Verify each plugin still exists and collect info
356+ # Verify each tracked plugin still exists and collect info.
338357 for name , info in list (metadata .plugins .items ()):
358+ try :
359+ _validate_plugin_name (name )
360+ except ValueError as e :
361+ logger .warning (f"Invalid tracked plugin name { name !r} , removing: { e } " )
362+ del metadata .plugins [name ]
363+ metadata_changed = True
364+ continue
365+
339366 plugin_path = installed_dir / name
340367 if plugin_path .exists ():
341368 installed_plugins .append (info )
342- else :
343- # Plugin directory was removed externally, clean up metadata
344- logger .warning (f"Plugin '{ name } ' directory missing, removing from metadata" )
345- del metadata .plugins [name ]
369+ continue
346370
347- # Save cleaned metadata if any plugins were removed
348- if len ( installed_plugins ) != len ( metadata .plugins ):
349- _save_metadata ( metadata , installed_dir )
371+ logger . warning ( f"Plugin ' { name } ' directory missing, removing from metadata" )
372+ del metadata .plugins [ name ]
373+ metadata_changed = True
350374
351- # Also check for plugins that exist but aren't in metadata
352- # (e.g., manually copied plugins)
375+ # Discover plugins that exist but aren't in metadata (e.g., manual copies).
353376 for item in installed_dir .iterdir ():
354- if item .is_dir () and item .name not in metadata .plugins :
355- if item .name .startswith ("." ):
356- continue # Skip hidden directories
357- try :
358- plugin = Plugin .load (item )
359- info = InstalledPluginInfo (
360- name = plugin .name ,
361- version = plugin .version ,
362- description = plugin .description ,
363- source = "local" , # Unknown source
364- installed_at = datetime .now (UTC ).isoformat (),
365- install_path = str (item ),
366- )
367- installed_plugins .append (info )
368- # Add to metadata for future reference
369- metadata .plugins [plugin .name ] = info
370- logger .info (f"Discovered untracked plugin: { plugin .name } " )
371- except Exception as e :
372- logger .debug (f"Skipping directory { item } : { e } " )
373-
374- # Save if we discovered new plugins
375- _save_metadata (metadata , installed_dir )
377+ if not item .is_dir () or item .name .startswith ("." ):
378+ continue
379+ if item .name in metadata .plugins :
380+ continue
381+
382+ try :
383+ _validate_plugin_name (item .name )
384+ except ValueError :
385+ logger .debug (f"Skipping directory with invalid plugin name: { item } " )
386+ continue
387+
388+ try :
389+ plugin = Plugin .load (item )
390+ except Exception as e :
391+ logger .debug (f"Skipping directory { item } : { e } " )
392+ continue
393+
394+ if plugin .name != item .name :
395+ logger .warning (
396+ "Skipping plugin directory because manifest name doesn't match "
397+ f"directory name: dir={ item .name !r} , manifest={ plugin .name !r} "
398+ )
399+ continue
400+
401+ info = InstalledPluginInfo (
402+ name = plugin .name ,
403+ version = plugin .version ,
404+ description = plugin .description ,
405+ source = "local" ,
406+ installed_at = datetime .now (UTC ).isoformat (),
407+ install_path = str (item ),
408+ )
409+ installed_plugins .append (info )
410+ metadata .plugins [item .name ] = info
411+ metadata_changed = True
412+ logger .info (f"Discovered untracked plugin: { plugin .name } " )
413+
414+ if metadata_changed :
415+ _save_metadata (metadata , installed_dir )
376416
377417 return installed_plugins
378418
@@ -425,6 +465,8 @@ def get_installed_plugin(
425465 >>> if info:
426466 ... print(f"Installed from {info.source} at {info.installed_at}")
427467 """
468+ _validate_plugin_name (name )
469+
428470 if installed_dir is None :
429471 installed_dir = get_installed_plugins_dir ()
430472
@@ -447,7 +489,9 @@ def update_plugin(
447489 """Update an installed plugin to the latest version.
448490
449491 Re-fetches the plugin from its original source and reinstalls it.
450- The original source and ref are preserved from the installation metadata.
492+
493+ This always updates to the latest version available from the original source
494+ (i.e., it does not preserve a pinned ref).
451495
452496 Args:
453497 name: Name of the plugin to update.
@@ -465,6 +509,8 @@ def update_plugin(
465509 >>> if info:
466510 ... print(f"Updated to v{info.version}")
467511 """
512+ _validate_plugin_name (name )
513+
468514 if installed_dir is None :
469515 installed_dir = get_installed_plugins_dir ()
470516
0 commit comments