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
@@ -0,0 +1,459 @@
1
+ """Git operations for cloning and caching remote repositories.
2
+
3
+ This module provides utilities for cloning git repositories to a local cache
4
+ and keeping them updated. Used by both the skills system and plugin fetching.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import shutil
10
+ from pathlib import Path
11
+
12
+ from filelock import FileLock, Timeout
13
+
14
+ from openhands.sdk.git.exceptions import GitCommandError
15
+ from openhands.sdk.git.utils import run_git_command
16
+ from openhands.sdk.logger import get_logger
17
+
18
+
19
+ logger = get_logger(__name__)
20
+
21
+ # Default timeout for acquiring cache locks (seconds)
22
+ # Consistent with other lock timeouts in the SDK (io/local.py, event_store.py)
23
+ DEFAULT_LOCK_TIMEOUT = 30
24
+
25
+
26
+ class GitHelper:
27
+ """Abstraction for git operations, enabling easy mocking in tests.
28
+
29
+ This class wraps git commands for cloning, fetching, and managing
30
+ cached repositories. All methods raise GitCommandError on failure.
31
+ """
32
+
33
+ def clone(
34
+ self,
35
+ url: str,
36
+ dest: Path,
37
+ depth: int | None = 1,
38
+ branch: str | None = None,
39
+ timeout: int = 120,
40
+ ) -> None:
41
+ """Clone a git repository.
42
+
43
+ Args:
44
+ url: Git URL to clone.
45
+ dest: Destination path.
46
+ depth: Clone depth (None for full clone, 1 for shallow). Note that
47
+ shallow clones only fetch the tip of the specified branch. If you
48
+ later need to checkout a specific commit that isn't the branch tip,
49
+ the checkout may fail. Use depth=None for full clones if you need
50
+ to checkout arbitrary commits.
51
+ branch: Branch/tag to checkout during clone.
52
+ timeout: Timeout in seconds.
53
+
54
+ Raises:
55
+ GitCommandError: If clone fails.
56
+ """
57
+ cmd = ["git", "clone"]
58
+
59
+ if depth is not None:
60
+ cmd.extend(["--depth", str(depth)])
61
+
62
+ if branch:
63
+ cmd.extend(["--branch", branch])
64
+
65
+ cmd.extend([url, str(dest)])
66
+
67
+ run_git_command(cmd, timeout=timeout)
68
+
69
+ def fetch(
70
+ self,
71
+ repo_path: Path,
72
+ remote: str = "origin",
73
+ ref: str | None = None,
74
+ timeout: int = 60,
75
+ ) -> None:
76
+ """Fetch from remote.
77
+
78
+ Args:
79
+ repo_path: Path to the repository.
80
+ remote: Remote name.
81
+ ref: Specific ref to fetch (optional).
82
+ timeout: Timeout in seconds.
83
+
84
+ Raises:
85
+ GitCommandError: If fetch fails.
86
+ """
87
+ cmd = ["git", "fetch", remote]
88
+ if ref:
89
+ cmd.append(ref)
90
+
91
+ run_git_command(cmd, cwd=repo_path, timeout=timeout)
92
+
93
+ def checkout(self, repo_path: Path, ref: str, timeout: int = 30) -> None:
94
+ """Checkout a ref (branch, tag, or commit).
95
+
96
+ Args:
97
+ repo_path: Path to the repository.
98
+ ref: Branch, tag, or commit to checkout.
99
+ timeout: Timeout in seconds.
100
+
101
+ Raises:
102
+ GitCommandError: If checkout fails.
103
+ """
104
+ run_git_command(["git", "checkout", ref], cwd=repo_path, timeout=timeout)
105
+
106
+ def reset_hard(self, repo_path: Path, ref: str, timeout: int = 30) -> None:
107
+ """Hard reset to a ref.
108
+
109
+ Args:
110
+ repo_path: Path to the repository.
111
+ ref: Ref to reset to (e.g., "origin/main").
112
+ timeout: Timeout in seconds.
113
+
114
+ Raises:
115
+ GitCommandError: If reset fails.
116
+ """
117
+ run_git_command(["git", "reset", "--hard", ref], cwd=repo_path, timeout=timeout)
118
+
119
+ def get_current_branch(self, repo_path: Path, timeout: int = 10) -> str | None:
120
+ """Get the current branch name.
121
+
122
+ Args:
123
+ repo_path: Path to the repository.
124
+ timeout: Timeout in seconds.
125
+
126
+ Returns:
127
+ Branch name, or None if in detached HEAD state.
128
+
129
+ Raises:
130
+ GitCommandError: If command fails.
131
+ """
132
+ branch = run_git_command(
133
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
134
+ cwd=repo_path,
135
+ timeout=timeout,
136
+ )
137
+ # "HEAD" means detached HEAD state
138
+ return None if branch == "HEAD" else branch
139
+
140
+ def get_default_branch(self, repo_path: Path, timeout: int = 10) -> str | None:
141
+ """Get the default branch name from the remote.
142
+
143
+ Queries origin/HEAD to determine the remote's default branch. This is set
144
+ during clone and points to the branch that would be checked out by default.
145
+
146
+ Args:
147
+ repo_path: Path to the repository.
148
+ timeout: Timeout in seconds.
149
+
150
+ Returns:
151
+ Default branch name (e.g., "main" or "master"), or None if it cannot
152
+ be determined (e.g., origin/HEAD is not set).
153
+
154
+ Raises:
155
+ GitCommandError: If the git command itself fails (not if ref is missing).
156
+ """
157
+ try:
158
+ # origin/HEAD is a symbolic ref pointing to the default branch
159
+ ref = run_git_command(
160
+ ["git", "symbolic-ref", "refs/remotes/origin/HEAD"],
161
+ cwd=repo_path,
162
+ timeout=timeout,
163
+ )
164
+ # Output is like "refs/remotes/origin/main" - extract branch name
165
+ prefix = "refs/remotes/origin/"
166
+ if ref.startswith(prefix):
167
+ return ref[len(prefix) :]
168
+ return None
169
+ except GitCommandError:
170
+ # origin/HEAD may not be set (e.g., bare clone, or never configured)
171
+ return None
172
+
173
+
174
+ def try_cached_clone_or_update(
175
+ url: str,
176
+ repo_path: Path,
177
+ ref: str | None = None,
178
+ update: bool = True,
179
+ git_helper: GitHelper | None = None,
180
+ lock_timeout: float = DEFAULT_LOCK_TIMEOUT,
181
+ ) -> Path | None:
182
+ """Clone or update a git repository in a cache directory.
183
+
184
+ This is the main entry point for cached repository operations.
185
+
186
+ Behavior:
187
+ - If repo doesn't exist: clone (shallow, --depth 1) with optional ref
188
+ - If repo exists and update=True: fetch, checkout+reset to ref
189
+ - If repo exists and update=False with ref: checkout ref without fetching
190
+ - If repo exists and update=False without ref: use as-is
191
+
192
+ The update sequence is: fetch origin -> checkout ref -> reset --hard origin/ref.
193
+ This ensures local changes are discarded and the cache matches the remote.
194
+
195
+ Concurrency:
196
+ Uses file-based locking to prevent race conditions when multiple processes
197
+ access the same cache directory. The lock file is created adjacent to the
198
+ repo directory (repo_path.lock).
199
+
200
+ Args:
201
+ url: Git URL to clone.
202
+ repo_path: Path where the repository should be cached.
203
+ ref: Branch, tag, or commit to checkout. If None, uses default branch.
204
+ update: If True and repo exists, fetch and update it. If False, skip fetch.
205
+ git_helper: GitHelper instance for git operations. If None, creates one.
206
+ lock_timeout: Timeout in seconds for acquiring the lock. Default is 5 minutes.
207
+
208
+ Returns:
209
+ Path to the local repository if successful, None on failure.
210
+ Returns None (not raises) on git errors to allow graceful degradation.
211
+ """
212
+ git = git_helper if git_helper is not None else GitHelper()
213
+
214
+ # Ensure parent directory exists for both the repo and lock file
215
+ repo_path.parent.mkdir(parents=True, exist_ok=True)
216
+
217
+ # Use a lock file adjacent to the repo directory
218
+ lock_path = repo_path.with_suffix(".lock")
219
+ lock = FileLock(lock_path)
220
+
221
+ try:
222
+ with lock.acquire(timeout=lock_timeout):
223
+ return _do_clone_or_update(url, repo_path, ref, update, git)
224
+ except Timeout:
225
+ logger.warning(
226
+ f"Timed out waiting for lock on {repo_path} after {lock_timeout}s"
227
+ )
228
+ return None
229
+ except GitCommandError as e:
230
+ logger.warning(f"Git operation failed: {e}")
231
+ return None
232
+ except Exception as e:
233
+ logger.warning(f"Error managing repository: {str(e)}")
234
+ return None
235
+
236
+
237
+ def _do_clone_or_update(
238
+ url: str,
239
+ repo_path: Path,
240
+ ref: str | None,
241
+ update: bool,
242
+ git: GitHelper,
243
+ ) -> Path:
244
+ """Perform the actual clone or update operation (called while holding lock).
245
+
246
+ Args:
247
+ url: Git URL to clone.
248
+ repo_path: Path where the repository should be cached.
249
+ ref: Branch, tag, or commit to checkout.
250
+ update: Whether to update existing repos.
251
+ git: GitHelper instance.
252
+
253
+ Returns:
254
+ Path to the repository.
255
+
256
+ Raises:
257
+ GitCommandError: If git operations fail.
258
+ """
259
+ if repo_path.exists() and (repo_path / ".git").exists():
260
+ if update:
261
+ logger.debug(f"Updating repository at {repo_path}")
262
+ _update_repository(repo_path, ref, git)
263
+ elif ref:
264
+ logger.debug(f"Checking out ref {ref} at {repo_path}")
265
+ _checkout_ref(repo_path, ref, git)
266
+ else:
267
+ logger.debug(f"Using cached repository at {repo_path}")
268
+ else:
269
+ logger.info(f"Cloning repository from {url}")
270
+ _clone_repository(url, repo_path, ref, git)
271
+
272
+ return repo_path
273
+
274
+
275
+ def _clone_repository(
276
+ url: str,
277
+ dest: Path,
278
+ branch: str | None,
279
+ git: GitHelper,
280
+ ) -> None:
281
+ """Clone a git repository.
282
+
283
+ Args:
284
+ url: Git URL to clone.
285
+ dest: Destination path.
286
+ branch: Branch to checkout (optional).
287
+ git: GitHelper instance.
288
+ """
289
+ # Remove existing directory if it exists but isn't a valid git repo
290
+ if dest.exists():
291
+ shutil.rmtree(dest)
292
+
293
+ git.clone(url, dest, depth=1, branch=branch)
294
+ logger.debug(f"Repository cloned to {dest}")
295
+
296
+
297
+ def _update_repository(
298
+ repo_path: Path,
299
+ ref: str | None,
300
+ git: GitHelper,
301
+ ) -> None:
302
+ """Update an existing cached repository to the latest remote state.
303
+
304
+ Fetches from origin and resets to match the remote. On any failure, logs a
305
+ warning and returns silently—the cached repository remains usable (just
306
+ potentially stale).
307
+
308
+ Behavior by scenario:
309
+ 1. ref is specified: Checkout and reset to that ref (branch/tag/commit)
310
+ 2. ref is None, on a branch: Reset to origin/{current_branch}
311
+ 3. ref is None, detached HEAD: Checkout the remote's default branch
312
+ (determined via origin/HEAD), then reset to origin/{default_branch}.
313
+ This handles the case where a previous fetch with a specific ref
314
+ (e.g., a tag) left the repo in detached HEAD state.
315
+
316
+ The detached HEAD recovery ensures that calling fetch(source, update=True)
317
+ without a ref always updates to "the latest", even if a previous call used
318
+ a specific tag or commit. Without this, the repo would be stuck on the old
319
+ ref with no way to get back to the default branch.
320
+
321
+ Args:
322
+ repo_path: Path to the repository.
323
+ ref: Branch, tag, or commit to update to. If None, uses current branch
324
+ or falls back to the remote's default branch.
325
+ git: GitHelper instance.
326
+ """
327
+ # Fetch from origin - if this fails, we still have a usable (stale) cache
328
+ if not _try_fetch(repo_path, git):
329
+ return
330
+
331
+ # If a specific ref was requested, check it out
332
+ if ref:
333
+ _try_checkout_and_reset(repo_path, ref, git)
334
+ return
335
+
336
+ # No ref specified - update based on current state
337
+ current_branch = git.get_current_branch(repo_path)
338
+
339
+ if current_branch:
340
+ # On a branch: reset to track origin
341
+ _try_reset_to_origin(repo_path, current_branch, git)
342
+ return
343
+
344
+ # Detached HEAD: recover by checking out the default branch
345
+ _recover_from_detached_head(repo_path, git)
346
+
347
+
348
+ def _try_fetch(repo_path: Path, git: GitHelper) -> bool:
349
+ """Attempt to fetch from origin. Returns True on success, False on failure."""
350
+ try:
351
+ git.fetch(repo_path)
352
+ return True
353
+ except GitCommandError as e:
354
+ logger.warning(f"Failed to fetch updates: {e}. Using cached version.")
355
+ return False
356
+
357
+
358
+ def _try_checkout_and_reset(repo_path: Path, ref: str, git: GitHelper) -> None:
359
+ """Attempt to checkout and reset to a specific ref. Logs warning on failure."""
360
+ try:
361
+ _checkout_ref(repo_path, ref, git)
362
+ logger.debug(f"Repository updated to {ref}")
363
+ except GitCommandError as e:
364
+ logger.warning(f"Failed to checkout {ref}: {e}. Using cached version.")
365
+
366
+
367
+ def _try_reset_to_origin(repo_path: Path, branch: str, git: GitHelper) -> None:
368
+ """Attempt to reset to origin/{branch}. Logs warning on failure."""
369
+ try:
370
+ git.reset_hard(repo_path, f"origin/{branch}")
371
+ logger.debug("Repository updated successfully")
372
+ except GitCommandError as e:
373
+ logger.warning(
374
+ f"Failed to reset to origin/{branch}: {e}. Using cached version."
375
+ )
376
+
377
+
378
+ def _recover_from_detached_head(repo_path: Path, git: GitHelper) -> None:
379
+ """Recover from detached HEAD state by checking out the default branch.
380
+
381
+ This handles the scenario where:
382
+ 1. User previously fetched with ref="v1.0.0" (a tag) -> repo is in detached HEAD
383
+ 2. User now fetches with update=True but no ref -> expects "latest"
384
+
385
+ Without this recovery, the repo would stay stuck on the old tag. By checking
386
+ out the default branch, we ensure update=True without a ref means "latest
387
+ from the default branch".
388
+ """
389
+ default_branch = git.get_default_branch(repo_path)
390
+
391
+ if not default_branch:
392
+ logger.warning(
393
+ "Repository is in detached HEAD state and default branch could not be "
394
+ "determined. Specify a ref explicitly to update, or the cached version "
395
+ "will be used as-is."
396
+ )
397
+ return
398
+
399
+ logger.debug(
400
+ f"Repository in detached HEAD state, "
401
+ f"checking out default branch: {default_branch}"
402
+ )
403
+
404
+ try:
405
+ git.checkout(repo_path, default_branch)
406
+ git.reset_hard(repo_path, f"origin/{default_branch}")
407
+ logger.debug(f"Repository updated to default branch: {default_branch}")
408
+ except GitCommandError as e:
409
+ logger.warning(
410
+ f"Failed to checkout default branch {default_branch}: {e}. "
411
+ "Using cached version."
412
+ )
413
+
414
+
415
+ def _checkout_ref(repo_path: Path, ref: str, git: GitHelper) -> None:
416
+ """Checkout a specific ref (branch, tag, or commit).
417
+
418
+ Handles each ref type with appropriate semantics:
419
+
420
+ - **Branches**: Checks out the branch and resets to ``origin/{branch}`` to
421
+ ensure the local branch matches the remote state.
422
+
423
+ - **Tags**: Checks out in detached HEAD state. Tags are immutable, so no
424
+ reset is performed.
425
+
426
+ - **Commits**: Checks out in detached HEAD state. For shallow clones, the
427
+ commit must be reachable from fetched history.
428
+
429
+ Args:
430
+ repo_path: Path to the repository.
431
+ ref: Branch name, tag name, or commit SHA to checkout.
432
+ git: GitHelper instance.
433
+
434
+ Raises:
435
+ GitCommandError: If checkout fails (ref doesn't exist or isn't reachable).
436
+ """
437
+ logger.debug(f"Checking out ref: {ref}")
438
+
439
+ # Checkout is the critical operation - let it raise if it fails
440
+ git.checkout(repo_path, ref)
441
+
442
+ # Determine what we checked out by examining HEAD state
443
+ current_branch = git.get_current_branch(repo_path)
444
+
445
+ if current_branch is None:
446
+ # Detached HEAD means we checked out a tag or commit - nothing more to do
447
+ logger.debug(f"Checked out {ref} (detached HEAD - tag or commit)")
448
+ return
449
+
450
+ # We're on a branch - reset to sync with origin
451
+ try:
452
+ git.reset_hard(repo_path, f"origin/{current_branch}")
453
+ logger.debug(f"Branch {current_branch} reset to origin/{current_branch}")
454
+ except GitCommandError:
455
+ # Branch may not exist on origin (e.g., local-only branch)
456
+ logger.debug(
457
+ f"Could not reset to origin/{current_branch} "
458
+ f"(branch may not exist on remote)"
459
+ )
@@ -1,4 +1,5 @@
1
1
  import logging
2
+ import re
2
3
  import shlex
3
4
  import subprocess
4
5
  from pathlib import Path
@@ -13,12 +14,17 @@ logger = logging.getLogger(__name__)
13
14
  GIT_EMPTY_TREE_HASH = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
14
15
 
15
16
 
16
- def run_git_command(args: list[str], cwd: str | Path) -> str:
17
+ def run_git_command(
18
+ args: list[str],
19
+ cwd: str | Path | None = None,
20
+ timeout: int = 30,
21
+ ) -> str:
17
22
  """Run a git command safely without shell injection vulnerabilities.
