openhands-sdk 1.8.2__py3-none-any.whl → 1.9.1__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/agent/agent.py +64 -0
- openhands/sdk/agent/base.py +22 -10
- openhands/sdk/context/skills/skill.py +59 -1
- openhands/sdk/context/skills/utils.py +6 -65
- openhands/sdk/conversation/base.py +5 -0
- openhands/sdk/conversation/impl/remote_conversation.py +16 -3
- openhands/sdk/conversation/visualizer/base.py +23 -0
- openhands/sdk/critic/__init__.py +4 -1
- openhands/sdk/critic/base.py +17 -20
- openhands/sdk/critic/impl/__init__.py +2 -0
- openhands/sdk/critic/impl/agent_finished.py +9 -5
- openhands/sdk/critic/impl/api/__init__.py +18 -0
- openhands/sdk/critic/impl/api/chat_template.py +232 -0
- openhands/sdk/critic/impl/api/client.py +313 -0
- openhands/sdk/critic/impl/api/critic.py +90 -0
- openhands/sdk/critic/impl/api/taxonomy.py +180 -0
- openhands/sdk/critic/result.py +148 -0
- openhands/sdk/event/llm_convertible/action.py +10 -0
- openhands/sdk/event/llm_convertible/message.py +10 -0
- openhands/sdk/git/cached_repo.py +459 -0
- openhands/sdk/git/utils.py +118 -3
- openhands/sdk/hooks/__init__.py +7 -1
- openhands/sdk/hooks/config.py +154 -45
- openhands/sdk/llm/utils/model_features.py +3 -0
- openhands/sdk/plugin/__init__.py +17 -0
- openhands/sdk/plugin/fetch.py +231 -0
- openhands/sdk/plugin/plugin.py +61 -4
- openhands/sdk/plugin/types.py +394 -1
- {openhands_sdk-1.8.2.dist-info → openhands_sdk-1.9.1.dist-info}/METADATA +5 -1
- {openhands_sdk-1.8.2.dist-info → openhands_sdk-1.9.1.dist-info}/RECORD +32 -24
- {openhands_sdk-1.8.2.dist-info → openhands_sdk-1.9.1.dist-info}/WHEEL +1 -1
- {openhands_sdk-1.8.2.dist-info → openhands_sdk-1.9.1.dist-info}/top_level.txt +0 -0
openhands/sdk/hooks/config.py
CHANGED
|
@@ -7,7 +7,7 @@ from enum import Enum
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from typing import Any
|
|
9
9
|
|
|
10
|
-
from pydantic import BaseModel, Field
|
|
10
|
+
from pydantic import BaseModel, Field, model_validator
|
|
11
11
|
|
|
12
12
|
from openhands.sdk.hooks.types import HookEventType
|
|
13
13
|
|
|
@@ -15,6 +15,26 @@ from openhands.sdk.hooks.types import HookEventType
|
|
|
15
15
|
logger = logging.getLogger(__name__)
|
|
16
16
|
|
|
17
17
|
|
|
18
|
+
def _pascal_to_snake(name: str) -> str:
|
|
19
|
+
"""Convert PascalCase to snake_case."""
|
|
20
|
+
# Insert underscore before uppercase letters and lowercase everything
|
|
21
|
+
result = re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower()
|
|
22
|
+
return result
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Valid snake_case field names for hook events
|
|
26
|
+
_VALID_HOOK_FIELDS: frozenset[str] = frozenset(
|
|
27
|
+
{
|
|
28
|
+
"pre_tool_use",
|
|
29
|
+
"post_tool_use",
|
|
30
|
+
"user_prompt_submit",
|
|
31
|
+
"session_start",
|
|
32
|
+
"session_end",
|
|
33
|
+
"stop",
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
18
38
|
class HookType(str, Enum):
|
|
19
39
|
"""Types of hooks that can be executed."""
|
|
20
40
|
|
|
@@ -77,9 +97,117 @@ class HookMatcher(BaseModel):
|
|
|
77
97
|
|
|
78
98
|
|
|
79
99
|
class HookConfig(BaseModel):
|
|
80
|
-
"""Configuration for all hooks
|
|
100
|
+
"""Configuration for all hooks.
|
|
101
|
+
|
|
102
|
+
Hooks can be configured either by loading from `.openhands/hooks.json` or
|
|
103
|
+
by directly instantiating with typed fields:
|
|
104
|
+
|
|
105
|
+
# Direct instantiation with typed fields (recommended):
|
|
106
|
+
config = HookConfig(
|
|
107
|
+
pre_tool_use=[
|
|
108
|
+
HookMatcher(
|
|
109
|
+
matcher="terminal",
|
|
110
|
+
hooks=[HookDefinition(command="block_dangerous.sh")]
|
|
111
|
+
)
|
|
112
|
+
]
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Load from JSON file:
|
|
116
|
+
config = HookConfig.load(".openhands/hooks.json")
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
model_config = {
|
|
120
|
+
"extra": "forbid",
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
pre_tool_use: list[HookMatcher] = Field(
|
|
124
|
+
default_factory=list,
|
|
125
|
+
description="Hooks that run before tool execution",
|
|
126
|
+
)
|
|
127
|
+
post_tool_use: list[HookMatcher] = Field(
|
|
128
|
+
default_factory=list,
|
|
129
|
+
description="Hooks that run after tool execution",
|
|
130
|
+
)
|
|
131
|
+
user_prompt_submit: list[HookMatcher] = Field(
|
|
132
|
+
default_factory=list,
|
|
133
|
+
description="Hooks that run when user submits a prompt",
|
|
134
|
+
)
|
|
135
|
+
session_start: list[HookMatcher] = Field(
|
|
136
|
+
default_factory=list,
|
|
137
|
+
description="Hooks that run when a session starts",
|
|
138
|
+
)
|
|
139
|
+
session_end: list[HookMatcher] = Field(
|
|
140
|
+
default_factory=list,
|
|
141
|
+
description="Hooks that run when a session ends",
|
|
142
|
+
)
|
|
143
|
+
stop: list[HookMatcher] = Field(
|
|
144
|
+
default_factory=list,
|
|
145
|
+
description="Hooks that run when the agent attempts to stop",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def is_empty(self) -> bool:
|
|
149
|
+
"""Check if this config has no hooks configured."""
|
|
150
|
+
return not any(
|
|
151
|
+
[
|
|
152
|
+
self.pre_tool_use,
|
|
153
|
+
self.post_tool_use,
|
|
154
|
+
self.user_prompt_submit,
|
|
155
|
+
self.session_start,
|
|
156
|
+
self.session_end,
|
|
157
|
+
self.stop,
|
|
158
|
+
]
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
@model_validator(mode="before")
|
|
162
|
+
@classmethod
|
|
163
|
+
def _normalize_hooks_input(cls, data: Any) -> Any:
|
|
164
|
+
"""Support JSON format with PascalCase keys and 'hooks' wrapper.
|
|
165
|
+
|
|
166
|
+
We intentionally continue supporting these formats for interoperability with
|
|
167
|
+
existing integrations (e.g. Claude Code plugin hook files).
|
|
168
|
+
"""
|
|
169
|
+
if not isinstance(data, dict):
|
|
170
|
+
return data
|
|
171
|
+
|
|
172
|
+
# Unwrap legacy format: {"hooks": {"PreToolUse": [...]}}
|
|
173
|
+
if "hooks" in data:
|
|
174
|
+
if len(data) != 1:
|
|
175
|
+
logger.warning(
|
|
176
|
+
'HookConfig legacy wrapper format should be {"hooks": {...}}. '
|
|
177
|
+
"Extra top-level keys will be ignored."
|
|
178
|
+
)
|
|
179
|
+
data = data["hooks"]
|
|
180
|
+
|
|
181
|
+
# Convert PascalCase keys to snake_case field names
|
|
182
|
+
normalized: dict[str, Any] = {}
|
|
183
|
+
seen_fields: set[str] = set()
|
|
184
|
+
|
|
185
|
+
for key, value in data.items():
|
|
186
|
+
snake_key = _pascal_to_snake(key)
|
|
187
|
+
is_pascal_case = snake_key != key
|
|
188
|
+
|
|
189
|
+
if is_pascal_case:
|
|
190
|
+
# Validate that PascalCase key maps to a known field
|
|
191
|
+
if snake_key not in _VALID_HOOK_FIELDS:
|
|
192
|
+
valid_types = ", ".join(sorted(_VALID_HOOK_FIELDS))
|
|
193
|
+
raise ValueError(
|
|
194
|
+
f"Unknown event type '{key}'. Valid types: {valid_types}"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# Check for duplicate keys (both PascalCase and snake_case provided)
|
|
198
|
+
if snake_key in seen_fields:
|
|
199
|
+
raise ValueError(
|
|
200
|
+
f"Duplicate hook event: both '{key}' and its snake_case "
|
|
201
|
+
f"equivalent '{snake_key}' were provided"
|
|
202
|
+
)
|
|
203
|
+
seen_fields.add(snake_key)
|
|
204
|
+
normalized[snake_key] = value
|
|
205
|
+
|
|
206
|
+
# Preserve backwards compatibility without deprecating any supported formats.
|
|
207
|
+
# The legacy 'hooks' wrapper and PascalCase keys are accepted for
|
|
208
|
+
# interoperability and should not emit a deprecation warning.
|
|
81
209
|
|
|
82
|
-
|
|
210
|
+
return normalized
|
|
83
211
|
|
|
84
212
|
@classmethod
|
|
85
213
|
def load(
|
|
@@ -111,49 +239,34 @@ class HookConfig(BaseModel):
|
|
|
111
239
|
if not path.exists():
|
|
112
240
|
return cls()
|
|
113
241
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
except (json.JSONDecodeError, OSError) as e:
|
|
119
|
-
# Log warning but don't fail - just return empty config
|
|
120
|
-
logger.warning(f"Failed to load hooks from {path}: {e}")
|
|
121
|
-
return cls()
|
|
242
|
+
with open(path) as f:
|
|
243
|
+
data = json.load(f)
|
|
244
|
+
# Use model_validate which triggers the model_validator
|
|
245
|
+
return cls.model_validate(data)
|
|
122
246
|
|
|
123
247
|
@classmethod
|
|
124
248
|
def from_dict(cls, data: dict[str, Any]) -> "HookConfig":
|
|
125
|
-
"""Create HookConfig from a dictionary.
|
|
126
|
-
|
|
127
|
-
hooks
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if not isinstance(matchers, list):
|
|
131
|
-
continue
|
|
132
|
-
|
|
133
|
-
hooks[event_type] = []
|
|
134
|
-
for matcher_data in matchers:
|
|
135
|
-
if isinstance(matcher_data, dict):
|
|
136
|
-
# Parse hooks within the matcher
|
|
137
|
-
hook_defs = []
|
|
138
|
-
for hook_data in matcher_data.get("hooks", []):
|
|
139
|
-
if isinstance(hook_data, dict):
|
|
140
|
-
hook_defs.append(HookDefinition(**hook_data))
|
|
141
|
-
|
|
142
|
-
hooks[event_type].append(
|
|
143
|
-
HookMatcher(
|
|
144
|
-
matcher=matcher_data.get("matcher", "*"),
|
|
145
|
-
hooks=hook_defs,
|
|
146
|
-
)
|
|
147
|
-
)
|
|
249
|
+
"""Create HookConfig from a dictionary.
|
|
250
|
+
|
|
251
|
+
Supports both legacy format with "hooks" wrapper and direct format:
|
|
252
|
+
# Legacy format:
|
|
253
|
+
{"hooks": {"PreToolUse": [...]}}
|
|
148
254
|
|
|
149
|
-
|
|
255
|
+
# Direct format:
|
|
256
|
+
{"PreToolUse": [...]}
|
|
257
|
+
"""
|
|
258
|
+
return cls.model_validate(data)
|
|
259
|
+
|
|
260
|
+
def _get_matchers_for_event(self, event_type: HookEventType) -> list[HookMatcher]:
|
|
261
|
+
"""Get matchers for an event type."""
|
|
262
|
+
field_name = _pascal_to_snake(event_type.value)
|
|
263
|
+
return getattr(self, field_name, [])
|
|
150
264
|
|
|
151
265
|
def get_hooks_for_event(
|
|
152
266
|
self, event_type: HookEventType, tool_name: str | None = None
|
|
153
267
|
) -> list[HookDefinition]:
|
|
154
268
|
"""Get all hooks that should run for an event."""
|
|
155
|
-
|
|
156
|
-
matchers = self.hooks.get(event_key, [])
|
|
269
|
+
matchers = self._get_matchers_for_event(event_type)
|
|
157
270
|
|
|
158
271
|
result: list[HookDefinition] = []
|
|
159
272
|
for matcher in matchers:
|
|
@@ -164,17 +277,13 @@ class HookConfig(BaseModel):
|
|
|
164
277
|
|
|
165
278
|
def has_hooks_for_event(self, event_type: HookEventType) -> bool:
|
|
166
279
|
"""Check if there are any hooks configured for an event type."""
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
def to_dict(self) -> dict[str, Any]:
|
|
170
|
-
"""Convert to dictionary format for serialization."""
|
|
171
|
-
hooks_dict = {k: [m.model_dump() for m in v] for k, v in self.hooks.items()}
|
|
172
|
-
return {"hooks": hooks_dict}
|
|
280
|
+
matchers = self._get_matchers_for_event(event_type)
|
|
281
|
+
return len(matchers) > 0
|
|
173
282
|
|
|
174
283
|
def save(self, path: str | Path) -> None:
|
|
175
|
-
"""Save hook configuration to a JSON file."""
|
|
284
|
+
"""Save hook configuration to a JSON file using snake_case field names."""
|
|
176
285
|
path = Path(path)
|
|
177
286
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
178
287
|
|
|
179
288
|
with open(path, "w") as f:
|
|
180
|
-
json.dump(self.
|
|
289
|
+
json.dump(self.model_dump(mode="json", exclude_defaults=True), f, indent=2)
|
|
@@ -63,6 +63,9 @@ REASONING_EFFORT_MODELS: list[str] = [
|
|
|
63
63
|
"o4-mini-2025-04-16",
|
|
64
64
|
"gemini-2.5-flash",
|
|
65
65
|
"gemini-2.5-pro",
|
|
66
|
+
# Gemini 3 family
|
|
67
|
+
"gemini-3-flash-preview",
|
|
68
|
+
"gemini-3-pro-preview",
|
|
66
69
|
# OpenAI GPT-5 family (includes mini variants)
|
|
67
70
|
"gpt-5",
|
|
68
71
|
# Anthropic Opus 4.5
|
openhands/sdk/plugin/__init__.py
CHANGED
|
@@ -2,21 +2,38 @@
|
|
|
2
2
|
|
|
3
3
|
This module provides support for loading and managing plugins that bundle
|
|
4
4
|
skills, hooks, MCP configurations, agents, and commands together.
|
|
5
|
+
|
|
6
|
+
It also provides support for plugin marketplaces - directories that list
|
|
7
|
+
available plugins with their metadata and source locations.
|
|
5
8
|
"""
|
|
6
9
|
|
|
10
|
+
from openhands.sdk.plugin.fetch import PluginFetchError
|
|
7
11
|
from openhands.sdk.plugin.plugin import Plugin
|
|
8
12
|
from openhands.sdk.plugin.types import (
|
|
9
13
|
AgentDefinition,
|
|
10
14
|
CommandDefinition,
|
|
15
|
+
Marketplace,
|
|
16
|
+
MarketplaceMetadata,
|
|
17
|
+
MarketplaceOwner,
|
|
18
|
+
MarketplacePluginEntry,
|
|
19
|
+
MarketplacePluginSource,
|
|
11
20
|
PluginAuthor,
|
|
12
21
|
PluginManifest,
|
|
13
22
|
)
|
|
14
23
|
|
|
15
24
|
|
|
16
25
|
__all__ = [
|
|
26
|
+
# Plugin classes
|
|
17
27
|
"Plugin",
|
|
28
|
+
"PluginFetchError",
|
|
18
29
|
"PluginManifest",
|
|
19
30
|
"PluginAuthor",
|
|
20
31
|
"AgentDefinition",
|
|
21
32
|
"CommandDefinition",
|
|
33
|
+
# Marketplace classes
|
|
34
|
+
"Marketplace",
|
|
35
|
+
"MarketplaceOwner",
|
|
36
|
+
"MarketplacePluginEntry",
|
|
37
|
+
"MarketplacePluginSource",
|
|
38
|
+
"MarketplaceMetadata",
|
|
22
39
|
]
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Plugin fetching utilities for remote plugin sources."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from openhands.sdk.git.cached_repo import GitHelper, try_cached_clone_or_update
|
|
9
|
+
from openhands.sdk.git.utils import extract_repo_name, is_git_url, normalize_git_url
|
|
10
|
+
from openhands.sdk.logger import get_logger
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
logger = get_logger(__name__)
|
|
14
|
+
|
|
15
|
+
DEFAULT_CACHE_DIR = Path.home() / ".openhands" / "cache" / "plugins"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PluginFetchError(Exception):
|
|
19
|
+
"""Raised when fetching a plugin fails."""
|
|
20
|
+
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_plugin_source(source: str) -> tuple[str, str]:
|
|
25
|
+
"""Parse plugin source into (type, url).
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
source: Plugin source string. Can be:
|
|
29
|
+
- "github:owner/repo" - GitHub repository shorthand
|
|
30
|
+
- "https://github.com/owner/repo.git" - Full git URL
|
|
31
|
+
- "git@github.com:owner/repo.git" - SSH git URL
|
|
32
|
+
- "/local/path" - Local path
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Tuple of (source_type, normalized_url) where source_type is one of:
|
|
36
|
+
- "github": GitHub repository
|
|
37
|
+
- "git": Any git URL
|
|
38
|
+
- "local": Local filesystem path
|
|
39
|
+
|
|
40
|
+
Examples:
|
|
41
|
+
>>> parse_plugin_source("github:owner/repo")
|
|
42
|
+
("github", "https://github.com/owner/repo.git")
|
|
43
|
+
>>> parse_plugin_source("https://gitlab.com/org/repo.git")
|
|
44
|
+
("git", "https://gitlab.com/org/repo.git")
|
|
45
|
+
>>> parse_plugin_source("/local/path")
|
|
46
|
+
("local", "/local/path")
|
|
47
|
+
"""
|
|
48
|
+
source = source.strip()
|
|
49
|
+
|
|
50
|
+
# GitHub shorthand: github:owner/repo
|
|
51
|
+
if source.startswith("github:"):
|
|
52
|
+
repo_path = source[7:] # Remove "github:" prefix
|
|
53
|
+
# Validate format
|
|
54
|
+
if "/" not in repo_path or repo_path.count("/") > 1:
|
|
55
|
+
raise PluginFetchError(
|
|
56
|
+
f"Invalid GitHub shorthand format: {source}. "
|
|
57
|
+
f"Expected format: github:owner/repo"
|
|
58
|
+
)
|
|
59
|
+
url = f"https://github.com/{repo_path}.git"
|
|
60
|
+
return ("github", url)
|
|
61
|
+
|
|
62
|
+
# Git URLs: detect by protocol/scheme rather than enumerating providers
|
|
63
|
+
# This handles GitHub, GitLab, Bitbucket, Codeberg, self-hosted instances, etc.
|
|
64
|
+
if is_git_url(source):
|
|
65
|
+
url = normalize_git_url(source)
|
|
66
|
+
return ("git", url)
|
|
67
|
+
|
|
68
|
+
# Local path: starts with /, ~, . or contains / without a URL scheme
|
|
69
|
+
if source.startswith(("/", "~", ".")):
|
|
70
|
+
return ("local", source)
|
|
71
|
+
|
|
72
|
+
if "/" in source and "://" not in source:
|
|
73
|
+
# Relative path like "plugins/my-plugin"
|
|
74
|
+
return ("local", source)
|
|
75
|
+
|
|
76
|
+
raise PluginFetchError(
|
|
77
|
+
f"Unable to parse plugin source: {source}. "
|
|
78
|
+
f"Expected formats: 'github:owner/repo', git URL, or local path"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_cache_path(source: str, cache_dir: Path | None = None) -> Path:
|
|
83
|
+
"""Get the cache path for a plugin source.
|
|
84
|
+
|
|
85
|
+
Creates a deterministic path based on a hash of the source URL.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
source: The plugin source (URL or path).
|
|
89
|
+
cache_dir: Base cache directory. Defaults to ~/.openhands/cache/plugins/
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Path where the plugin should be cached.
|
|
93
|
+
"""
|
|
94
|
+
if cache_dir is None:
|
|
95
|
+
cache_dir = DEFAULT_CACHE_DIR
|
|
96
|
+
|
|
97
|
+
# Create a hash of the source for the directory name
|
|
98
|
+
source_hash = hashlib.sha256(source.encode()).hexdigest()[:16]
|
|
99
|
+
|
|
100
|
+
# Extract repo name for human-readable cache directory name
|
|
101
|
+
readable_name = extract_repo_name(source)
|
|
102
|
+
|
|
103
|
+
cache_name = f"{readable_name}-{source_hash}"
|
|
104
|
+
return cache_dir / cache_name
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _resolve_local_source(url: str, subpath: str | None) -> Path:
|
|
108
|
+
"""Resolve a local plugin source to a path.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
url: Local path string (may contain ~ for home directory).
|
|
112
|
+
subpath: Optional subdirectory within the local path.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Resolved absolute path to the plugin directory.
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
PluginFetchError: If path doesn't exist or subpath is invalid.
|
|
119
|
+
"""
|
|
120
|
+
local_path = Path(url).expanduser().resolve()
|
|
121
|
+
if not local_path.exists():
|
|
122
|
+
raise PluginFetchError(f"Local plugin path does not exist: {local_path}")
|
|
123
|
+
return _apply_subpath(local_path, subpath, "local plugin path")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _fetch_remote_source(
|
|
127
|
+
url: str,
|
|
128
|
+
cache_dir: Path,
|
|
129
|
+
ref: str | None,
|
|
130
|
+
update: bool,
|
|
131
|
+
subpath: str | None,
|
|
132
|
+
git_helper: GitHelper | None,
|
|
133
|
+
source: str,
|
|
134
|
+
) -> Path:
|
|
135
|
+
"""Fetch a remote plugin source and cache it locally.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
url: Git URL to fetch.
|
|
139
|
+
cache_dir: Base directory for caching.
|
|
140
|
+
ref: Optional branch, tag, or commit to checkout.
|
|
141
|
+
update: Whether to update existing cache.
|
|
142
|
+
subpath: Optional subdirectory within the repository.
|
|
143
|
+
git_helper: GitHelper instance for git operations.
|
|
144
|
+
source: Original source string (for error messages).
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Path to the cached plugin directory.
|
|
148
|
+
|
|
149
|
+
Raises:
|
|
150
|
+
PluginFetchError: If fetching fails or subpath is invalid.
|
|
151
|
+
"""
|
|
152
|
+
plugin_path = get_cache_path(url, cache_dir)
|
|
153
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
|
|
155
|
+
result = try_cached_clone_or_update(
|
|
156
|
+
url=url,
|
|
157
|
+
repo_path=plugin_path,
|
|
158
|
+
ref=ref,
|
|
159
|
+
update=update,
|
|
160
|
+
git_helper=git_helper,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if result is None:
|
|
164
|
+
raise PluginFetchError(f"Failed to fetch plugin from {source}")
|
|
165
|
+
|
|
166
|
+
return _apply_subpath(plugin_path, subpath, "plugin repository")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _apply_subpath(base_path: Path, subpath: str | None, context: str) -> Path:
|
|
170
|
+
"""Apply a subpath to a base path, validating it exists.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
base_path: The root path.
|
|
174
|
+
subpath: Optional subdirectory path (may have leading/trailing slashes).
|
|
175
|
+
context: Description for error messages (e.g., "plugin repository").
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
The final path (base_path if no subpath, otherwise base_path/subpath).
|
|
179
|
+
|
|
180
|
+
Raises:
|
|
181
|
+
PluginFetchError: If subpath doesn't exist.
|
|
182
|
+
"""
|
|
183
|
+
if not subpath:
|
|
184
|
+
return base_path
|
|
185
|
+
|
|
186
|
+
final_path = base_path / subpath.strip("/")
|
|
187
|
+
if not final_path.exists():
|
|
188
|
+
raise PluginFetchError(f"Subdirectory '{subpath}' not found in {context}")
|
|
189
|
+
return final_path
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def fetch_plugin(
|
|
193
|
+
source: str,
|
|
194
|
+
cache_dir: Path | None = None,
|
|
195
|
+
ref: str | None = None,
|
|
196
|
+
update: bool = True,
|
|
197
|
+
subpath: str | None = None,
|
|
198
|
+
git_helper: GitHelper | None = None,
|
|
199
|
+
) -> Path:
|
|
200
|
+
"""Fetch a plugin from a remote source and return the local cached path.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
source: Plugin source - can be:
|
|
204
|
+
- "github:owner/repo" - GitHub repository shorthand
|
|
205
|
+
- "https://github.com/owner/repo.git" - Full git URL
|
|
206
|
+
- "/local/path" - Local path (returned as-is)
|
|
207
|
+
cache_dir: Directory for caching. Defaults to ~/.openhands/cache/plugins/
|
|
208
|
+
ref: Optional branch, tag, or commit to checkout.
|
|
209
|
+
update: If True and cache exists, update it. If False, use cached version as-is.
|
|
210
|
+
subpath: Optional subdirectory path within the repo. If specified, the returned
|
|
211
|
+
path will point to this subdirectory instead of the repository root.
|
|
212
|
+
git_helper: GitHelper instance (for testing). Defaults to global instance.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
Path to the local plugin directory (ready for Plugin.load()).
|
|
216
|
+
If subpath is specified, returns the path to that subdirectory.
|
|
217
|
+
|
|
218
|
+
Raises:
|
|
219
|
+
PluginFetchError: If fetching fails or subpath doesn't exist.
|
|
220
|
+
"""
|
|
221
|
+
source_type, url = parse_plugin_source(source)
|
|
222
|
+
|
|
223
|
+
if source_type == "local":
|
|
224
|
+
return _resolve_local_source(url, subpath)
|
|
225
|
+
|
|
226
|
+
if cache_dir is None:
|
|
227
|
+
cache_dir = DEFAULT_CACHE_DIR
|
|
228
|
+
|
|
229
|
+
return _fetch_remote_source(
|
|
230
|
+
url, cache_dir, ref, update, subpath, git_helper, source
|
|
231
|
+
)
|
openhands/sdk/plugin/plugin.py
CHANGED
|
@@ -16,6 +16,7 @@ from openhands.sdk.context.skills.utils import (
|
|
|
16
16
|
)
|
|
17
17
|
from openhands.sdk.hooks import HookConfig
|
|
18
18
|
from openhands.sdk.logger import get_logger
|
|
19
|
+
from openhands.sdk.plugin.fetch import fetch_plugin
|
|
19
20
|
from openhands.sdk.plugin.types import (
|
|
20
21
|
AgentDefinition,
|
|
21
22
|
CommandDefinition,
|
|
@@ -83,6 +84,59 @@ class Plugin(BaseModel):
|
|
|
83
84
|
"""Get the plugin description."""
|
|
84
85
|
return self.manifest.description
|
|
85
86
|
|
|
87
|
+
@classmethod
|
|
88
|
+
def fetch(
|
|
89
|
+
cls,
|
|
90
|
+
source: str,
|
|
91
|
+
cache_dir: Path | None = None,
|
|
92
|
+
ref: str | None = None,
|
|
93
|
+
update: bool = True,
|
|
94
|
+
subpath: str | None = None,
|
|
95
|
+
) -> Path:
|
|
96
|
+
"""Fetch a plugin from a remote source and return the local cached path.
|
|
97
|
+
|
|
98
|
+
This method fetches plugins from remote sources (GitHub repositories, git URLs)
|
|
99
|
+
and caches them locally. Use the returned path with Plugin.load() to load
|
|
100
|
+
the plugin.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
source: Plugin source - can be:
|
|
104
|
+
- "github:owner/repo" - GitHub repository shorthand
|
|
105
|
+
- "https://github.com/owner/repo.git" - Full git URL
|
|
106
|
+
- "/local/path" - Local path (returned as-is)
|
|
107
|
+
cache_dir: Directory for caching. Defaults to ~/.openhands/cache/plugins/
|
|
108
|
+
ref: Optional branch, tag, or commit to checkout.
|
|
109
|
+
update: If True and cache exists, update it. If False, use cached as-is.
|
|
110
|
+
subpath: Optional subdirectory path within the repo. If specified, the
|
|
111
|
+
returned path will point to this subdirectory instead of the
|
|
112
|
+
repository root.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Path to the local plugin directory (ready for Plugin.load()).
|
|
116
|
+
If subpath is specified, returns the path to that subdirectory.
|
|
117
|
+
|
|
118
|
+
Raises:
|
|
119
|
+
PluginFetchError: If fetching fails or subpath doesn't exist.
|
|
120
|
+
|
|
121
|
+
Example:
|
|
122
|
+
>>> path = Plugin.fetch("github:owner/my-plugin")
|
|
123
|
+
>>> plugin = Plugin.load(path)
|
|
124
|
+
|
|
125
|
+
>>> # With specific version
|
|
126
|
+
>>> path = Plugin.fetch("github:owner/my-plugin", ref="v1.0.0")
|
|
127
|
+
>>> plugin = Plugin.load(path)
|
|
128
|
+
|
|
129
|
+
>>> # Fetch a plugin from a subdirectory
|
|
130
|
+
>>> path = Plugin.fetch("github:owner/monorepo", subpath="plugins/sub")
|
|
131
|
+
>>> plugin = Plugin.load(path)
|
|
132
|
+
|
|
133
|
+
>>> # Fetch and load in one step
|
|
134
|
+
>>> plugin = Plugin.load(Plugin.fetch("github:owner/my-plugin"))
|
|
135
|
+
"""
|
|
136
|
+
return fetch_plugin(
|
|
137
|
+
source, cache_dir=cache_dir, ref=ref, update=update, subpath=subpath
|
|
138
|
+
)
|
|
139
|
+
|
|
86
140
|
@classmethod
|
|
87
141
|
def load(cls, plugin_path: str | Path) -> Plugin:
|
|
88
142
|
"""Load a plugin from a directory.
|
|
@@ -239,10 +293,13 @@ def _load_hooks(plugin_dir: Path) -> HookConfig | None:
|
|
|
239
293
|
|
|
240
294
|
try:
|
|
241
295
|
hook_config = HookConfig.load(path=hooks_json)
|
|
242
|
-
#
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
296
|
+
# If hooks.json exists but is invalid, HookConfig.load() returns an empty
|
|
297
|
+
# config and logs the validation error. Keep that distinct from "file not
|
|
298
|
+
# present" (None).
|
|
299
|
+
if hook_config.is_empty():
|
|
300
|
+
logger.info(f"No hooks configured in {hooks_json}")
|
|
301
|
+
return HookConfig()
|
|
302
|
+
return hook_config
|
|
246
303
|
except Exception as e:
|
|
247
304
|
logger.warning(f"Failed to load hooks from {hooks_json}: {e}")
|
|
248
305
|
return None
|