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.
- openhands/sdk/__init__.py +2 -0
- openhands/sdk/agent/agent.py +31 -1
- openhands/sdk/agent/base.py +111 -67
- openhands/sdk/agent/prompts/system_prompt.j2 +1 -1
- openhands/sdk/agent/utils.py +3 -0
- openhands/sdk/context/agent_context.py +45 -3
- openhands/sdk/context/condenser/__init__.py +2 -0
- openhands/sdk/context/condenser/base.py +59 -8
- openhands/sdk/context/condenser/llm_summarizing_condenser.py +38 -10
- openhands/sdk/context/prompts/templates/skill_knowledge_info.j2 +4 -0
- openhands/sdk/context/prompts/templates/system_message_suffix.j2 +9 -0
- openhands/sdk/context/skills/__init__.py +12 -0
- openhands/sdk/context/skills/skill.py +425 -228
- openhands/sdk/context/skills/types.py +4 -0
- openhands/sdk/context/skills/utils.py +442 -0
- openhands/sdk/context/view.py +2 -0
- openhands/sdk/conversation/impl/local_conversation.py +42 -14
- openhands/sdk/conversation/impl/remote_conversation.py +99 -55
- openhands/sdk/conversation/state.py +54 -18
- openhands/sdk/event/llm_convertible/action.py +20 -0
- openhands/sdk/git/utils.py +31 -6
- openhands/sdk/hooks/conversation_hooks.py +57 -10
- openhands/sdk/llm/llm.py +59 -76
- openhands/sdk/llm/options/chat_options.py +4 -1
- openhands/sdk/llm/router/base.py +12 -0
- openhands/sdk/llm/utils/telemetry.py +2 -2
- openhands/sdk/llm/utils/verified_models.py +1 -1
- openhands/sdk/mcp/tool.py +3 -1
- openhands/sdk/plugin/__init__.py +22 -0
- openhands/sdk/plugin/plugin.py +299 -0
- openhands/sdk/plugin/types.py +226 -0
- openhands/sdk/tool/__init__.py +7 -1
- openhands/sdk/tool/builtins/__init__.py +4 -0
- openhands/sdk/tool/schema.py +6 -3
- openhands/sdk/tool/tool.py +60 -9
- openhands/sdk/utils/models.py +198 -472
- openhands/sdk/workspace/base.py +22 -0
- openhands/sdk/workspace/local.py +16 -0
- openhands/sdk/workspace/remote/async_remote_workspace.py +16 -0
- openhands/sdk/workspace/remote/base.py +16 -0
- {openhands_sdk-1.7.3.dist-info → openhands_sdk-1.8.0.dist-info}/METADATA +2 -2
- {openhands_sdk-1.7.3.dist-info → openhands_sdk-1.8.0.dist-info}/RECORD +44 -40
- {openhands_sdk-1.7.3.dist-info → openhands_sdk-1.8.0.dist-info}/WHEEL +0 -0
- {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(
|
|
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="
|
|
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)
|
|
555
|
+
# 4) request context for telemetry (always include context_window for metrics)
|
|
555
556
|
assert self._telemetry is not None
|
|
556
|
-
|
|
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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
#
|
|
692
|
+
# Request context for telemetry (always include context_window for metrics)
|
|
676
693
|
assert self._telemetry is not None
|
|
677
|
-
|
|
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
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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(
|
|
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":
|
|
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
|
openhands/sdk/llm/router/base.py
CHANGED
|
@@ -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,
|
|
76
|
+
def on_request(self, telemetry_ctx: dict | None) -> None:
|
|
77
77
|
self._req_start = time.time()
|
|
78
|
-
self._req_ctx =
|
|
78
|
+
self._req_ctx = telemetry_ctx or {}
|
|
79
79
|
|
|
80
80
|
def on_response(
|
|
81
81
|
self,
|
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
|