-
Notifications
You must be signed in to change notification settings - Fork 213
Expand file tree
/
Copy pathplugin.py
More file actions
514 lines (415 loc) · 18 KB
/
plugin.py
File metadata and controls
514 lines (415 loc) · 18 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
"""Plugin class for loading and managing plugins."""
from __future__ import annotations
import json
from pathlib import Path
from typing import TYPE_CHECKING, Any
from pydantic import BaseModel, Field
from openhands.sdk.hooks import HookConfig
from openhands.sdk.logger import get_logger
from openhands.sdk.plugin.fetch import fetch_plugin
from openhands.sdk.plugin.types import (
CommandDefinition,
PluginAuthor,
PluginManifest,
)
from openhands.sdk.skills.skill import Skill
from openhands.sdk.skills.utils import (
discover_skill_resources,
find_skill_md,
load_mcp_config,
)
from openhands.sdk.subagent.schema import AgentDefinition
if TYPE_CHECKING:
from openhands.sdk.context import AgentContext
logger = get_logger(__name__)
# Directories to check for plugin manifest
PLUGIN_MANIFEST_DIRS = [".plugin", ".claude-plugin"]
PLUGIN_MANIFEST_FILE = "plugin.json"
class Plugin(BaseModel):
"""A plugin that bundles skills, hooks, MCP config, agents, and commands.
Plugins follow the Claude Code plugin structure for compatibility:
```
plugin-name/
├── .claude-plugin/ # or .plugin/
│ └── plugin.json # Plugin metadata
├── commands/ # Slash commands (optional)
├── agents/ # Specialized agents (optional)
├── skills/ # Agent Skills (optional)
├── hooks/ # Event handlers (optional)
│ └── hooks.json
├── .mcp.json # External tool configuration (optional)
└── README.md # Plugin documentation
```
"""
manifest: PluginManifest = Field(description="Plugin manifest from plugin.json")
path: str = Field(description="Path to the plugin directory")
skills: list[Skill] = Field(
default_factory=list, description="Skills loaded from skills/ directory"
)
hooks: HookConfig | None = Field(
default=None, description="Hook configuration from hooks/hooks.json"
)
mcp_config: dict[str, Any] | None = Field(
default=None, description="MCP configuration from .mcp.json"
)
agents: list[AgentDefinition] = Field(
default_factory=list, description="Agent definitions from agents/ directory"
)
commands: list[CommandDefinition] = Field(
default_factory=list, description="Command definitions from commands/ directory"
)
@property
def name(self) -> str:
"""Get the plugin name."""
return self.manifest.name
@property
def version(self) -> str:
"""Get the plugin version."""
return self.manifest.version
@property
def description(self) -> str:
"""Get the plugin description."""
return self.manifest.description
@property
def entry_slash_command(self) -> str | None:
"""Get the full slash command for the entry point, if defined.
Returns the slash command in format /<plugin-name>:<command-name>,
or None if no entry_command is defined in the manifest.
Example:
>>> plugin = Plugin.load(path)
>>> plugin.entry_slash_command
'/city-weather:now'
"""
if not self.manifest.entry_command:
return None
return f"/{self.name}:{self.manifest.entry_command}"
def get_all_skills(self) -> list[Skill]:
"""Get all skills including those converted from commands.
Returns skills from both the skills/ directory and commands/ directory.
Commands are converted to keyword-triggered skills using the format
/<plugin-name>:<command-name>.
Returns:
Combined list of skills (original + command-derived skills).
"""
all_skills = list(self.skills)
# Convert commands to skills with keyword triggers
for command in self.commands:
skill = command.to_skill(self.name)
all_skills.append(skill)
return all_skills
def add_skills_to(
self,
agent_context: AgentContext | None = None,
max_skills: int | None = None,
) -> AgentContext:
"""Add this plugin's skills to an agent context.
Plugin skills override existing skills with the same name.
Includes both explicit skills and command-derived skills.
Args:
agent_context: Existing agent context (or None to create new)
max_skills: Optional max total skills (raises ValueError if exceeded)
Returns:
New AgentContext with this plugin's skills added
Raises:
ValueError: If max_skills limit would be exceeded
Example:
>>> plugin = Plugin.load(Plugin.fetch("github:owner/plugin"))
>>> new_context = plugin.add_skills_to(agent.agent_context, max_skills=100)
>>> agent = agent.model_copy(update={"agent_context": new_context})
"""
# Import at runtime to avoid circular import
from openhands.sdk.context import AgentContext
existing_skills = agent_context.skills if agent_context else []
# Get all skills including command-derived skills
all_skills = self.get_all_skills()
skills_by_name = {s.name: s for s in existing_skills}
for skill in all_skills:
if skill.name in skills_by_name:
logger.warning(f"Plugin skill '{skill.name}' overrides existing skill")
skills_by_name[skill.name] = skill
if max_skills is not None and len(skills_by_name) > max_skills:
raise ValueError(
f"Total skills ({len(skills_by_name)}) exceeds maximum ({max_skills})"
)
merged_skills = list(skills_by_name.values())
if agent_context:
return agent_context.model_copy(update={"skills": merged_skills})
return AgentContext(skills=merged_skills)
def add_mcp_config_to(
self,
mcp_config: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Add this plugin's MCP servers to an MCP config.
Plugin MCP servers override existing servers with the same name.
Merge semantics (Claude Code compatible):
- mcpServers: deep-merge by server name (last plugin wins for same server)
- Other top-level keys: shallow override (plugin wins)
Args:
mcp_config: Existing MCP config (or None to create new)
Returns:
New MCP config dict with this plugin's servers added
Example:
>>> plugin = Plugin.load(Plugin.fetch("github:owner/plugin"))
>>> new_mcp = plugin.add_mcp_config_to(agent.mcp_config)
>>> agent = agent.model_copy(update={"mcp_config": new_mcp})
"""
base_config = mcp_config
plugin_config = self.mcp_config
if base_config is None and plugin_config is None:
return {}
if base_config is None:
return dict(plugin_config) if plugin_config else {}
if plugin_config is None:
return dict(base_config)
# Shallow copy to avoid mutating inputs
result = dict(base_config)
# Merge mcpServers by server name (Claude Code compatible behavior)
if "mcpServers" in plugin_config:
existing_servers = result.get("mcpServers", {})
for server_name in plugin_config["mcpServers"]:
if server_name in existing_servers:
logger.warning(
f"Plugin MCP server '{server_name}' overrides existing server"
)
result["mcpServers"] = {
**existing_servers,
**plugin_config["mcpServers"],
}
# Other top-level keys: plugin wins (shallow override)
for key, value in plugin_config.items():
if key != "mcpServers":
if key in result:
logger.warning(
f"Plugin MCP config key '{key}' overrides existing value"
)
result[key] = value
return result
@classmethod
def fetch(
cls,
source: str,
cache_dir: Path | None = None,
ref: str | None = None,
update: bool = True,
repo_path: str | None = None,
) -> Path:
"""Fetch a plugin from a remote source and return the local cached path.
This method fetches plugins from remote sources (GitHub repositories, git URLs)
and caches them locally. Use the returned path with Plugin.load() to load
the plugin.
Args:
source: Plugin source - can be:
- Any git URL (GitHub, GitLab, Bitbucket, Codeberg, self-hosted, etc.)
e.g., "https://gitlab.com/org/repo", "git@bitbucket.org:team/repo.git"
- "github:owner/repo" - GitHub shorthand (convenience syntax)
- "/local/path" - Local path (returned as-is)
cache_dir: Directory for caching. Defaults to ~/.openhands/cache/plugins/
ref: Optional branch, tag, or commit to checkout.
update: If True and cache exists, update it. If False, use cached as-is.
repo_path: Subdirectory path within the git repository
(e.g., 'plugins/my-plugin' for monorepos). Only relevant for git
sources, not local paths. If specified, the returned path will
point to this subdirectory instead of the repository root.
Returns:
Path to the local plugin directory (ready for Plugin.load()).
If repo_path is specified, returns the path to that subdirectory.
Raises:
PluginFetchError: If fetching fails or repo_path doesn't exist.
Example:
>>> path = Plugin.fetch("github:owner/my-plugin")
>>> plugin = Plugin.load(path)
>>> # With specific version
>>> path = Plugin.fetch("github:owner/my-plugin", ref="v1.0.0")
>>> plugin = Plugin.load(path)
>>> # Fetch a plugin from a subdirectory in a monorepo
>>> path = Plugin.fetch("github:owner/monorepo", repo_path="plugins/sub")
>>> plugin = Plugin.load(path)
>>> # Fetch and load in one step
>>> plugin = Plugin.load(Plugin.fetch("github:owner/my-plugin"))
"""
return fetch_plugin(
source, cache_dir=cache_dir, ref=ref, update=update, repo_path=repo_path
)
@classmethod
def load(cls, plugin_path: str | Path) -> Plugin:
"""Load a plugin from a directory.
Args:
plugin_path: Path to the plugin directory.
Returns:
Loaded Plugin instance.
Raises:
FileNotFoundError: If the plugin directory doesn't exist.
ValueError: If the plugin manifest is invalid.
"""
plugin_dir = Path(plugin_path).resolve()
if not plugin_dir.is_dir():
raise FileNotFoundError(f"Plugin directory not found: {plugin_dir}")
# Load manifest
manifest = _load_manifest(plugin_dir)
# Load skills
skills = _load_skills(plugin_dir)
# Load hooks
hooks = _load_hooks(plugin_dir)
# Load MCP config
mcp_config = _load_mcp_config(plugin_dir)
# Load agents
agents = _load_agents(plugin_dir)
# Load commands
commands = _load_commands(plugin_dir)
return cls(
manifest=manifest,
path=str(plugin_dir),
skills=skills,
hooks=hooks,
mcp_config=mcp_config,
agents=agents,
commands=commands,
)
@classmethod
def load_all(cls, plugins_dir: str | Path) -> list[Plugin]:
"""Load all plugins from a directory.
Args:
plugins_dir: Path to directory containing plugin subdirectories.
Returns:
List of loaded Plugin instances.
"""
plugins_path = Path(plugins_dir).resolve()
if not plugins_path.is_dir():
logger.warning(f"Plugins directory not found: {plugins_path}")
return []
plugins: list[Plugin] = []
for item in plugins_path.iterdir():
if item.is_dir():
try:
plugin = cls.load(item)
plugins.append(plugin)
logger.debug(f"Loaded plugin: {plugin.name} from {item}")
except Exception as e:
logger.warning(f"Failed to load plugin from {item}: {e}")
return plugins
def _load_manifest(plugin_dir: Path) -> PluginManifest:
"""Load plugin manifest from plugin.json.
Checks both .plugin/ and .claude-plugin/ directories.
Falls back to inferring from directory name if no manifest found.
"""
manifest_path = None
# Check for manifest in standard locations
for manifest_dir in PLUGIN_MANIFEST_DIRS:
candidate = plugin_dir / manifest_dir / PLUGIN_MANIFEST_FILE
if candidate.exists():
manifest_path = candidate
break
if manifest_path:
try:
with open(manifest_path) as f:
data = json.load(f)
# Handle author field - can be string or object
if "author" in data and isinstance(data["author"], str):
data["author"] = PluginAuthor.from_string(data["author"]).model_dump()
return PluginManifest.model_validate(data)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON in {manifest_path}: {e}") from e
except Exception as e:
raise ValueError(f"Failed to parse manifest {manifest_path}: {e}") from e
# Fall back to inferring from directory name
logger.debug(f"No manifest found for {plugin_dir}, inferring from directory name")
return PluginManifest(
name=plugin_dir.name,
version="1.0.0",
description=f"Plugin loaded from {plugin_dir.name}",
)
def _load_skills(plugin_dir: Path) -> list[Skill]:
"""Load skills from the skills/ directory.
Note: Plugin skills are loaded with relaxed validation (strict=False)
to support Claude Code plugins which may use different naming conventions.
"""
skills_dir = plugin_dir / "skills"
if not skills_dir.is_dir():
return []
skills: list[Skill] = []
for item in skills_dir.iterdir():
if item.is_dir():
skill_md = find_skill_md(item)
if skill_md:
try:
skill = Skill.load(skill_md, skills_dir, strict=False)
# Discover and attach resources
skill.resources = discover_skill_resources(item)
skills.append(skill)
logger.debug(f"Loaded skill: {skill.name} from {skill_md}")
except Exception as e:
logger.warning(f"Failed to load skill from {item}: {e}")
elif item.suffix == ".md" and item.name.lower() != "readme.md":
# Also support single .md files in skills/ directory
try:
skill = Skill.load(item, skills_dir, strict=False)
skills.append(skill)
logger.debug(f"Loaded skill: {skill.name} from {item}")
except Exception as e:
logger.warning(f"Failed to load skill from {item}: {e}")
return skills
def _load_hooks(plugin_dir: Path) -> HookConfig | None:
"""Load hooks configuration from hooks/hooks.json."""
hooks_json = plugin_dir / "hooks" / "hooks.json"
if not hooks_json.exists():
return None
try:
hook_config = HookConfig.load(path=hooks_json)
# If hooks.json exists but is invalid, HookConfig.load() returns an empty
# config and logs the validation error. Keep that distinct from "file not
# present" (None).
if hook_config.is_empty():
logger.info(f"No hooks configured in {hooks_json}")
return HookConfig()
logger.info(f"Loaded hooks from {hooks_json}")
return hook_config
except Exception as e:
logger.warning(f"Failed to load hooks from {hooks_json}: {e}")
return None
def _load_mcp_config(plugin_dir: Path) -> dict[str, Any] | None:
"""Load MCP configuration from .mcp.json."""
mcp_json = plugin_dir / ".mcp.json"
if not mcp_json.exists():
return None
try:
config = load_mcp_config(mcp_json, skill_root=plugin_dir)
if config and "mcpServers" in config:
server_names = list(config["mcpServers"].keys())
logger.info(
f"Loaded MCP config from {mcp_json} "
f"with {len(server_names)} server(s): {server_names}"
)
return config
except Exception as e:
logger.warning(f"Failed to load MCP config from {mcp_json}: {e}")
return None
def _load_agents(plugin_dir: Path) -> list[AgentDefinition]:
"""Load agent definitions from the agents/ directory."""
agents_dir = plugin_dir / "agents"
if not agents_dir.is_dir():
return []
agents: list[AgentDefinition] = []
for item in agents_dir.iterdir():
if item.suffix == ".md" and item.name.lower() != "readme.md":
try:
agent = AgentDefinition.load(item)
agents.append(agent)
logger.debug(f"Loaded agent: {agent.name} from {item}")
except Exception as e:
logger.warning(f"Failed to load agent from {item}: {e}")
return agents
def _load_commands(plugin_dir: Path) -> list[CommandDefinition]:
"""Load command definitions from the commands/ directory."""
commands_dir = plugin_dir / "commands"
if not commands_dir.is_dir():
return []
commands: list[CommandDefinition] = []
for item in commands_dir.iterdir():
if item.suffix == ".md" and item.name.lower() != "readme.md":
try:
command = CommandDefinition.load(item)
commands.append(command)
logger.debug(f"Loaded command: {command.name} from {item}")
except Exception as e:
logger.warning(f"Failed to load command from {item}: {e}")
return commands