18
23
 
19
24
  Args:
20
25
  args: List of command arguments (e.g., ['git', 'status', '--porcelain'])
21
- cwd: Working directory to run the command in
26
+ cwd: Working directory to run the command in (optional for commands like clone)
27
+ timeout: Timeout in seconds (default: 30)
22
28
 
23
29
  Returns:
24
30
  Command output as string
@@ -33,7 +39,7 @@ def run_git_command(args: list[str], cwd: str | Path) -> str:
33
39
  capture_output=True,
34
40
  text=True,
35
41
  check=False,
36
- timeout=30, # Prevent hanging commands
42
+ timeout=timeout,
37
43
  )
38
44
 
39
45
  if result.returncode != 0:
@@ -212,3 +218,112 @@ def validate_git_repository(repo_dir: str | Path) -> Path:
212
218
  raise GitRepositoryError(f"Not a git repository: {repo_path}") from e
213
219
 
214
220
  return repo_path
221
+
222
+
223
+ # ============================================================================
224
+ # Git URL utilities
225
+ # ============================================================================
226
+
227
+
228
+ def is_git_url(source: str) -> bool:
229
+ """Check if a source string looks like a git URL.
230
+
231
+ Detects git URLs by their protocol/scheme rather than enumerating providers.
232
+ This handles any git hosting service (GitHub, GitLab, Codeberg, self-hosted, etc.)
233
+
234
+ Args:
235
+ source: String to check.
236
+
237
+ Returns:
238
+ True if the string appears to be a git URL, False otherwise.
239
+
240
+ Examples:
241
+ >>> is_git_url("https://github.com/owner/repo.git")
242
+ True
243
+ >>> is_git_url("git@github.com:owner/repo.git")
244
+ True
245
+ >>> is_git_url("/local/path")
246
+ False
247
+ """
248
+ # HTTPS/HTTP URLs to git repositories
249
+ if source.startswith(("https://", "http://")):
250
+ return True
251
+
252
+ # SSH format: git@host:path or user@host:path
253
+ if re.match(r"^[\w.-]+@[\w.-]+:", source):
254
+ return True
255
+
256
+ # Git protocol
257
+ if source.startswith("git://"):
258
+ return True
259
+
260
+ # File protocol (for testing)
261
+ if source.startswith("file://"):
262
+ return True
263
+
264
+ return False
265
+
266
+
267
+ def normalize_git_url(url: str) -> str:
268
+ """Normalize a git URL by ensuring .git suffix for HTTPS URLs.
269
+
270
+ Args:
271
+ url: Git URL to normalize.
272
+
273
+ Returns:
274
+ Normalized URL with .git suffix for HTTPS/HTTP URLs.
275
+
276
+ Examples:
277
+ >>> normalize_git_url("https://github.com/owner/repo")
278
+ "https://github.com/owner/repo.git"
279
+ >>> normalize_git_url("https://github.com/owner/repo.git")
280
+ "https://github.com/owner/repo.git"
281
+ >>> normalize_git_url("git@github.com:owner/repo.git")
282
+ "git@github.com:owner/repo.git"
283
+ """
284
+ if url.startswith(("https://", "http://")) and not url.endswith(".git"):
285
+ url = url.rstrip("/")
286
+ url = f"{url}.git"
287
+ return url
288
+
289
+
290
+ def extract_repo_name(source: str) -> str:
291
+ """Extract a human-readable repository name from a git URL or path.
292
+
293
+ Extracts the last path component (repo name) and sanitizes it for use
294
+ in directory names or display purposes.
295
+
296
+ Args:
297
+ source: Git URL or local path string.
298
+
299
+ Returns:
300
+ A sanitized name suitable for use in directory names (max 32 chars).
301
+
302
+ Examples:
303
+ >>> extract_repo_name("https://github.com/owner/my-repo.git")
304
+ "my-repo"
305
+ >>> extract_repo_name("git@github.com:owner/my-repo.git")
306
+ "my-repo"
307
+ >>> extract_repo_name("/path/to/local-repo")
308
+ "local-repo"
309
+ """
310
+ # Strip common prefixes to get to the path portion
311
+ name = source
312
+ for prefix in ("github:", "https://", "http://", "git://", "file://"):
313
+ if name.startswith(prefix):
314
+ name = name[len(prefix) :]
315
+ break
316
+
317
+ # Handle SSH format: user@host:path -> path
318
+ if "@" in name and ":" in name and "/" not in name.split(":")[0]:
319
+ name = name.split(":", 1)[1]
320
+
321
+ # Remove .git suffix and get last path component
322
+ name = name.rstrip("/").removesuffix(".git")
323
+ name = name.rsplit("/", 1)[-1]
324
+
325
+ # Sanitize: keep alphanumeric, dash, underscore only
326
+ name = re.sub(r"[^a-zA-Z0-9_-]", "-", name)
327
+ name = re.sub(r"-+", "-", name).strip("-")
328
+
329
+ return name[:32] if name else "repo"
@@ -5,7 +5,12 @@ Hooks are event-driven scripts that execute at specific lifecycle events
5
5
  during agent execution, enabling deterministic control over agent behavior.
6
6
  """
7
7
 
8
- from openhands.sdk.hooks.config import HookConfig, HookDefinition, HookMatcher
8
+ from openhands.sdk.hooks.config import (
9
+ HookConfig,
10
+ HookDefinition,
11
+ HookMatcher,
12
+ HookType,
13
+ )
9
14
  from openhands.sdk.hooks.conversation_hooks import (
10
15
  HookEventProcessor,
11
16
  create_hook_callback,
@@ -19,6 +24,7 @@ __all__ = [
19
24
  "HookConfig",
20
25
  "HookDefinition",
21
26
  "HookMatcher",
27
+ "HookType",
22
28
  "HookExecutor",
23
29
  "HookResult",
24
30
  "HookManager",