11"""Claude Code Python SDK integration."""
22
33import asyncio
4+ import json
45import os
56from dataclasses import dataclass , field
67from pathlib import Path
@@ -196,6 +197,12 @@ def _stderr_callback(line: str) -> None:
196197 sdk_allowed_tools = self .config .claude_allowed_tools
197198 sdk_disallowed_tools = self .config .claude_disallowed_tools
198199
200+ # Discover installed CLI plugins
201+ discovered_plugins = self ._discover_plugins ()
202+ extra_plugin_dirs = self .config .claude_plugin_dirs or []
203+ for d in extra_plugin_dirs :
204+ discovered_plugins .append ({"type" : "local" , "path" : d })
205+
199206 # Build Claude Agent options
200207 options = ClaudeAgentOptions (
201208 max_turns = self .config .claude_max_turns ,
@@ -212,7 +219,8 @@ def _stderr_callback(line: str) -> None:
212219 "excludedCommands" : self .config .sandbox_excluded_commands or [],
213220 },
214221 system_prompt = base_prompt ,
215- setting_sources = ["project" ],
222+ setting_sources = ["user" , "project" ],
223+ plugins = discovered_plugins if discovered_plugins else None ,
216224 stderr = _stderr_callback ,
217225 )
218226
@@ -525,6 +533,68 @@ async def _handle_stream_message(
525533 except Exception as e :
526534 logger .warning ("Stream callback failed" , error = str (e ))
527535
536+ def _discover_plugins (self ) -> List [Dict [str , str ]]:
537+ """Discover installed CLI plugins from ~/.claude/plugins/cache/.
538+
539+ Returns a list of plugin descriptors suitable for ClaudeAgentOptions.plugins,
540+ e.g. [{"type": "local", "path": "/root/.claude/plugins/cache/mp/plug/1.0"}].
541+ """
542+ plugins : List [Dict [str , str ]] = []
543+ cache_dir = Path .home () / ".claude" / "plugins" / "cache"
544+ if not cache_dir .is_dir ():
545+ return plugins
546+
547+ # Also consult installed_plugins.json for the pinned version of each plugin,
548+ # so we resolve the correct version directory under cache/.
549+ installed_versions : Dict [str , str ] = {}
550+ installed_json = Path .home () / ".claude" / "plugins" / "installed_plugins.json"
551+ if installed_json .is_file ():
552+ try :
553+ data = json .loads (installed_json .read_text (encoding = "utf-8" ))
554+ for entry in data if isinstance (data , list ) else []:
555+ name = entry .get ("name" , "" )
556+ version = entry .get ("version" , "" )
557+ if name and version :
558+ installed_versions [name ] = version
559+ except (json .JSONDecodeError , OSError ) as e :
560+ logger .debug ("Could not read installed_plugins.json" , error = str (e ))
561+
562+ for marketplace_dir in cache_dir .iterdir ():
563+ if not marketplace_dir .is_dir ():
564+ continue
565+ for plugin_dir in marketplace_dir .iterdir ():
566+ if not plugin_dir .is_dir ():
567+ continue
568+ # Determine which version to use: prefer installed_plugins.json,
569+ # then fall back to the lexicographically latest version dir.
570+ lookup_key = f"{ plugin_dir .name } @{ marketplace_dir .name } "
571+ pinned = installed_versions .get (lookup_key )
572+ version_dir = None
573+ if pinned :
574+ candidate = plugin_dir / pinned
575+ if candidate .is_dir ():
576+ version_dir = candidate
577+ if version_dir is None :
578+ version_dirs = sorted (
579+ [d for d in plugin_dir .iterdir () if d .is_dir ()],
580+ key = lambda d : d .name ,
581+ reverse = True ,
582+ )
583+ if version_dirs :
584+ version_dir = version_dirs [0 ]
585+ if version_dir and (
586+ version_dir / ".claude-plugin" / "plugin.json"
587+ ).is_file ():
588+ plugins .append ({"type" : "local" , "path" : str (version_dir )})
589+ logger .info (
590+ "Discovered plugin" ,
591+ plugin = plugin_dir .name ,
592+ marketplace = marketplace_dir .name ,
593+ path = str (version_dir ),
594+ )
595+
596+ return plugins
597+
528598 def _load_mcp_config (self , config_path : Path ) -> Dict [str , Any ]:
529599 """Load MCP server configuration from a JSON file.
530600
0 commit comments