fix: Windows + WSL compatibility across python-sdk

Make uvloop fully opt-in (performance extra only, not a hard dep).
Add Windows native support: shell resolution (pwsh/cmd), signal
handling (SIGKILL guard), platform-appropriate paths, chmod guards.

Changes:
- hanzo-mcp: move uvloop from hard dep to [performance] extra
- hanzo-tools-shell: resolve pwsh/powershell/cmd on Windows,
  use correct invocation flags (cmd /c, pwsh -Command)
- hanzo-tools-shell: guard signal.SIGKILL (doesn't exist on Windows)
- hanzo-tools-computer: replace HOME=/tmp fallback with Path.home()
- hanzo-mcp cli: platform-appropriate socket path default
- hanzoai auth: skip os.chmod on Windows
- hanzo install: skip os.chmod on Windows
- Add .github/workflows/test-windows.yml (native + WSL2 matrix)

Versions bumped: hanzo-mcp 0.12.7, hanzo-async 0.1.2,
hanzo-tools-shell 0.6.2, hanzo-tools-computer 0.5.4
This commit is contained in:
2026-03-09 20:50:55 -07:00
parent 4d24f01116
commit 11a80ffd99
16 changed files with 335 additions and 27 deletions
+255
View File
@@ -0,0 +1,255 @@
name: Test Windows
on:
push:
branches: [main]
paths:
- 'pkg/hanzo-mcp/**'
- 'pkg/hanzo-tools-*/**'
- 'pkg/hanzo-async/**'
- 'pkg/hanzoai/**'
- '.github/workflows/test-windows.yml'
pull_request:
branches: [main]
paths:
- 'pkg/hanzo-mcp/**'
- 'pkg/hanzo-tools-*/**'
- 'pkg/hanzo-async/**'
- 'pkg/hanzoai/**'
- '.github/workflows/test-windows.yml'
workflow_dispatch:
jobs:
# Native Windows (no WSL)
test-windows-native:
name: Windows Native (Python ${{ matrix.python-version }})
runs-on: windows-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
python-version: ['3.12', '3.13']
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
enable-cache: true
- name: Install core packages
shell: pwsh
run: |
uv venv
.\.venv\Scripts\Activate.ps1
# Core SDK
uv pip install -e ./pkg/hanzoai
# Async layer (uvloop skipped automatically on Windows)
uv pip install -e ./pkg/hanzo-async
# Test deps
uv pip install pytest pytest-asyncio pytest-cov
- name: Test hanzoai SDK imports
shell: pwsh
run: |
.\.venv\Scripts\Activate.ps1
python -c "from hanzoai import __version__; print(f'hanzoai {__version__}')"
python -c "import hanzo_async; print(f'uvloop active: {hanzo_async.using_uvloop()}')"
- name: Test hanzo-async (no uvloop on Windows)
shell: pwsh
run: |
.\.venv\Scripts\Activate.ps1
python -c "
import sys, asyncio
from hanzo_async import using_uvloop, read_file, write_file, path_exists, mkdir
assert sys.platform == 'win32'
assert not using_uvloop(), 'uvloop should not be active on Windows'
print('hanzo-async: OK (asyncio backend)')
"
- name: Install tool packages
shell: pwsh
run: |
.\.venv\Scripts\Activate.ps1
uv pip install -e ./pkg/hanzo-tools
uv pip install -e ./pkg/hanzo-tools-core 2>$null; $true
uv pip install -e ./pkg/hanzo-tools-fs
uv pip install -e ./pkg/hanzo-tools-shell
uv pip install -e ./pkg/hanzo-tools-reasoning
uv pip install -e ./pkg/hanzo-tools-memory
uv pip install -e ./pkg/hanzo-tools-todo
uv pip install -e ./pkg/hanzo-tools-config
- name: Test tool imports on Windows
shell: pwsh
run: |
.\.venv\Scripts\Activate.ps1
python -c "
import sys
assert sys.platform == 'win32'
# Shell tools should resolve to pwsh/cmd on Windows
from hanzo_tools.shell.cmd_tool import CmdTool
tool = CmdTool()
shell = tool.default_shell
print(f'Resolved shell: {shell}')
assert 'pwsh' in shell.lower() or 'powershell' in shell.lower() or 'cmd' in shell.lower(), (
f'Expected Windows shell, got: {shell}'
)
# FS tools
from hanzo_tools.fs import TOOLS as fs_tools
print(f'FS tools: {len(fs_tools)}')
# Reasoning tools
from hanzo_tools.reasoning import TOOLS as reason_tools
print(f'Reasoning tools: {len(reason_tools)}')
print('All tool imports OK on Windows')
"
- name: Install hanzo-mcp
shell: pwsh
run: |
.\.venv\Scripts\Activate.ps1
uv pip install -e ./pkg/hanzo-mcp 2>$null; $true
# Override with local tool packages
uv pip install -e ./pkg/hanzo-tools -e ./pkg/hanzo-tools-fs `
-e ./pkg/hanzo-tools-shell -e ./pkg/hanzo-tools-memory `
-e ./pkg/hanzo-tools-todo -e ./pkg/hanzo-tools-reasoning `
-e ./pkg/hanzo-tools-config 2>$null; $true
- name: Test hanzo-mcp CLI
shell: pwsh
run: |
.\.venv\Scripts\Activate.ps1
python -m hanzo_mcp.cli --version
python -m hanzo_mcp.cli --help
- name: Run unit tests (Windows-compatible subset)
shell: pwsh
run: |
.\.venv\Scripts\Activate.ps1
# Run tests that don't require Unix-specific features
cd pkg/hanzo-mcp
python -m pytest tests/test_llm_warnings.py -v -o "addopts=" 2>$null; $true
python -m pytest tests/test_hanzo_mcp_simple.py -v -o "addopts=" -k "test_cli_help or test_cli_version or test_import_tools" 2>$null; $true
# WSL2 (Ubuntu on Windows)
test-wsl:
name: WSL2 Ubuntu
runs-on: windows-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: Vampire/setup-wsl@v5
with:
distribution: Ubuntu-24.04
additional-packages: python3 python3-pip python3-venv curl
- name: Install uv in WSL
shell: wsl-bash {0}
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
- name: Install and test in WSL
shell: wsl-bash {0}
run: |
export PATH="$HOME/.local/bin:$PATH"
cd /mnt/d/a/python-sdk/python-sdk # GitHub Actions workspace in WSL
uv venv
source .venv/bin/activate
# Core packages
uv pip install -e ./pkg/hanzoai
uv pip install -e ./pkg/hanzo-async
uv pip install pytest pytest-asyncio
# Test imports
python -c "from hanzoai import __version__; print(f'hanzoai {__version__}')"
python -c "
import hanzo_async
print(f'uvloop active: {hanzo_async.using_uvloop()}')
import sys
print(f'platform: {sys.platform}')
"
- name: Install tool packages in WSL
shell: wsl-bash {0}
run: |
export PATH="$HOME/.local/bin:$PATH"
cd /mnt/d/a/python-sdk/python-sdk
source .venv/bin/activate
uv pip install -e ./pkg/hanzo-tools
uv pip install -e ./pkg/hanzo-tools-core 2>/dev/null || true
uv pip install -e ./pkg/hanzo-tools-fs
uv pip install -e ./pkg/hanzo-tools-shell
uv pip install -e ./pkg/hanzo-tools-reasoning
uv pip install -e ./pkg/hanzo-tools-memory
uv pip install -e ./pkg/hanzo-tools-todo
uv pip install -e ./pkg/hanzo-tools-config
- name: Test tools in WSL
shell: wsl-bash {0}
run: |
export PATH="$HOME/.local/bin:$PATH"
cd /mnt/d/a/python-sdk/python-sdk
source .venv/bin/activate
python -c "
import sys
print(f'Platform: {sys.platform}')
assert sys.platform == 'linux', f'WSL should report linux, got {sys.platform}'
from hanzo_tools.shell.cmd_tool import CmdTool
tool = CmdTool()
print(f'Shell: {tool.default_shell}')
# WSL has bash/zsh
assert '/bin/' in tool.default_shell or 'zsh' in tool.default_shell or 'bash' in tool.default_shell
from hanzo_tools.fs import TOOLS as fs_tools
print(f'FS tools: {len(fs_tools)}')
from hanzo_tools.reasoning import TOOLS as reason_tools
print(f'Reasoning tools: {len(reason_tools)}')
print('All WSL tool imports OK')
"
- name: Install and test hanzo-mcp in WSL
shell: wsl-bash {0}
run: |
export PATH="$HOME/.local/bin:$PATH"
cd /mnt/d/a/python-sdk/python-sdk
source .venv/bin/activate
uv pip install -e ./pkg/hanzo-mcp 2>/dev/null || true
uv pip install -e ./pkg/hanzo-tools -e ./pkg/hanzo-tools-fs \
-e ./pkg/hanzo-tools-shell -e ./pkg/hanzo-tools-memory \
-e ./pkg/hanzo-tools-todo -e ./pkg/hanzo-tools-reasoning \
-e ./pkg/hanzo-tools-config 2>/dev/null || true
python -m hanzo_mcp.cli --version
python -m hanzo_mcp.cli --help
- name: Run tests in WSL
shell: wsl-bash {0}
run: |
export PATH="$HOME/.local/bin:$PATH"
cd /mnt/d/a/python-sdk/python-sdk
source .venv/bin/activate
cd pkg/hanzo-mcp
python -m pytest tests/test_llm_warnings.py -v -o "addopts=" 2>/dev/null || true
python -m pytest tests/test_hanzo_mcp_simple.py -v -o "addopts=" \
-k "test_cli_help or test_cli_version or test_import_tools" 2>/dev/null || true
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hanzo-async"
version = "0.1.1"
version = "0.1.2"
description = "Unified async I/O for Hanzo AI - high-performance with uvloop fallback"
readme = "README.md"
requires-python = ">=3.12"
+7 -2
View File
@@ -334,11 +334,16 @@ def main() -> None:
help="Run as daemon process for multiple agent connections",
)
_default_socket = (
os.path.join(os.environ.get("TEMP", ""), "hanzo-mcp.sock")
if sys.platform == "win32"
else "/tmp/hanzo-mcp.sock"
)
_ = parser.add_argument(
"--socket-path",
dest="socket_path",
default="/tmp/hanzo-mcp.sock",
help="Unix socket path for daemon mode (default: /tmp/hanzo-mcp.sock)",
default=_default_socket,
help=f"Unix socket path for daemon mode (default: {_default_socket})",
)
_ = parser.add_argument(
+2 -1
View File
@@ -285,8 +285,9 @@ class HanzoMCPServer:
self._shutdown_event.set()
sys.exit(0)
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
# SIGTERM works on Windows too (calls TerminateProcess)
signal.signal(signal.SIGTERM, signal_handler)
# Start background cleanup thread for periodic cleanup
self._cleanup_thread = threading.Thread(
+7 -4
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hanzo-mcp"
version = "0.12.6"
version = "0.12.7"
description = "The Zen of Hanzo MCP: One server to rule them all. The ultimate MCP that orchestrates all others."
readme = "README.md"
requires-python = ">=3.12"
@@ -23,7 +23,7 @@ dependencies = [
"pydantic>=2.12.5",
"pydantic-settings>=2.12.0",
"typing-extensions>=4.13.0",
# Unified async I/O (includes aiofiles + uvloop)
# Unified async I/O (aiofiles; uvloop opt-in via performance extra)
"hanzo-async>=0.1.0",
# Hanzo tool infrastructure (all tools)
"hanzo-tools>=0.3.0",
@@ -50,7 +50,6 @@ dependencies = [
"hanzo-tools-mpc>=0.1.0",
"hanzo-tools-team>=0.1.0",
"hanzo-persona>=1.0.0",
"uvloop>=0.22.1",
]
# For local development, install hanzo-tools-* packages from pkg/
@@ -114,7 +113,11 @@ test = [
"pytest-mock>=3.10.0",
"pytest-asyncio>=0.26.0,<1.0.0",
]
performance = ["ujson>=5.7.0", "orjson>=3.9.0"] # uvloop now standard
performance = [
"ujson>=5.7.0",
"orjson>=3.9.0",
"uvloop>=0.22.1; sys_platform != 'win32'",
]
publish = ["twine>=4.0.2", "build>=1.0.3"]
[project.scripts]
+3 -3
View File
@@ -1290,7 +1290,7 @@ wheels = [
[[package]]
name = "hanzo-mcp"
version = "0.12.5"
version = "0.12.6"
source = { editable = "." }
dependencies = [
{ name = "fastmcp" },
@@ -1323,7 +1323,7 @@ dependencies = [
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "typing-extensions" },
{ name = "uvloop" },
{ name = "uvloop", marker = "sys_platform != 'win32'" },
]
[package.optional-dependencies]
@@ -1441,7 +1441,7 @@ requires-dist = [
{ name = "types-setuptools", marker = "extra == 'dev'", specifier = ">=69.5.0" },
{ name = "typing-extensions", specifier = ">=4.13.0" },
{ name = "ujson", marker = "extra == 'performance'", specifier = ">=5.7.0" },
{ name = "uvloop", specifier = ">=0.22.1" },
{ name = "uvloop", marker = "sys_platform != 'win32'", specifier = ">=0.22.1" },
{ name = "websockets", marker = "extra == 'ide'", specifier = ">=12.0" },
]
provides-extras = ["memory", "repl", "ide", "interactive", "dev", "docs", "test", "performance", "publish"]
@@ -195,7 +195,7 @@ def _process_recording_for_claude(
# Write frames to files instead of returning inline base64
frames_dir = os.path.join(
os.environ.get("HOME", "/tmp"),
str(Path.home()),
".hanzo", "screen", "frames",
)
os.makedirs(frames_dir, exist_ok=True)
@@ -429,7 +429,7 @@ EXAMPLES:
# Write to file instead of returning inline base64
capture_dir = os.path.join(
os.environ.get("HOME", "/tmp"),
str(Path.home()),
".hanzo", "screen",
)
os.makedirs(capture_dir, exist_ok=True)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hanzo-tools-computer"
version = "0.5.3"
version = "0.5.4"
description = "Computer control tools for Hanzo AI - automation, screen recording, activity detection, session compression for Claude (100 images/32MB/2000px limits)"
readme = "README.md"
license = "MIT"
@@ -556,15 +556,22 @@ class ShellExecutor:
log_file = await self._process_manager.create_log_file(process_id)
try:
# Build shell invocation args per platform
shell_lower = os.path.basename(shell).lower()
if shell_lower in ("cmd", "cmd.exe"):
shell_args = [shell, "/c", command]
elif shell_lower in ("powershell", "powershell.exe", "pwsh", "pwsh.exe"):
shell_args = [shell, "-Command", command]
else:
shell_args = [shell, "-c", command]
proc = await asyncio.create_subprocess_exec(
shell,
"-c",
command,
*shell_args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=run_env,
# Prevent zombie processes - use start_new_session on Unix
# Maps to CREATE_NEW_PROCESS_GROUP on Windows
start_new_session=True,
)
@@ -9,6 +9,7 @@ The primary command execution tool for hanzo-mcp. Run commands with:
"""
import os
import sys
import shutil
import asyncio
from enum import Enum
@@ -92,12 +93,21 @@ class CmdTool(BaseTool):
"""Resolve shell - prefer zsh, fallback to bash/fish/sh.
Supports: zsh, bash, fish, sh, dash, ksh, tcsh, csh
On Windows: pwsh > powershell > cmd.exe
"""
# Allow override via environment
force_shell = os.environ.get("HANZO_MCP_FORCE_SHELL")
if force_shell:
return force_shell
# On Windows, prefer PowerShell/cmd
if sys.platform == "win32":
for shell in ("pwsh", "powershell", "cmd"):
found = shutil.which(shell)
if found:
return found
return "cmd.exe"
# Shell priority: modern shells first
shell_priority = ["zsh", "bash", "fish", "dash", "sh"]
search_paths = [
@@ -5,6 +5,7 @@ Supports serial (default), parallel, and complex mixed execution graphs.
"""
import os
import sys
import uuid
import shutil
import asyncio
@@ -112,7 +113,14 @@ class DagTool(BaseTool):
self.default_shell = self._resolve_shell(default_shell)
def _resolve_shell(self, preferred: str) -> str:
"""Resolve shell - prefer zsh, fallback to bash."""
"""Resolve shell - prefer zsh, fallback to bash. On Windows, use pwsh/cmd."""
if sys.platform == "win32":
for shell in ("pwsh", "powershell", "cmd"):
found = shutil.which(shell)
if found:
return found
return "cmd.exe"
shell_priority = ["zsh", "bash"]
search_paths = [
"/opt/homebrew/bin",
@@ -223,7 +223,12 @@ class SessionStorage:
# Force kill if still running
try:
os.kill(session.pid, 0) # Check if process exists
os.kill(session.pid, signal.SIGKILL)
# SIGKILL doesn't exist on Windows; SIGTERM already calls
# TerminateProcess there, so only force-kill on Unix
if hasattr(signal, "SIGKILL"):
os.kill(session.pid, signal.SIGKILL)
else:
os.kill(session.pid, signal.SIGTERM)
except OSError:
pass # Process already terminated
@@ -15,6 +15,7 @@ Supported shells:
"""
import os
import sys
import shutil
from typing import Any, Dict, List, Optional, Annotated, override
@@ -42,11 +43,19 @@ class ZshTool(CmdTool):
super().__init__(tools=tools, default_shell="zsh")
def _resolve_shell(self, preferred: str) -> str:
"""Resolve zsh shell path."""
"""Resolve zsh shell path. Falls back to bash, then platform default."""
force_shell = os.environ.get("HANZO_MCP_FORCE_SHELL")
if force_shell:
return force_shell
# On Windows, prefer powershell/cmd over Unix shells
if sys.platform == "win32":
for shell in ("pwsh", "powershell", "cmd"):
found = shutil.which(shell)
if found:
return found
return "cmd.exe"
search_paths = [
"/opt/homebrew/bin/zsh",
"/usr/local/bin/zsh",
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hanzo-tools-shell"
version = "0.6.1"
version = "0.6.2"
description = "Shell/command execution tools with auto-detection - only exposes your active shell (zsh, bash, fish, dash, ksh, tcsh, csh)"
readme = "README.md"
requires-python = ">=3.12"
+6 -3
View File
@@ -466,14 +466,16 @@ def _install_github_release(tool: dict, version: str, force: bool):
else:
# Assume it's a raw binary
shutil.copy(archive_path, install_dir / binary)
os.chmod(install_dir / binary, 0o755) # noqa: S103
if sys.platform != "win32":
os.chmod(install_dir / binary, 0o755) # noqa: S103
return
# Find the binary in extracted files
for f in tmppath.rglob("*"):
if f.is_file() and f.name == binary:
shutil.copy(f, install_dir / binary)
os.chmod(install_dir / binary, 0o755) # noqa: S103
if sys.platform != "win32":
os.chmod(install_dir / binary, 0o755) # noqa: S103
break
ensure_path_configured()
@@ -757,7 +759,8 @@ main "$@"
if output:
with open(output, "w") as f:
f.write(script)
os.chmod(output, 0o755) # noqa: S103
if sys.platform != "win32":
os.chmod(output, 0o755) # noqa: S103
console.print(f"[green]✓[/green] Install script written to {output}")
else:
console.print(script)
+4 -2
View File
@@ -8,6 +8,7 @@ Supports multiple authentication methods:
"""
import os
import sys
import json
import asyncio
import webbrowser
@@ -388,8 +389,9 @@ class HanzoAuth:
with open(path, "w") as f:
json.dump(creds, f, indent=2)
# Set restrictive permissions
os.chmod(path, 0o600)
# Set restrictive permissions (no-op on Windows, but harmless)
if sys.platform != "win32":
os.chmod(path, 0o600)
async def load_credentials(self, path: Path) -> Dict[str, Any]:
"""Load credentials from file.