Files
python-sdk/pkg/hanzo-tools-shell/hanzo_tools/shell/shell_tools.py
T
zeekay 1a7ec1bad0 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
2026-03-09 20:50:55 -07:00

807 lines
26 KiB
Python

"""Shell tool shims - zsh, bash, fish, dash, ksh, tcsh, csh, shell wrappers over cmd.
These are thin wrappers that set the shell and delegate to CmdTool.
Use cmd directly for full control.
Supported shells:
- zsh: Z shell (default on macOS, popular on Linux)
- bash: Bourne-Again shell (most common, default on most Linux)
- fish: Friendly Interactive Shell (modern, user-friendly)
- dash: Debian Almquist Shell (fast POSIX shell, Ubuntu's /bin/sh)
- ksh: KornShell (enterprise Unix, efficient scripting)
- tcsh: TENEX C Shell (enhanced csh with command completion)
- csh: C Shell (older, C-like syntax)
- shell: Auto-selects best available (zsh > bash > fish > dash > ksh > sh)
"""
import os
import sys
import shutil
from typing import Any, Dict, List, Optional, Annotated, override
from pydantic import Field
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import BaseTool, auto_timeout, create_tool_context
from hanzo_tools.shell.cmd_tool import CmdTool, Command
class ZshTool(CmdTool):
"""Zsh shell - thin wrapper over cmd with shell=zsh.
Usage:
zsh("ls -la") # Single command
zsh(["ls", "pwd"]) # Sequential
zsh(["a", "b"], parallel=True) # Parallel
"""
name = "zsh"
def __init__(self, tools: Optional[Dict[str, BaseTool]] = None):
"""Initialize with zsh as default shell."""
super().__init__(tools=tools, default_shell="zsh")
def _resolve_shell(self, preferred: str) -> str:
"""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",
"/bin/zsh",
"/usr/bin/zsh",
]
for path in search_paths:
if os.path.isfile(path) and os.access(path, os.X_OK):
return path
found = shutil.which("zsh")
if found:
return found
# Fallback to bash if zsh not found
return shutil.which("bash") or "sh"
@property
@override
def description(self) -> str:
shell_name = os.path.basename(self.default_shell)
return f"""Zsh shell (using: {shell_name}).
SIMPLE:
zsh("ls -la") # Single command
zsh("A ; B ; C") # Sequential via shell
ARRAYS:
zsh(["a", "b", "c"]) # Sequential
zsh(["a", "b"], parallel=True) # All parallel
zsh(["a", ["b", "c"], "d"]) # Nested = parallel
OPTIONS:
parallel: Run ALL commands concurrently
strict: Stop on first error
quiet: Suppress stdout
timeout: Per-command timeout (default: 45s)
cwd: Working directory
env: Environment variables
AUTO-BACKGROUNDING: Commands exceeding 45s auto-background.
Use ps --logs <id> to view, ps --kill <id> to stop."""
@override
def register(self, mcp_server: FastMCP) -> None:
"""Register zsh tool with MCP server."""
tool_self = self
@mcp_server.tool(name=self.name, description=self.description)
async def zsh_handler(
command: Annotated[
Optional[str],
Field(description="Single command to execute", default=None),
] = None,
commands: Annotated[
Optional[List[Any]],
Field(description="List of commands for DAG execution", default=None),
] = None,
parallel: Annotated[
bool, Field(description="Run all commands in parallel", default=False)
] = False,
cwd: Annotated[
Optional[str], Field(description="Working directory", default=None)
] = None,
env: Annotated[
Optional[Dict[str, str]],
Field(description="Environment variables", default=None),
] = None,
timeout: Annotated[
int, Field(description="Timeout per command (seconds)", default=30)
] = 30,
strict: Annotated[
bool, Field(description="Stop on first error", default=False)
] = False,
quiet: Annotated[
bool, Field(description="Suppress stdout", default=False)
] = False,
ctx: MCPContext = None,
) -> str:
return await tool_self.call(
ctx,
command=command,
commands=commands,
parallel=parallel,
shell=None, # Use default (zsh)
cwd=cwd,
env=env,
timeout=timeout,
strict=strict,
quiet=quiet,
)
class BashTool(CmdTool):
"""Bash shell - thin wrapper over cmd with shell=bash."""
name = "bash"
def __init__(self, tools: Optional[Dict[str, BaseTool]] = None):
"""Initialize with bash as default shell."""
super().__init__(tools=tools, default_shell="bash")
def _resolve_shell(self, preferred: str) -> str:
"""Resolve bash shell path."""
force_shell = os.environ.get("HANZO_MCP_FORCE_SHELL")
if force_shell:
return force_shell
found = shutil.which("bash")
if found:
return found
return "sh"
@property
@override
def description(self) -> str:
return """Bash shell - thin wrapper over cmd.
USAGE:
bash("ls -la") # Single command
bash(["a", "b", "c"]) # Sequential
bash(["a", "b"], parallel=True) # Parallel
AUTO-BACKGROUNDING: Commands exceeding 45s auto-background."""
@override
def register(self, mcp_server: FastMCP) -> None:
"""Register bash tool with MCP server."""
tool_self = self
@mcp_server.tool(name=self.name, description=self.description)
async def bash_handler(
command: Annotated[
Optional[str],
Field(description="Single command to execute", default=None),
] = None,
commands: Annotated[
Optional[List[Any]], Field(description="List of commands", default=None)
] = None,
parallel: Annotated[
bool, Field(description="Run in parallel", default=False)
] = False,
cwd: Annotated[
Optional[str], Field(description="Working directory", default=None)
] = None,
env: Annotated[
Optional[Dict[str, str]],
Field(description="Environment variables", default=None),
] = None,
timeout: Annotated[
int, Field(description="Timeout (seconds)", default=30)
] = 30,
strict: Annotated[
bool, Field(description="Stop on first error", default=False)
] = False,
quiet: Annotated[
bool, Field(description="Suppress stdout", default=False)
] = False,
ctx: MCPContext = None,
) -> str:
return await tool_self.call(
ctx,
command=command,
commands=commands,
parallel=parallel,
shell=None, # Use default (bash)
cwd=cwd,
env=env,
timeout=timeout,
strict=strict,
quiet=quiet,
)
class FishTool(CmdTool):
"""Fish shell - thin wrapper over cmd with shell=fish."""
name = "fish"
def __init__(self, tools: Optional[Dict[str, BaseTool]] = None):
"""Initialize with fish as default shell."""
super().__init__(tools=tools, default_shell="fish")
def _resolve_shell(self, preferred: str) -> str:
"""Resolve fish shell path."""
force_shell = os.environ.get("HANZO_MCP_FORCE_SHELL")
if force_shell:
return force_shell
search_paths = [
"/opt/homebrew/bin/fish",
"/usr/local/bin/fish",
"/usr/bin/fish",
]
for path in search_paths:
if os.path.isfile(path) and os.access(path, os.X_OK):
return path
found = shutil.which("fish")
if found:
return found
# Fallback to zsh/bash if fish not found
return shutil.which("zsh") or shutil.which("bash") or "sh"
@property
@override
def description(self) -> str:
shell_name = os.path.basename(self.default_shell)
return f"""Fish shell (using: {shell_name}).
USAGE:
fish("ls -la") # Single command
fish(["a", "b", "c"]) # Sequential
fish(["a", "b"], parallel=True) # Parallel
AUTO-BACKGROUNDING: Commands exceeding 45s auto-background."""
@override
def register(self, mcp_server: FastMCP) -> None:
"""Register fish tool with MCP server."""
tool_self = self
@mcp_server.tool(name=self.name, description=self.description)
async def fish_handler(
command: Annotated[
Optional[str],
Field(description="Single command to execute", default=None),
] = None,
commands: Annotated[
Optional[List[Any]], Field(description="List of commands", default=None)
] = None,
parallel: Annotated[
bool, Field(description="Run in parallel", default=False)
] = False,
cwd: Annotated[
Optional[str], Field(description="Working directory", default=None)
] = None,
env: Annotated[
Optional[Dict[str, str]],
Field(description="Environment variables", default=None),
] = None,
timeout: Annotated[
int, Field(description="Timeout (seconds)", default=30)
] = 30,
strict: Annotated[
bool, Field(description="Stop on first error", default=False)
] = False,
quiet: Annotated[
bool, Field(description="Suppress stdout", default=False)
] = False,
ctx: MCPContext = None,
) -> str:
return await tool_self.call(
ctx,
command=command,
commands=commands,
parallel=parallel,
shell=None, # Use default (fish)
cwd=cwd,
env=env,
timeout=timeout,
strict=strict,
quiet=quiet,
)
class DashTool(CmdTool):
"""Dash shell - fast POSIX-compliant shell (Ubuntu's /bin/sh)."""
name = "dash"
def __init__(self, tools: Optional[Dict[str, BaseTool]] = None):
"""Initialize with dash as default shell."""
super().__init__(tools=tools, default_shell="dash")
def _resolve_shell(self, preferred: str) -> str:
"""Resolve dash shell path."""
force_shell = os.environ.get("HANZO_MCP_FORCE_SHELL")
if force_shell:
return force_shell
# dash is often /bin/sh on Debian/Ubuntu
search_paths = [
"/bin/dash",
"/usr/bin/dash",
]
for path in search_paths:
if os.path.isfile(path) and os.access(path, os.X_OK):
return path
found = shutil.which("dash")
if found:
return found
# Fallback to sh (which may be dash on Ubuntu)
return "sh"
@property
@override
def description(self) -> str:
return """Dash shell - fast POSIX-compliant shell.
Dash is the Debian Almquist Shell, a POSIX-compliant shell that's
faster than bash for scripts. It's the default /bin/sh on Ubuntu/Debian.
USAGE:
dash("ls -la") # Single command
dash(["a", "b", "c"]) # Sequential
dash(["a", "b"], parallel=True) # Parallel
AUTO-BACKGROUNDING: Commands exceeding 45s auto-background."""
@override
def register(self, mcp_server: FastMCP) -> None:
"""Register dash tool with MCP server."""
tool_self = self
@mcp_server.tool(name=self.name, description=self.description)
async def dash_handler(
command: Annotated[
Optional[str],
Field(description="Single command to execute", default=None),
] = None,
commands: Annotated[
Optional[List[Any]], Field(description="List of commands", default=None)
] = None,
parallel: Annotated[
bool, Field(description="Run in parallel", default=False)
] = False,
cwd: Annotated[
Optional[str], Field(description="Working directory", default=None)
] = None,
env: Annotated[
Optional[Dict[str, str]],
Field(description="Environment variables", default=None),
] = None,
timeout: Annotated[
int, Field(description="Timeout (seconds)", default=30)
] = 30,
strict: Annotated[
bool, Field(description="Stop on first error", default=False)
] = False,
quiet: Annotated[
bool, Field(description="Suppress stdout", default=False)
] = False,
ctx: MCPContext = None,
) -> str:
return await tool_self.call(
ctx,
command=command,
commands=commands,
parallel=parallel,
shell=None, # Use default (dash)
cwd=cwd,
env=env,
timeout=timeout,
strict=strict,
quiet=quiet,
)
class KshTool(CmdTool):
"""KornShell - enterprise Unix shell with efficient scripting."""
name = "ksh"
def __init__(self, tools: Optional[Dict[str, BaseTool]] = None):
"""Initialize with ksh as default shell."""
super().__init__(tools=tools, default_shell="ksh")
def _resolve_shell(self, preferred: str) -> str:
"""Resolve ksh shell path."""
force_shell = os.environ.get("HANZO_MCP_FORCE_SHELL")
if force_shell:
return force_shell
search_paths = [
"/bin/ksh",
"/usr/bin/ksh",
"/usr/local/bin/ksh",
"/opt/homebrew/bin/ksh",
]
for path in search_paths:
if os.path.isfile(path) and os.access(path, os.X_OK):
return path
found = shutil.which("ksh")
if found:
return found
# Also try ksh93 and pdksh
for variant in ["ksh93", "pdksh", "mksh"]:
found = shutil.which(variant)
if found:
return found
return "sh"
@property
@override
def description(self) -> str:
return """KornShell (ksh) - enterprise Unix shell.
KornShell is historically important in enterprise Unix environments.
Efficient for scripting, still used on some commercial Unix systems.
USAGE:
ksh("ls -la") # Single command
ksh(["a", "b", "c"]) # Sequential
ksh(["a", "b"], parallel=True) # Parallel
AUTO-BACKGROUNDING: Commands exceeding 45s auto-background."""
@override
def register(self, mcp_server: FastMCP) -> None:
"""Register ksh tool with MCP server."""
tool_self = self
@mcp_server.tool(name=self.name, description=self.description)
async def ksh_handler(
command: Annotated[
Optional[str],
Field(description="Single command to execute", default=None),
] = None,
commands: Annotated[
Optional[List[Any]], Field(description="List of commands", default=None)
] = None,
parallel: Annotated[
bool, Field(description="Run in parallel", default=False)
] = False,
cwd: Annotated[
Optional[str], Field(description="Working directory", default=None)
] = None,
env: Annotated[
Optional[Dict[str, str]],
Field(description="Environment variables", default=None),
] = None,
timeout: Annotated[
int, Field(description="Timeout (seconds)", default=30)
] = 30,
strict: Annotated[
bool, Field(description="Stop on first error", default=False)
] = False,
quiet: Annotated[
bool, Field(description="Suppress stdout", default=False)
] = False,
ctx: MCPContext = None,
) -> str:
return await tool_self.call(
ctx,
command=command,
commands=commands,
parallel=parallel,
shell=None,
cwd=cwd,
env=env,
timeout=timeout,
strict=strict,
quiet=quiet,
)
class TcshTool(CmdTool):
"""TENEX C Shell - enhanced csh with command completion."""
name = "tcsh"
def __init__(self, tools: Optional[Dict[str, BaseTool]] = None):
"""Initialize with tcsh as default shell."""
super().__init__(tools=tools, default_shell="tcsh")
def _resolve_shell(self, preferred: str) -> str:
"""Resolve tcsh shell path."""
force_shell = os.environ.get("HANZO_MCP_FORCE_SHELL")
if force_shell:
return force_shell
search_paths = [
"/bin/tcsh",
"/usr/bin/tcsh",
"/usr/local/bin/tcsh",
"/opt/homebrew/bin/tcsh",
]
for path in search_paths:
if os.path.isfile(path) and os.access(path, os.X_OK):
return path
found = shutil.which("tcsh")
if found:
return found
# Fallback to csh
return shutil.which("csh") or "sh"
@property
@override
def description(self) -> str:
return """TENEX C Shell (tcsh) - enhanced C shell.
Tcsh is an enhanced version of csh with command completion,
command-line editing, and other improvements. Mostly legacy now
but still encountered in some environments.
USAGE:
tcsh("ls -la") # Single command
tcsh(["a", "b", "c"]) # Sequential
tcsh(["a", "b"], parallel=True) # Parallel
AUTO-BACKGROUNDING: Commands exceeding 45s auto-background."""
@override
def register(self, mcp_server: FastMCP) -> None:
"""Register tcsh tool with MCP server."""
tool_self = self
@mcp_server.tool(name=self.name, description=self.description)
async def tcsh_handler(
command: Annotated[
Optional[str],
Field(description="Single command to execute", default=None),
] = None,
commands: Annotated[
Optional[List[Any]], Field(description="List of commands", default=None)
] = None,
parallel: Annotated[
bool, Field(description="Run in parallel", default=False)
] = False,
cwd: Annotated[
Optional[str], Field(description="Working directory", default=None)
] = None,
env: Annotated[
Optional[Dict[str, str]],
Field(description="Environment variables", default=None),
] = None,
timeout: Annotated[
int, Field(description="Timeout (seconds)", default=30)
] = 30,
strict: Annotated[
bool, Field(description="Stop on first error", default=False)
] = False,
quiet: Annotated[
bool, Field(description="Suppress stdout", default=False)
] = False,
ctx: MCPContext = None,
) -> str:
return await tool_self.call(
ctx,
command=command,
commands=commands,
parallel=parallel,
shell=None,
cwd=cwd,
env=env,
timeout=timeout,
strict=strict,
quiet=quiet,
)
class CshTool(CmdTool):
"""C Shell - older shell with C-like syntax."""
name = "csh"
def __init__(self, tools: Optional[Dict[str, BaseTool]] = None):
"""Initialize with csh as default shell."""
super().__init__(tools=tools, default_shell="csh")
def _resolve_shell(self, preferred: str) -> str:
"""Resolve csh shell path."""
force_shell = os.environ.get("HANZO_MCP_FORCE_SHELL")
if force_shell:
return force_shell
search_paths = [
"/bin/csh",
"/usr/bin/csh",
"/usr/local/bin/csh",
]
for path in search_paths:
if os.path.isfile(path) and os.access(path, os.X_OK):
return path
found = shutil.which("csh")
if found:
return found
# Fallback to tcsh (often linked to csh)
return shutil.which("tcsh") or "sh"
@property
@override
def description(self) -> str:
return """C Shell (csh) - shell with C-like syntax.
The C shell is an older shell with syntax reminiscent of C.
Mostly legacy now but still encountered in some environments.
Consider using tcsh (enhanced csh) or bash instead.
USAGE:
csh("ls -la") # Single command
csh(["a", "b", "c"]) # Sequential
csh(["a", "b"], parallel=True) # Parallel
AUTO-BACKGROUNDING: Commands exceeding 45s auto-background."""
@override
def register(self, mcp_server: FastMCP) -> None:
"""Register csh tool with MCP server."""
tool_self = self
@mcp_server.tool(name=self.name, description=self.description)
async def csh_handler(
command: Annotated[
Optional[str],
Field(description="Single command to execute", default=None),
] = None,
commands: Annotated[
Optional[List[Any]], Field(description="List of commands", default=None)
] = None,
parallel: Annotated[
bool, Field(description="Run in parallel", default=False)
] = False,
cwd: Annotated[
Optional[str], Field(description="Working directory", default=None)
] = None,
env: Annotated[
Optional[Dict[str, str]],
Field(description="Environment variables", default=None),
] = None,
timeout: Annotated[
int, Field(description="Timeout (seconds)", default=30)
] = 30,
strict: Annotated[
bool, Field(description="Stop on first error", default=False)
] = False,
quiet: Annotated[
bool, Field(description="Suppress stdout", default=False)
] = False,
ctx: MCPContext = None,
) -> str:
return await tool_self.call(
ctx,
command=command,
commands=commands,
parallel=parallel,
shell=None,
cwd=cwd,
env=env,
timeout=timeout,
strict=strict,
quiet=quiet,
)
class ShellTool(CmdTool):
"""Smart shell - auto-selects best available shell (zsh > bash > fish > dash > ksh > sh)."""
name = "shell"
def __init__(self, tools: Optional[Dict[str, BaseTool]] = None):
"""Initialize with best available shell."""
super().__init__(
tools=tools, default_shell="zsh"
) # Will resolve to best available
@property
@override
def description(self) -> str:
shell_name = os.path.basename(self.default_shell)
return f"""Smart shell (auto-selected: {shell_name}).
Automatically uses the best available shell:
zsh > bash > fish > dash > sh
USAGE:
shell("ls -la") # Single command
shell(["a", "b", "c"]) # Sequential
shell(["a", "b"], parallel=True) # Parallel
AUTO-BACKGROUNDING: Commands exceeding 45s auto-background."""
@override
def register(self, mcp_server: FastMCP) -> None:
"""Register shell tool with MCP server."""
tool_self = self
@mcp_server.tool(name=self.name, description=self.description)
async def shell_handler(
command: Annotated[
Optional[str],
Field(description="Single command to execute", default=None),
] = None,
commands: Annotated[
Optional[List[Any]], Field(description="List of commands", default=None)
] = None,
parallel: Annotated[
bool, Field(description="Run in parallel", default=False)
] = False,
cwd: Annotated[
Optional[str], Field(description="Working directory", default=None)
] = None,
env: Annotated[
Optional[Dict[str, str]],
Field(description="Environment variables", default=None),
] = None,
timeout: Annotated[
int, Field(description="Timeout (seconds)", default=30)
] = 30,
strict: Annotated[
bool, Field(description="Stop on first error", default=False)
] = False,
quiet: Annotated[
bool, Field(description="Suppress stdout", default=False)
] = False,
ctx: MCPContext = None,
) -> str:
return await tool_self.call(
ctx,
command=command,
commands=commands,
parallel=parallel,
shell=None, # Use default
cwd=cwd,
env=env,
timeout=timeout,
strict=strict,
quiet=quiet,
)
# Singleton instances
zsh_tool = ZshTool()
bash_tool = BashTool()
fish_tool = FishTool()
dash_tool = DashTool()
ksh_tool = KshTool()
tcsh_tool = TcshTool()
csh_tool = CshTool()
shell_tool = ShellTool()