Skip to content

Commit 239903f

Browse files
authored
Merge pull request #2 from jhbarnett/feat/upstream-cherry-picks
feat: auto-discover and load CLI-installed plugins into SDK sessions
2 parents 89e36c2 + a5426b2 commit 239903f

3 files changed

Lines changed: 89 additions & 2 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "claude-code-telegram"
3-
version = "1.5.0"
3+
version = "1.6.1"
44
description = "Telegram bot for remote Claude Code access with comprehensive configuration management"
55
readme = "README.md"
66
license = "MIT"

src/claude/sdk_integration.py

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Claude Code Python SDK integration."""
22

33
import asyncio
4+
import json
45
import os
56
from dataclasses import dataclass, field
67
from 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

src/config/settings.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,10 @@ class Settings(BaseSettings):
120120
default=[],
121121
description="List of explicitly disallowed Claude tools/commands",
122122
)
123+
claude_plugin_dirs: Optional[List[str]] = Field(
124+
default=None,
125+
description="Additional plugin directories to load (comma-separated paths)",
126+
)
123127

124128
# Sandbox settings
125129
sandbox_enabled: bool = Field(
@@ -302,6 +306,19 @@ def parse_int_list(cls, v: Any) -> Optional[List[int]]:
302306
return [int(uid) for uid in v]
303307
return v # type: ignore[no-any-return]
304308

309+
@field_validator("claude_plugin_dirs", mode="before")
310+
@classmethod
311+
def parse_claude_plugin_dirs(cls, v: Any) -> Optional[List[str]]:
312+
"""Parse comma-separated plugin directory paths."""
313+
if v is None:
314+
return None
315+
if isinstance(v, str):
316+
dirs = [d.strip() for d in v.split(",") if d.strip()]
317+
return dirs if dirs else None
318+
if isinstance(v, list):
319+
return [str(d) for d in v]
320+
return v # type: ignore[no-any-return]
321+
305322
@field_validator("claude_allowed_tools", mode="before")
306323
@classmethod
307324
def parse_claude_allowed_tools(cls, v: Any) -> Optional[List[str]]:

0 commit comments

Comments
 (0)