openhands-sdk 1.7.3__py3-none-any.whl → 1.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. openhands/sdk/__init__.py +2 -0
  2. openhands/sdk/agent/agent.py +31 -1
  3. openhands/sdk/agent/base.py +111 -67
  4. openhands/sdk/agent/prompts/system_prompt.j2 +1 -1
  5. openhands/sdk/agent/utils.py +3 -0
  6. openhands/sdk/context/agent_context.py +45 -3
  7. openhands/sdk/context/condenser/__init__.py +2 -0
  8. openhands/sdk/context/condenser/base.py +59 -8
  9. openhands/sdk/context/condenser/llm_summarizing_condenser.py +38 -10
  10. openhands/sdk/context/prompts/templates/skill_knowledge_info.j2 +4 -0
  11. openhands/sdk/context/prompts/templates/system_message_suffix.j2 +9 -0
  12. openhands/sdk/context/skills/__init__.py +12 -0
  13. openhands/sdk/context/skills/skill.py +425 -228
  14. openhands/sdk/context/skills/types.py +4 -0
  15. openhands/sdk/context/skills/utils.py +442 -0
  16. openhands/sdk/context/view.py +2 -0
  17. openhands/sdk/conversation/impl/local_conversation.py +42 -14
  18. openhands/sdk/conversation/impl/remote_conversation.py +99 -55
  19. openhands/sdk/conversation/state.py +54 -18
  20. openhands/sdk/event/llm_convertible/action.py +20 -0
  21. openhands/sdk/git/utils.py +31 -6
  22. openhands/sdk/hooks/conversation_hooks.py +57 -10
  23. openhands/sdk/llm/llm.py +59 -76
  24. openhands/sdk/llm/options/chat_options.py +4 -1
  25. openhands/sdk/llm/router/base.py +12 -0
  26. openhands/sdk/llm/utils/telemetry.py +2 -2
  27. openhands/sdk/llm/utils/verified_models.py +1 -1
  28. openhands/sdk/mcp/tool.py +3 -1
  29. openhands/sdk/plugin/__init__.py +22 -0
  30. openhands/sdk/plugin/plugin.py +299 -0
  31. openhands/sdk/plugin/types.py +226 -0
  32. openhands/sdk/tool/__init__.py +7 -1
  33. openhands/sdk/tool/builtins/__init__.py +4 -0
  34. openhands/sdk/tool/schema.py +6 -3
  35. openhands/sdk/tool/tool.py +60 -9
  36. openhands/sdk/utils/models.py +198 -472
  37. openhands/sdk/workspace/base.py +22 -0
  38. openhands/sdk/workspace/local.py +16 -0
  39. openhands/sdk/workspace/remote/async_remote_workspace.py +16 -0
  40. openhands/sdk/workspace/remote/base.py +16 -0
  41. {openhands_sdk-1.7.3.dist-info → openhands_sdk-1.8.0.dist-info}/METADATA +2 -2
  42. {openhands_sdk-1.7.3.dist-info → openhands_sdk-1.8.0.dist-info}/RECORD +44 -40
  43. {openhands_sdk-1.7.3.dist-info → openhands_sdk-1.8.0.dist-info}/WHEEL +0 -0
  44. {openhands_sdk-1.7.3.dist-info → openhands_sdk-1.8.0.dist-info}/top_level.txt +0 -0
openhands/sdk/llm/llm.py CHANGED
@@ -28,8 +28,6 @@ from openhands.sdk.utils.pydantic_secrets import serialize_secret, validate_secr
28
28
  if TYPE_CHECKING: # type hints only, avoid runtime import cycle
29
29
  from openhands.sdk.tool.tool import ToolDefinition
30
30
 
31
- from openhands.sdk.utils.pydantic_diff import pretty_pydantic_diff
32
-
33
31
 
34
32
  with warnings.catch_warnings():
35
33
  warnings.simplefilter("ignore")
@@ -139,7 +137,12 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
139
137
  retry_min_wait: int = Field(default=8, ge=0)
140
138
  retry_max_wait: int = Field(default=64, ge=0)
141
139
 
