merge: resolve conflict with main (keep fence markers + _find_shell)
This commit is contained in:
commit
f967471758
36 changed files with 5037 additions and 293 deletions
|
|
@ -20,6 +20,7 @@ Platform: Linux / macOS only (Unix domain sockets). Disabled on Windows.
|
|||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
|
|
@ -28,6 +29,8 @@ import tempfile
|
|||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
_IS_WINDOWS = platform.system() == "Windows"
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
# Availability gate: UDS requires a POSIX OS
|
||||
|
|
@ -405,7 +408,7 @@ def execute_code(
|
|||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
stdin=subprocess.DEVNULL,
|
||||
preexec_fn=os.setsid,
|
||||
preexec_fn=None if _IS_WINDOWS else os.setsid,
|
||||
)
|
||||
|
||||
# --- Poll loop: watch for exit, timeout, and interrupt ---
|
||||
|
|
@ -514,7 +517,10 @@ def execute_code(
|
|||
def _kill_process_group(proc, escalate: bool = False):
|
||||
"""Kill the child and its entire process group."""
|
||||
try:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||
if _IS_WINDOWS:
|
||||
proc.terminate()
|
||||
else:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
try:
|
||||
proc.kill()
|
||||
|
|
@ -527,7 +533,10 @@ def _kill_process_group(proc, escalate: bool = False):
|
|||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
try:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
||||
if _IS_WINDOWS:
|
||||
proc.kill()
|
||||
else:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
try:
|
||||
proc.kill()
|
||||
|
|
|
|||
|
|
@ -1,18 +1,58 @@
|
|||
"""Local execution environment with interrupt support and non-blocking I/O."""
|
||||
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
_IS_WINDOWS = platform.system() == "Windows"
|
||||
|
||||
from tools.environments.base import BaseEnvironment
|
||||
|
||||
# Unique marker to isolate real command output from shell init/exit noise.
|
||||
# printf (no trailing newline) keeps the boundaries clean for splitting.
|
||||
_OUTPUT_FENCE = "__HERMES_FENCE_a9f7b3__"
|
||||
|
||||
|
||||
def _find_shell() -> str:
|
||||
"""Find the best shell for command execution.
|
||||
|
||||
On Unix: uses $SHELL, falls back to bash.
|
||||
On Windows: uses Git Bash (bundled with Git for Windows).
|
||||
Raises RuntimeError if no suitable shell is found on Windows.
|
||||
"""
|
||||
if not _IS_WINDOWS:
|
||||
return os.environ.get("SHELL") or shutil.which("bash") or "/bin/bash"
|
||||
|
||||
# Windows: look for Git Bash (installed with Git for Windows).
|
||||
# Allow override via env var (same pattern as Claude Code).
|
||||
custom = os.environ.get("HERMES_GIT_BASH_PATH")
|
||||
if custom and os.path.isfile(custom):
|
||||
return custom
|
||||
|
||||
# shutil.which finds bash.exe if Git\bin is on PATH
|
||||
found = shutil.which("bash")
|
||||
if found:
|
||||
return found
|
||||
|
||||
# Check common Git for Windows install locations
|
||||
for candidate in (
|
||||
os.path.join(os.environ.get("ProgramFiles", r"C:\Program Files"), "Git", "bin", "bash.exe"),
|
||||
os.path.join(os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"), "Git", "bin", "bash.exe"),
|
||||
os.path.join(os.environ.get("LOCALAPPDATA", ""), "Programs", "Git", "bin", "bash.exe"),
|
||||
):
|
||||
if candidate and os.path.isfile(candidate):
|
||||
return candidate
|
||||
|
||||
raise RuntimeError(
|
||||
"Git Bash not found. Hermes Agent requires Git for Windows on Windows.\n"
|
||||
"Install it from: https://git-scm.com/download/win\n"
|
||||
"Or set HERMES_GIT_BASH_PATH to your bash.exe location."
|
||||
)
|
||||
|
||||
# Noise lines emitted by interactive shells when stdin is not a terminal.
|
||||
# Used as a fallback when output fence markers are missing.
|
||||
_SHELL_NOISE_SUBSTRINGS = (
|
||||
|
|
@ -119,7 +159,7 @@ class LocalEnvironment(BaseEnvironment):
|
|||
# tools like nvm, pyenv, and cargo install their init scripts.
|
||||
# -l alone isn't enough: .profile sources .bashrc, but the guard
|
||||
# returns early because the shell isn't interactive.
|
||||
user_shell = os.environ.get("SHELL") or shutil.which("bash") or "/bin/bash"
|
||||
user_shell = _find_shell()
|
||||
# Wrap with output fences so we can later extract the real
|
||||
# command output and discard shell init/exit noise.
|
||||
fenced_cmd = (
|
||||
|
|
@ -139,7 +179,7 @@ class LocalEnvironment(BaseEnvironment):
|
|||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
stdin=subprocess.PIPE if stdin_data is not None else subprocess.DEVNULL,
|
||||
preexec_fn=os.setsid,
|
||||
preexec_fn=None if _IS_WINDOWS else os.setsid,
|
||||
)
|
||||
|
||||
if stdin_data is not None:
|
||||
|
|
@ -172,12 +212,15 @@ class LocalEnvironment(BaseEnvironment):
|
|||
while proc.poll() is None:
|
||||
if _interrupt_event.is_set():
|
||||
try:
|
||||
pgid = os.getpgid(proc.pid)
|
||||
os.killpg(pgid, signal.SIGTERM)
|
||||
try:
|
||||
proc.wait(timeout=1.0)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(pgid, signal.SIGKILL)
|
||||
if _IS_WINDOWS:
|
||||
proc.terminate()
|
||||
else:
|
||||
pgid = os.getpgid(proc.pid)
|
||||
os.killpg(pgid, signal.SIGTERM)
|
||||
try:
|
||||
proc.wait(timeout=1.0)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(pgid, signal.SIGKILL)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
proc.kill()
|
||||
reader.join(timeout=2)
|
||||
|
|
@ -187,7 +230,10 @@ class LocalEnvironment(BaseEnvironment):
|
|||
}
|
||||
if time.monotonic() > deadline:
|
||||
try:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||
if _IS_WINDOWS:
|
||||
proc.terminate()
|
||||
else:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
proc.kill()
|
||||
reader.join(timeout=2)
|
||||
|
|
|
|||
1047
tools/mcp_tool.py
Normal file
1047
tools/mcp_tool.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -32,6 +32,7 @@ Usage:
|
|||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import shlex
|
||||
import shutil
|
||||
import signal
|
||||
|
|
@ -39,6 +40,9 @@ import subprocess
|
|||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
_IS_WINDOWS = platform.system() == "Windows"
|
||||
from tools.environments.local import _find_shell
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
|
@ -145,7 +149,7 @@ class ProcessRegistry:
|
|||
# Try PTY mode for interactive CLI tools
|
||||
try:
|
||||
import ptyprocess
|
||||
user_shell = os.environ.get("SHELL") or shutil.which("bash") or "/bin/bash"
|
||||
user_shell = _find_shell()
|
||||
pty_env = os.environ | (env_vars or {})
|
||||
pty_env["PYTHONUNBUFFERED"] = "1"
|
||||
pty_proc = ptyprocess.PtyProcess.spawn(
|
||||
|
|
@ -183,7 +187,7 @@ class ProcessRegistry:
|
|||
# Standard Popen path (non-PTY or PTY fallback)
|
||||
# Use the user's login shell for consistency with LocalEnvironment --
|
||||
# ensures rc files are sourced and user tools are available.
|
||||
user_shell = os.environ.get("SHELL") or shutil.which("bash") or "/bin/bash"
|
||||
user_shell = _find_shell()
|
||||
# Force unbuffered output for Python scripts so progress is visible
|
||||
# during background execution (libraries like tqdm/datasets buffer when
|
||||
# stdout is a pipe, hiding output from process(action="poll")).
|
||||
|
|
@ -199,7 +203,7 @@ class ProcessRegistry:
|
|||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
stdin=subprocess.PIPE,
|
||||
preexec_fn=os.setsid,
|
||||
preexec_fn=None if _IS_WINDOWS else os.setsid,
|
||||
)
|
||||
|
||||
session.process = proc
|
||||
|
|
@ -551,7 +555,10 @@ class ProcessRegistry:
|
|||
elif session.process:
|
||||
# Local process -- kill the process group
|
||||
try:
|
||||
os.killpg(os.getpgid(session.process.pid), signal.SIGTERM)
|
||||
if _IS_WINDOWS:
|
||||
session.process.terminate()
|
||||
else:
|
||||
os.killpg(os.getpgid(session.process.pid), signal.SIGTERM)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
session.process.kill()
|
||||
elif session.env_ref and session.pid:
|
||||
|
|
|
|||
|
|
@ -520,8 +520,8 @@ class ClawHubSource(SkillSource):
|
|||
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{self.BASE_URL}/skills/search",
|
||||
params={"q": query, "limit": limit},
|
||||
f"{self.BASE_URL}/skills",
|
||||
params={"search": query, "limit": limit},
|
||||
timeout=15,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
|
|
@ -530,82 +530,154 @@ class ClawHubSource(SkillSource):
|
|||
except (httpx.HTTPError, json.JSONDecodeError):
|
||||
return []
|
||||
|
||||
skills_data = data.get("skills", data) if isinstance(data, dict) else data
|
||||
skills_data = data.get("items", data) if isinstance(data, dict) else data
|
||||
if not isinstance(skills_data, list):
|
||||
return []
|
||||
|
||||
results = []
|
||||
for item in skills_data[:limit]:
|
||||
name = item.get("name", item.get("slug", ""))
|
||||
if not name:
|
||||
slug = item.get("slug")
|
||||
if not slug:
|
||||
continue
|
||||
meta = SkillMeta(
|
||||
name=name,
|
||||
description=item.get("description", ""),
|
||||
display_name = item.get("displayName") or item.get("name") or slug
|
||||
summary = item.get("summary") or item.get("description") or ""
|
||||
tags = item.get("tags", [])
|
||||
if not isinstance(tags, list):
|
||||
tags = []
|
||||
results.append(SkillMeta(
|
||||
name=display_name,
|
||||
description=summary,
|
||||
source="clawhub",
|
||||
identifier=item.get("slug", name),
|
||||
identifier=slug,
|
||||
trust_level="community",
|
||||
tags=item.get("tags", []),
|
||||
)
|
||||
results.append(meta)
|
||||
tags=[str(t) for t in tags],
|
||||
))
|
||||
|
||||
_write_index_cache(cache_key, [_skill_meta_to_dict(s) for s in results])
|
||||
return results
|
||||
|
||||
def fetch(self, identifier: str) -> Optional[SkillBundle]:
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{self.BASE_URL}/skills/{identifier}/versions/latest/files",
|
||||
timeout=30,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
data = resp.json()
|
||||
except (httpx.HTTPError, json.JSONDecodeError):
|
||||
slug = identifier.split("/")[-1]
|
||||
|
||||
skill_data = self._get_json(f"{self.BASE_URL}/skills/{slug}")
|
||||
if not isinstance(skill_data, dict):
|
||||
return None
|
||||
|
||||
files: Dict[str, str] = {}
|
||||
file_list = data.get("files", data) if isinstance(data, dict) else data
|
||||
if isinstance(file_list, list):
|
||||
for f in file_list:
|
||||
fname = f.get("name", f.get("path", ""))
|
||||
content = f.get("content", "")
|
||||
if fname and content:
|
||||
files[fname] = content
|
||||
elif isinstance(file_list, dict):
|
||||
files = {k: v for k, v in file_list.items() if isinstance(v, str)}
|
||||
latest_version = self._resolve_latest_version(slug, skill_data)
|
||||
if not latest_version:
|
||||
logger.warning("ClawHub fetch failed for %s: could not resolve latest version", slug)
|
||||
return None
|
||||
|
||||
version_data = self._get_json(f"{self.BASE_URL}/skills/{slug}/versions/{latest_version}")
|
||||
if not isinstance(version_data, dict):
|
||||
return None
|
||||
|
||||
files = self._extract_files(version_data)
|
||||
if "SKILL.md" not in files:
|
||||
logger.warning(
|
||||
"ClawHub fetch for %s resolved version %s but no inline/raw file content was available",
|
||||
slug,
|
||||
latest_version,
|
||||
)
|
||||
return None
|
||||
|
||||
return SkillBundle(
|
||||
name=identifier.split("/")[-1] if "/" in identifier else identifier,
|
||||
name=slug,
|
||||
files=files,
|
||||
source="clawhub",
|
||||
identifier=identifier,
|
||||
identifier=slug,
|
||||
trust_level="community",
|
||||
)
|
||||
|
||||
def inspect(self, identifier: str) -> Optional[SkillMeta]:
|
||||
slug = identifier.split("/")[-1]
|
||||
data = self._get_json(f"{self.BASE_URL}/skills/{slug}")
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
|
||||
tags = data.get("tags", [])
|
||||
if not isinstance(tags, list):
|
||||
tags = []
|
||||
|
||||
return SkillMeta(
|
||||
name=data.get("displayName") or data.get("name") or data.get("slug") or slug,
|
||||
description=data.get("summary") or data.get("description") or "",
|
||||
source="clawhub",
|
||||
identifier=data.get("slug") or slug,
|
||||
trust_level="community",
|
||||
tags=[str(t) for t in tags],
|
||||
)
|
||||
|
||||
def _get_json(self, url: str, timeout: int = 20) -> Optional[Any]:
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{self.BASE_URL}/skills/{identifier}",
|
||||
timeout=15,
|
||||
)
|
||||
resp = httpx.get(url, timeout=timeout)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
data = resp.json()
|
||||
return resp.json()
|
||||
except (httpx.HTTPError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
return SkillMeta(
|
||||
name=data.get("name", identifier),
|
||||
description=data.get("description", ""),
|
||||
source="clawhub",
|
||||
identifier=identifier,
|
||||
trust_level="community",
|
||||
tags=data.get("tags", []),
|
||||
)
|
||||
def _resolve_latest_version(self, slug: str, skill_data: Dict[str, Any]) -> Optional[str]:
|
||||
latest = skill_data.get("latestVersion")
|
||||
if isinstance(latest, dict):
|
||||
version = latest.get("version")
|
||||
if isinstance(version, str) and version:
|
||||
return version
|
||||
|
||||
tags = skill_data.get("tags")
|
||||
if isinstance(tags, dict):
|
||||
latest_tag = tags.get("latest")
|
||||
if isinstance(latest_tag, str) and latest_tag:
|
||||
return latest_tag
|
||||
|
||||
versions_data = self._get_json(f"{self.BASE_URL}/skills/{slug}/versions")
|
||||
if isinstance(versions_data, list) and versions_data:
|
||||
first = versions_data[0]
|
||||
if isinstance(first, dict):
|
||||
version = first.get("version")
|
||||
if isinstance(version, str) and version:
|
||||
return version
|
||||
return None
|
||||
|
||||
def _extract_files(self, version_data: Dict[str, Any]) -> Dict[str, str]:
|
||||
files: Dict[str, str] = {}
|
||||
file_list = version_data.get("files")
|
||||
|
||||
if isinstance(file_list, dict):
|
||||
return {k: v for k, v in file_list.items() if isinstance(v, str)}
|
||||
|
||||
if not isinstance(file_list, list):
|
||||
return files
|
||||
|
||||
for file_meta in file_list:
|
||||
if not isinstance(file_meta, dict):
|
||||
continue
|
||||
|
||||
fname = file_meta.get("path") or file_meta.get("name")
|
||||
if not fname or not isinstance(fname, str):
|
||||
continue
|
||||
|
||||
inline_content = file_meta.get("content")
|
||||
if isinstance(inline_content, str):
|
||||
files[fname] = inline_content
|
||||
continue
|
||||
|
||||
raw_url = file_meta.get("rawUrl") or file_meta.get("downloadUrl") or file_meta.get("url")
|
||||
if isinstance(raw_url, str) and raw_url.startswith("http"):
|
||||
content = self._fetch_text(raw_url)
|
||||
if content is not None:
|
||||
files[fname] = content
|
||||
|
||||
return files
|
||||
|
||||
def _fetch_text(self, url: str) -> Optional[str]:
|
||||
try:
|
||||
resp = httpx.get(url, timeout=20)
|
||||
if resp.status_code == 200:
|
||||
return resp.text
|
||||
except httpx.HTTPError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue