Files
python-sdk/pkg/hanzo-tools-shell/hanzo_tools/shell/shell_tools.py
T
zeekay 8d30e5a9e3 feat(shell): remove cmd, add ksh/tcsh/csh support, shell-first exposure
- Remove 'cmd' from default TOOLS list - only detected shell exposed
- Add KshTool, TcshTool, CshTool for enterprise Unix shells
- Fallback to ShellTool (smart auto-detect) if shell detection fails
- Support ksh93, pdksh, mksh as aliases for ksh
- Bump hanzo-tools-shell to 0.6.1, hanzo-mcp to 0.10.37
2026-01-11 19:22:32 -08:00

683 lines
24 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 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."""
force_shell = os.environ.get("HANZO_MCP_FORCE_SHELL")
if force_shell:
return force_shell
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()