142
- timeout: int | None = Field(default=None, ge=0, description="HTTP timeout (s).")
140
+ timeout: int | None = Field(
141
+ default=300,
142
+ ge=0,
143
+ description="HTTP timeout in seconds. Default is 300s (5 minutes). "
144
+ "Set to None to disable timeout (not recommended for production).",
145
+ )
143
146
 
144
147
  max_message_chars: int = Field(
145
148
  default=30_000,
@@ -158,7 +161,6 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
158
161
  top_p: float | None = Field(default=1.0, ge=0, le=1)
159
162
  top_k: float | None = Field(default=None, ge=0)
160
163
 
161
- custom_llm_provider: str | None = Field(default=None)
162
164
  max_input_tokens: int | None = Field(
163
165
  default=None,
164
166
  ge=1,
@@ -323,26 +325,13 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
323
325
  exclude=True,
324
326
  )
325
327
  _metrics: Metrics | None = PrivateAttr(default=None)
326
- # ===== Plain class vars (NOT Fields) =====
327
- # When serializing, these fields (SecretStr) will be dump to "****"
328
- # When deserializing, these fields will be ignored and we will override
329
- # them from the LLM instance provided at runtime.
330
- OVERRIDE_ON_SERIALIZE: tuple[str, ...] = (
331
- "api_key",
332
- "aws_access_key_id",
333
- "aws_secret_access_key",
334
- # Dynamic runtime metadata for telemetry/routing that can differ across sessions
335
- # and should not cause resume-time diffs. Always prefer the runtime value.
336
- "litellm_extra_body",
337
- )
338
-
339
328
  # Runtime-only private attrs
340
329
  _model_info: Any = PrivateAttr(default=None)
341
330
  _tokenizer: Any = PrivateAttr(default=None)
342
331
  _telemetry: Telemetry | None = PrivateAttr(default=None)
343
332
 
344
333
  model_config: ClassVar[ConfigDict] = ConfigDict(
345
- extra="forbid", arbitrary_types_allowed=True
334
+ extra="ignore", arbitrary_types_allowed=True
346
335
  )
347
336
 
348
337
  # =========================================================================
@@ -499,9 +488,21 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
499
488
  This is the method for getting responses from the model via Completion API.
500
489
  It handles message formatting, tool calling, and response processing.
501
490
 
491
+ Args:
492
+ messages: List of conversation messages
493
+ tools: Optional list of tools available to the model
494
+ _return_metrics: Whether to return usage metrics
495
+ add_security_risk_prediction: Add security_risk field to tool schemas
496
+ on_token: Optional callback for streaming tokens
497
+ **kwargs: Additional arguments passed to the LLM API
498
+
502
499
  Returns:
503
500
  LLMResponse containing the model's response and metadata.
504
501
 
502
+ Note:
503
+ Summary field is always added to tool schemas for transparency and
504
+ explainability of agent actions.
505
+
505
506
  Raises:
506
507
  ValueError: If streaming is requested (not supported).
507
508
 
@@ -529,7 +530,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
529
530
  if tools:
530
531
  cc_tools = [
531
532
  t.to_openai_tool(
532
- add_security_risk_prediction=add_security_risk_prediction
533
+ add_security_risk_prediction=add_security_risk_prediction,
533
534
  )
534
535
  for t in tools
535
536
  ]
@@ -551,18 +552,20 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
551
552
  # Behavior-preserving: delegate to select_chat_options
552
553
  call_kwargs = select_chat_options(self, kwargs, has_tools=has_tools_flag)
553
554
 
554
- # 4) optional request logging context (kept small)
555
+ # 4) request context for telemetry (always include context_window for metrics)
555
556
  assert self._telemetry is not None
556
- log_ctx = None
557
+ # Always pass context_window so metrics are tracked even when logging disabled
558
+ telemetry_ctx: dict[str, Any] = {"context_window": self.max_input_tokens or 0}
557
559
  if self._telemetry.log_enabled:
558
- log_ctx = {
559
- "messages": formatted_messages[:], # already simple dicts
560
- "tools": tools,
561
- "kwargs": {k: v for k, v in call_kwargs.items()},
562
- "context_window": self.max_input_tokens or 0,
563
- }
560
+ telemetry_ctx.update(
561
+ {
562
+ "messages": formatted_messages[:], # already simple dicts
563
+ "tools": tools,
564
+ "kwargs": {k: v for k, v in call_kwargs.items()},
565
+ }
566
+ )
564
567
  if tools and not use_native_fc:
565
- log_ctx["raw_messages"] = original_fncall_msgs
568
+ telemetry_ctx["raw_messages"] = original_fncall_msgs
566
569
 
567
570
  # 5) do the call with retries
568
571
  @self.retry_decorator(
@@ -575,7 +578,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
575
578
  )
576
579
  def _one_attempt(**retry_kwargs) -> ModelResponse:
577
580
  assert self._telemetry is not None
578
- self._telemetry.on_request(log_ctx=log_ctx)
581
+ self._telemetry.on_request(telemetry_ctx=telemetry_ctx)
579
582
  # Merge retry-modified kwargs (like temperature) with call_kwargs
580
583
  final_kwargs = {**call_kwargs, **retry_kwargs}
581
584
  resp = self._transport_call(
@@ -646,6 +649,20 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
646
649
  """Alternative invocation path using OpenAI Responses API via LiteLLM.
647
650
 
648
651
  Maps Message[] -> (instructions, input[]) and returns LLMResponse.
652
+
653
+ Args:
654
+ messages: List of conversation messages
655
+ tools: Optional list of tools available to the model
656
+ include: Optional list of fields to include in response
657
+ store: Whether to store the conversation
658
+ _return_metrics: Whether to return usage metrics
659
+ add_security_risk_prediction: Add security_risk field to tool schemas
660
+ on_token: Optional callback for streaming tokens (not yet supported)
661
+ **kwargs: Additional arguments passed to the API
662
+
663
+ Note:
664
+ Summary field is always added to tool schemas for transparency and
665
+ explainability of agent actions.
649
666
  """
650
667
  # Streaming not yet supported
651
668
  if kwargs.get("stream", False) or self.stream or on_token is not None:
@@ -659,7 +676,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
659
676
  resp_tools = (
660
677
  [
661
678
  t.to_responses_tool(
662
- add_security_risk_prediction=add_security_risk_prediction
679
+ add_security_risk_prediction=add_security_risk_prediction,
663
680
  )
664
681
  for t in tools
665
682
  ]
@@ -672,17 +689,19 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
672
689
  self, kwargs, include=include, store=store
673
690
  )
674
691
 
675
- # Optional request logging
692
+ # Request context for telemetry (always include context_window for metrics)
676
693
  assert self._telemetry is not None
677
- log_ctx = None
694
+ # Always pass context_window so metrics are tracked even when logging disabled
695
+ telemetry_ctx: dict[str, Any] = {"context_window": self.max_input_tokens or 0}
678
696
  if self._telemetry.log_enabled:
679
- log_ctx = {
680
- "llm_path": "responses",
681
- "input": input_items[:],
682
- "tools": tools,
683
- "kwargs": {k: v for k, v in call_kwargs.items()},
684
- "context_window": self.max_input_tokens or 0,
685
- }
697
+ telemetry_ctx.update(
698
+ {
699
+ "llm_path": "responses",
700
+ "input": input_items[:],
701
+ "tools": tools,
702
+ "kwargs": {k: v for k, v in call_kwargs.items()},
703
+ }
704
+ )
686
705
 
687
706
  # Perform call with retries
688
707
  @self.retry_decorator(
@@ -695,7 +714,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
695
714
  )
696
715
  def _one_attempt(**retry_kwargs) -> ResponsesAPIResponse:
697
716
  assert self._telemetry is not None
698
- self._telemetry.on_request(log_ctx=log_ctx)
717
+ self._telemetry.on_request(telemetry_ctx=telemetry_ctx)
699
718
  final_kwargs = {**call_kwargs, **retry_kwargs}
700
719
  with self._litellm_modify_params_ctx(self.modify_params):
701
720
  with warnings.catch_warnings():
@@ -1102,39 +1121,3 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
1102
1121
  if v is not None:
1103
1122
  data[field_name] = v
1104
1123
  return cls(**data)
1105
-
1106
- def resolve_diff_from_deserialized(self, persisted: LLM) -> LLM:
1107
- """Resolve differences between a deserialized LLM and the current instance.
1108
-
1109
- This is due to fields like api_key being serialized to "****" in dumps,
1110
- and we want to ensure that when loading from a file, we still use the
1111
- runtime-provided api_key in the self instance.
1112
-
1113
- Return a new LLM instance equivalent to `persisted` but with
1114
- explicitly whitelisted fields (e.g. api_key) taken from `self`.
1115
- """
1116
- if persisted.__class__ is not self.__class__:
1117
- raise ValueError(
1118
- f"Cannot resolve_diff_from_deserialized between {self.__class__} "
1119
- f"and {persisted.__class__}"
1120
- )
1121
-
1122
- # Copy allowed fields from runtime llm into the persisted llm
1123
- llm_updates = {}
1124
- persisted_dump = persisted.model_dump(context={"expose_secrets": True})
1125
- for field in self.OVERRIDE_ON_SERIALIZE:
1126
- if field in persisted_dump.keys():
1127
- llm_updates[field] = getattr(self, field)
1128
- if llm_updates:
1129
- reconciled = persisted.model_copy(update=llm_updates)
1130
- else:
1131
- reconciled = persisted
1132
-
1133
- dump = self.model_dump(context={"expose_secrets": True})
1134
- reconciled_dump = reconciled.model_dump(context={"expose_secrets": True})
1135
- if dump != reconciled_dump:
1136
- raise ValueError(
1137
- "The LLM provided is different from the one in persisted state.\n"
1138
- f"Diff: {pretty_pydantic_diff(self, reconciled)}"
1139
- )
1140
- return reconciled
@@ -51,9 +51,12 @@ def select_chat_options(
51
51
  # Extended thinking models
52
52
  if get_features(llm.model).supports_extended_thinking:
53
53
  if llm.extended_thinking_budget:
54
+ # Anthropic throws errors if thinking budget equals or exceeds max output
55
+ # tokens -- force the thinking budget lower if there's a conflict
56
+ budget_tokens = min(llm.extended_thinking_budget, llm.max_output_tokens - 1)
54
57
  out["thinking"] = {
55
58
  "type": "enabled",
56
- "budget_tokens": llm.extended_thinking_budget,
59
+ "budget_tokens": budget_tokens,
57
60
  }
58
61
  # Enable interleaved thinking
59
62
  # Merge default header with any user-provided headers; user wins on conflict
@@ -59,6 +59,18 @@ class RouterLLM(LLM):
59
59
  """
60
60
  This method intercepts completion calls and routes them to the appropriate
61
61
  underlying LLM based on the routing logic implemented in select_llm().
62
+
63
+ Args:
64
+ messages: List of conversation messages
65
+ tools: Optional list of tools available to the model
66
+ return_metrics: Whether to return usage metrics
67
+ add_security_risk_prediction: Add security_risk field to tool schemas
68
+ on_token: Optional callback for streaming tokens
69
+ **kwargs: Additional arguments passed to the LLM API
70
+
71
+ Note:
72
+ Summary field is always added to tool schemas for transparency and
73
+ explainability of agent actions.
62
74
  """
63
75
  # Select appropriate LLM
64
76
  selected_model = self.select_llm(messages)
@@ -73,9 +73,9 @@ class Telemetry(BaseModel):
73
73
  """
74
74
  self._stats_update_callback = callback
75
75
 
76
- def on_request(self, log_ctx: dict | None) -> None:
76
+ def on_request(self, telemetry_ctx: dict | None) -> None:
77
77
  self._req_start = time.time()
78
- self._req_ctx = log_ctx or {}
78
+ self._req_ctx = telemetry_ctx or {}
79
79
 
80
80
  def on_response(
81
81
  self,
@@ -50,7 +50,7 @@ VERIFIED_OPENHANDS_MODELS = [
50
50
  "gpt-5.1-codex",
51
51
  "gpt-5.1",
52
52
  "gemini-3-pro-preview",
53
- "deekseek-chat",
53
+ "deepseek-chat",
54
54
  "kimi-k2-thinking",
55
55
  "devstral-medium-2512",
56
56
  "devstral-2512",
openhands/sdk/mcp/tool.py CHANGED
@@ -186,7 +186,9 @@ class MCPToolDefinition(ToolDefinition[MCPToolAction, MCPToolObservation]):
186
186
  # Use exclude_none to avoid injecting nulls back to the call
187
187
  # Exclude DiscriminatedUnionMixin fields (e.g., 'kind') as they're
188
188
  # internal to OpenHands and not part of the MCP tool schema
189
- exclude_fields = set(DiscriminatedUnionMixin.model_fields.keys())
189
+ exclude_fields = set(DiscriminatedUnionMixin.model_fields.keys()) | set(
190
+ DiscriminatedUnionMixin.model_computed_fields.keys()
191
+ )
190
192
  sanitized = validated.model_dump(exclude_none=True, exclude=exclude_fields)
191
193
  return MCPToolAction(data=sanitized)
192
194
 
@@ -0,0 +1,22 @@
1
+ """Plugin module for OpenHands SDK.
2
+
3
+ This module provides support for loading and managing plugins that bundle
4
+ skills, hooks, MCP configurations, agents, and commands together.
5
+ """
6
+
7
+ from openhands.sdk.plugin.plugin import Plugin
8
+ from openhands.sdk.plugin.types import (
9
+ AgentDefinition,
10
+ CommandDefinition,
11
+ PluginAuthor,
12
+ PluginManifest,
13
+ )
14
+
15
+
16
+ __all__ = [
17
+ "Plugin",
18
+ "PluginManifest",
19
+ "PluginAuthor",
20
+ "AgentDefinition",
21
+ "CommandDefinition",
22
+ ]
@@ -0,0 +1,299 @@
1
+ """Plugin class for loading and managing plugins."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+ from openhands.sdk.context.skills import Skill
12
+ from openhands.sdk.context.skills.utils import (
13
+ discover_skill_resources,
14
+ find_skill_md,
15
+ load_mcp_config,
16
+ )
17
+ from openhands.sdk.hooks import HookConfig
18
+ from openhands.sdk.logger import get_logger
19
+ from openhands.sdk.plugin.types import (
20
+ AgentDefinition,
21
+ CommandDefinition,
22
+ PluginAuthor,
23
+ PluginManifest,
24
+ )
25
+
26
+
27
+ logger = get_logger(__name__)
28
+
29
+ # Directories to check for plugin manifest
30
+ PLUGIN_MANIFEST_DIRS = [".plugin", ".claude-plugin"]
31
+ PLUGIN_MANIFEST_FILE = "plugin.json"
32
+
33
+
34
+ class Plugin(BaseModel):
35
+ """A plugin that bundles skills, hooks, MCP config, agents, and commands.
36
+
37
+ Plugins follow the Claude Code plugin structure for compatibility:
38
+
39
+ ```
40
+ plugin-name/
41
+ ├── .claude-plugin/ # or .plugin/
42
+ │ └── plugin.json # Plugin metadata
43
+ ├── commands/ # Slash commands (optional)
44
+ ├── agents/ # Specialized agents (optional)
45
+ ├── skills/ # Agent Skills (optional)
46
+ ├── hooks/ # Event handlers (optional)
47
+ │ └── hooks.json
48
+ ├── .mcp.json # External tool configuration (optional)
49
+ └── README.md # Plugin documentation
50
+ ```
51
+ """
52
+
53
+ manifest: PluginManifest = Field(description="Plugin manifest from plugin.json")
54
+ path: str = Field(description="Path to the plugin directory")
55
+ skills: list[Skill] = Field(
56
+ default_factory=list, description="Skills loaded from skills/ directory"
57
+ )
58
+ hooks: HookConfig | None = Field(
59
+ default=None, description="Hook configuration from hooks/hooks.json"
60
+ )
61
+ mcp_config: dict[str, Any] | None = Field(
62
+ default=None, description="MCP configuration from .mcp.json"
63
+ )
64
+ agents: list[AgentDefinition] = Field(
65
+ default_factory=list, description="Agent definitions from agents/ directory"
66
+ )
67
+ commands: list[CommandDefinition] = Field(
68
+ default_factory=list, description="Command definitions from commands/ directory"
69
+ )
70
+
71
+ @property
72
+ def name(self) -> str:
73
+ """Get the plugin name."""
74
+ return self.manifest.name
75
+
76
+ @property
77
+ def version(self) -> str:
78
+ """Get the plugin version."""
79
+ return self.manifest.version
80
+
81
+ @property
82
+ def description(self) -> str:
83
+ """Get the plugin description."""
84
+ return self.manifest.description
85
+
86
+ @classmethod
87
+ def load(cls, plugin_path: str | Path) -> Plugin:
88
+ """Load a plugin from a directory.
89
+
90
+ Args:
91
+ plugin_path: Path to the plugin directory.
92
+
93
+ Returns:
94
+ Loaded Plugin instance.
95
+
96
+ Raises:
97
+ FileNotFoundError: If the plugin directory doesn't exist.
98
+ ValueError: If the plugin manifest is invalid.
99
+ """
100
+ plugin_dir = Path(plugin_path).resolve()
101
+ if not plugin_dir.is_dir():
102
+ raise FileNotFoundError(f"Plugin directory not found: {plugin_dir}")
103
+
104
+ # Load manifest
105
+ manifest = _load_manifest(plugin_dir)
106
+
107
+ # Load skills
108
+ skills = _load_skills(plugin_dir)
109
+
110
+ # Load hooks
111
+ hooks = _load_hooks(plugin_dir)
112
+
113
+ # Load MCP config
114
+ mcp_config = _load_mcp_config(plugin_dir)
115
+
116
+ # Load agents
117
+ agents = _load_agents(plugin_dir)
118
+
119
+ # Load commands
120
+ commands = _load_commands(plugin_dir)
121
+
122
+ return cls(
123
+ manifest=manifest,
124
+ path=str(plugin_dir),
125
+ skills=skills,
126
+ hooks=hooks,
127
+ mcp_config=mcp_config,
128
+ agents=agents,
129
+ commands=commands,
130
+ )
131
+
132
+ @classmethod
133
+ def load_all(cls, plugins_dir: str | Path) -> list[Plugin]:
134
+ """Load all plugins from a directory.
135
+
136
+ Args:
137
+ plugins_dir: Path to directory containing plugin subdirectories.
138
+
139
+ Returns:
140
+ List of loaded Plugin instances.
141
+ """
142
+ plugins_path = Path(plugins_dir).resolve()
143
+ if not plugins_path.is_dir():
144
+ logger.warning(f"Plugins directory not found: {plugins_path}")
145
+ return []
146
+
147
+ plugins: list[Plugin] = []
148
+ for item in plugins_path.iterdir():
149
+ if item.is_dir():
150
+ try:
151
+ plugin = cls.load(item)
152
+ plugins.append(plugin)
153
+ logger.debug(f"Loaded plugin: {plugin.name} from {item}")
154
+ except Exception as e:
155
+ logger.warning(f"Failed to load plugin from {item}: {e}")
156
+
157
+ return plugins
158
+
159
+
160
+ def _load_manifest(plugin_dir: Path) -> PluginManifest:
161
+ """Load plugin manifest from plugin.json.
162
+
163
+ Checks both .plugin/ and .claude-plugin/ directories.
164
+ Falls back to inferring from directory name if no manifest found.
165
+ """
166
+ manifest_path = None
167
+
168
+ # Check for manifest in standard locations
169
+ for manifest_dir in PLUGIN_MANIFEST_DIRS:
170
+ candidate = plugin_dir / manifest_dir / PLUGIN_MANIFEST_FILE
171
+ if candidate.exists():
172
+ manifest_path = candidate
173
+ break
174
+
175
+ if manifest_path:
176
+ try:
177
+ with open(manifest_path) as f:
178
+ data = json.load(f)
179
+
180
+ # Handle author field - can be string or object
181
+ if "author" in data and isinstance(data["author"], str):
182
+ data["author"] = PluginAuthor.from_string(data["author"]).model_dump()
183
+
184
+ return PluginManifest.model_validate(data)
185
+ except json.JSONDecodeError as e:
186
+ raise ValueError(f"Invalid JSON in {manifest_path}: {e}") from e
187
+ except Exception as e:
188
+ raise ValueError(f"Failed to parse manifest {manifest_path}: {e}") from e
189
+
190
+ # Fall back to inferring from directory name
191
+ logger.debug(f"No manifest found for {plugin_dir}, inferring from directory name")
192
+ return PluginManifest(
193
+ name=plugin_dir.name,
194
+ version="1.0.0",
195
+ description=f"Plugin loaded from {plugin_dir.name}",
196
+ )
197
+
198
+
199
+ def _load_skills(plugin_dir: Path) -> list[Skill]:
200
+ """Load skills from the skills/ directory.
201
+
202
+ Note: Plugin skills are loaded with relaxed validation (strict=False)
203
+ to support Claude Code plugins which may use different naming conventions.
204
+ """
205
+ skills_dir = plugin_dir / "skills"
206
+ if not skills_dir.is_dir():
207
+ return []
208
+
209
+ skills: list[Skill] = []
210
+ for item in skills_dir.iterdir():
211
+ if item.is_dir():
212
+ skill_md = find_skill_md(item)
213
+ if skill_md:
214
+ try:
215
+ skill = Skill.load(skill_md, skills_dir, strict=False)
216
+ # Discover and attach resources
217
+ skill.resources = discover_skill_resources(item)
218
+ skills.append(skill)
219
+ logger.debug(f"Loaded skill: {skill.name} from {skill_md}")
220
+ except Exception as e:
221
+ logger.warning(f"Failed to load skill from {item}: {e}")
222
+ elif item.suffix == ".md" and item.name.lower() != "readme.md":
223
+ # Also support single .md files in skills/ directory
224
+ try:
225
+ skill = Skill.load(item, skills_dir, strict=False)
226
+ skills.append(skill)
227
+ logger.debug(f"Loaded skill: {skill.name} from {item}")
228
+ except Exception as e:
229
+ logger.warning(f"Failed to load skill from {item}: {e}")
230
+
231
+ return skills
232
+
233
+
234
+ def _load_hooks(plugin_dir: Path) -> HookConfig | None:
235
+ """Load hooks configuration from hooks/hooks.json."""
236
+ hooks_json = plugin_dir / "hooks" / "hooks.json"
237
+ if not hooks_json.exists():
238
+ return None
239
+
240
+ try:
241
+ hook_config = HookConfig.load(path=hooks_json)
242
+ # load() returns empty config on error, check if it has hooks
243
+ if hook_config.hooks:
244
+ return hook_config
245
+ return None
246
+ except Exception as e:
247
+ logger.warning(f"Failed to load hooks from {hooks_json}: {e}")
248
+ return None
249
+
250
+
251
+ def _load_mcp_config(plugin_dir: Path) -> dict[str, Any] | None:
252
+ """Load MCP configuration from .mcp.json."""
253
+ mcp_json = plugin_dir / ".mcp.json"
254
+ if not mcp_json.exists():
255
+ return None
256
+
257
+ try:
258
+ return load_mcp_config(mcp_json, skill_root=plugin_dir)
259
+ except Exception as e:
260
+ logger.warning(f"Failed to load MCP config from {mcp_json}: {e}")
261
+ return None
262
+
263
+
264
+ def _load_agents(plugin_dir: Path) -> list[AgentDefinition]:
265
+ """Load agent definitions from the agents/ directory."""
266
+ agents_dir = plugin_dir / "agents"
267
+ if not agents_dir.is_dir():
268
+ return []
269
+
270
+ agents: list[AgentDefinition] = []
271
+ for item in agents_dir.iterdir():
272
+ if item.suffix == ".md" and item.name.lower() != "readme.md":
273
+ try:
274
+ agent = AgentDefinition.load(item)
275
+ agents.append(agent)
276
+ logger.debug(f"Loaded agent: {agent.name} from {item}")
277
+ except Exception as e:
278
+ logger.warning(f"Failed to load agent from {item}: {e}")
279
+
280
+ return agents
281
+
282
+
283
+ def _load_commands(plugin_dir: Path) -> list[CommandDefinition]:
284
+ """Load command definitions from the commands/ directory."""
285
+ commands_dir = plugin_dir / "commands"
286
+ if not commands_dir.is_dir():
287
+ return []
288
+
289
+ commands: list[CommandDefinition] = []
290
+ for item in commands_dir.iterdir():
291
+ if item.suffix == ".md" and item.name.lower() != "readme.md":
292
+ try:
293
+ command = CommandDefinition.load(item)
294
+ commands.append(command)
295
+ logger.debug(f"Loaded command: {command.name} from {item}")
296
+ except Exception as e:
297
+ logger.warning(f"Failed to load command from {item}: {e}")
298
+
299
+ return commands