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.
Files changed (32) hide show
  1. openhands/sdk/agent/agent.py +64 -0
  2. openhands/sdk/agent/base.py +22 -10
  3. openhands/sdk/context/skills/skill.py +59 -1
  4. openhands/sdk/context/skills/utils.py +6 -65
  5. openhands/sdk/conversation/base.py +5 -0
  6. openhands/sdk/conversation/impl/remote_conversation.py +16 -3
  7. openhands/sdk/conversation/visualizer/base.py +23 -0
  8. openhands/sdk/critic/__init__.py +4 -1
  9. openhands/sdk/critic/base.py +17 -20
  10. openhands/sdk/critic/impl/__init__.py +2 -0
  11. openhands/sdk/critic/impl/agent_finished.py +9 -5
  12. openhands/sdk/critic/impl/api/__init__.py +18 -0
  13. openhands/sdk/critic/impl/api/chat_template.py +232 -0
  14. openhands/sdk/critic/impl/api/client.py +313 -0
  15. openhands/sdk/critic/impl/api/critic.py +90 -0
  16. openhands/sdk/critic/impl/api/taxonomy.py +180 -0
  17. openhands/sdk/critic/result.py +148 -0
  18. openhands/sdk/event/llm_convertible/action.py +10 -0
  19. openhands/sdk/event/llm_convertible/message.py +10 -0
  20. openhands/sdk/git/cached_repo.py +459 -0
  21. openhands/sdk/git/utils.py +118 -3
  22. openhands/sdk/hooks/__init__.py +7 -1
  23. openhands/sdk/hooks/config.py +154 -45
  24. openhands/sdk/llm/utils/model_features.py +3 -0
  25. openhands/sdk/plugin/__init__.py +17 -0
  26. openhands/sdk/plugin/fetch.py +231 -0
  27. openhands/sdk/plugin/plugin.py +61 -4
  28. openhands/sdk/plugin/types.py +394 -1
  29. {openhands_sdk-1.8.2.dist-info → openhands_sdk-1.9.1.dist-info}/METADATA +5 -1
  30. {openhands_sdk-1.8.2.dist-info → openhands_sdk-1.9.1.dist-info}/RECORD +32 -24
  31. {openhands_sdk-1.8.2.dist-info → openhands_sdk-1.9.1.dist-info}/WHEEL +1 -1
  32. {openhands_sdk-1.8.2.dist-info → openhands_sdk-1.9.1.dist-info}/top_level.txt +0 -0
@@ -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, loaded from .openhands/hooks.json."""
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
- hooks: dict[str, list[HookMatcher]] = Field(default_factory=dict)
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
- try:
115
- with open(path) as f:
116
- data = json.load(f)
117
- return cls.from_dict(data)
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
- hooks_data = data.get("hooks", {})
127
- hooks: dict[str, list[HookMatcher]] = {}
128
-
129
- for event_type, matchers in hooks_data.items():
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
- return cls(hooks=hooks)
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
- event_key = event_type.value
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
- return event_type.value in self.hooks and len(self.hooks[event_type.value]) > 0
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.to_dict(), f, indent=2)
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
@@ -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
+ )
@@ -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
- # load() returns empty config on error, check if it has hooks
243
- if hook_config.hooks:
244
- return hook_config
245
- return None
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