mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
feat(shell): add bash tool and shell parameter for runtime switching
- Add shell parameter to zsh/bash/shell tools for runtime shell switching
- Add BashTool for bash-specific scripts
- Usage: zsh("cmd", shell="bash") or bash("cmd")
- Bump hanzo-tools-shell to v0.3.2
This commit is contained in:
@@ -32,7 +32,7 @@ from hanzo_tools.shell.dag_tool import DagTool, create_dag_tool
|
||||
from hanzo_tools.shell.npx_tool import NpxTool, npx_tool
|
||||
from hanzo_tools.shell.truncate import truncate_lines, estimate_tokens, truncate_response
|
||||
from hanzo_tools.shell.uvx_tool import UvxTool, uvx_tool
|
||||
from hanzo_tools.shell.zsh_tool import ZshTool, ShellTool, zsh_tool, shell_tool
|
||||
from hanzo_tools.shell.zsh_tool import ZshTool, ShellTool, BashTool, zsh_tool, shell_tool, bash_tool
|
||||
|
||||
# Convenience tools
|
||||
from hanzo_tools.shell.open_tool import OpenTool, open_tool
|
||||
@@ -53,7 +53,8 @@ from hanzo_tools.shell.base_process import (
|
||||
|
||||
# Tools list for entry point discovery
|
||||
# Note: ZshTool now includes full DAG functionality. DagTool kept for backwards compatibility.
|
||||
TOOLS = [ZshTool, ShellTool, PsTool, NpxTool, UvxTool, OpenTool, CurlTool, JqTool, WgetTool]
|
||||
# BashTool available for bash-specific scripts.
|
||||
TOOLS = [ZshTool, BashTool, ShellTool, PsTool, NpxTool, UvxTool, OpenTool, CurlTool, JqTool, WgetTool]
|
||||
|
||||
__all__ = [
|
||||
# Base classes
|
||||
@@ -75,6 +76,8 @@ __all__ = [
|
||||
"zsh_tool",
|
||||
"ShellTool",
|
||||
"shell_tool",
|
||||
"BashTool",
|
||||
"bash_tool",
|
||||
# Convenience tools
|
||||
"OpenTool",
|
||||
"open_tool",
|
||||
|
||||
@@ -172,22 +172,27 @@ Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
cwd: Optional[str] = None,
|
||||
env: Optional[Dict[str, str]] = None,
|
||||
timeout: int = 120,
|
||||
shell: Optional[str] = None,
|
||||
) -> DagResult:
|
||||
"""Run a shell command with auto-backgrounding on timeout."""
|
||||
start_time = datetime.now()
|
||||
node_id = f"shell_{id(cmd)}"
|
||||
process_manager = ProcessManager()
|
||||
|
||||
# Use specified shell or default
|
||||
use_shell = shell or self._shell
|
||||
|
||||
try:
|
||||
run_env = os.environ.copy()
|
||||
if env:
|
||||
run_env.update(env)
|
||||
|
||||
process_id = f"zsh_{uuid.uuid4().hex[:8]}"
|
||||
shell_name = os.path.basename(use_shell)
|
||||
process_id = f"{shell_name}_{uuid.uuid4().hex[:8]}"
|
||||
log_file = await process_manager.create_log_file(process_id)
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
self._shell,
|
||||
use_shell,
|
||||
"-c",
|
||||
cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
@@ -217,10 +222,10 @@ Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
duration = int((datetime.now() - start_time).total_seconds() * 1000)
|
||||
|
||||
async with aiofiles.open(log_file, "w") as f:
|
||||
await f.write(f"[zsh] Command backgrounded after {timeout}s timeout\n")
|
||||
await f.write(f"[zsh] Command: {cmd}\n")
|
||||
await f.write(f"[zsh] PID: {proc.pid}\n")
|
||||
await f.write(f"[zsh] Started: {start_time.isoformat()}\n")
|
||||
await f.write(f"[{shell_name}] Command backgrounded after {timeout}s timeout\n")
|
||||
await f.write(f"[{shell_name}] Command: {cmd}\n")
|
||||
await f.write(f"[{shell_name}] PID: {proc.pid}\n")
|
||||
await f.write(f"[{shell_name}] Started: {start_time.isoformat()}\n")
|
||||
await f.write("-" * 40 + "\n")
|
||||
|
||||
process_manager.add_process(process_id, proc, str(log_file))
|
||||
@@ -272,7 +277,7 @@ Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
)
|
||||
|
||||
await proc.wait()
|
||||
await f.write(f"\n[zsh] Process exited with code {proc.returncode}\n")
|
||||
await f.write(f"\n[shell] Process exited with code {proc.returncode}\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -328,11 +333,12 @@ Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
cwd: Optional[str],
|
||||
env: Optional[Dict[str, str]],
|
||||
timeout: int,
|
||||
shell: Optional[str] = None,
|
||||
) -> DagResult:
|
||||
"""Execute a single DAG node."""
|
||||
|
||||
if isinstance(cmd, str):
|
||||
return await self._run_shell(cmd, cwd, env, timeout)
|
||||
return await self._run_shell(cmd, cwd, env, timeout, shell)
|
||||
|
||||
if isinstance(cmd, dict):
|
||||
if "tool" in cmd:
|
||||
@@ -340,7 +346,7 @@ Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
|
||||
if "parallel" in cmd:
|
||||
parallel_cmds = cmd["parallel"]
|
||||
tasks = [self._execute_node(c, ctx, cwd, env, timeout) for c in parallel_cmds]
|
||||
tasks = [self._execute_node(c, ctx, cwd, env, timeout, shell) for c in parallel_cmds]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
combined_stdout = []
|
||||
@@ -430,6 +436,7 @@ Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
command: Optional[str] = None,
|
||||
commands: Optional[List[Command]] = None,
|
||||
parallel: bool = False,
|
||||
shell: Optional[str] = None,
|
||||
cwd: Optional[str] = None,
|
||||
env: Optional[Dict[str, str]] = None,
|
||||
timeout: int = 120,
|
||||
@@ -443,6 +450,7 @@ Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
command: Single command string (simple mode)
|
||||
commands: List of commands for DAG mode
|
||||
parallel: Run all commands concurrently
|
||||
shell: Shell to use (default: zsh, options: bash, sh, fish, etc.)
|
||||
cwd: Working directory
|
||||
env: Additional environment variables
|
||||
timeout: Per-command timeout in seconds
|
||||
@@ -454,7 +462,7 @@ Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
|
||||
# Single command mode
|
||||
if command and not commands:
|
||||
result = await self._run_shell(command, cwd, env, timeout)
|
||||
result = await self._run_shell(command, cwd, env, timeout, shell)
|
||||
if result.status == NodeStatus.SUCCESS:
|
||||
return result.stdout if result.stdout else "(no output)"
|
||||
return f"{result.stdout}\n[stderr] {result.stderr}" if result.stderr else result.stdout
|
||||
@@ -466,7 +474,7 @@ Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
results: List[DagResult] = []
|
||||
|
||||
if parallel:
|
||||
tasks = [self._execute_node(cmd, ctx, cwd, env, timeout) for cmd in commands]
|
||||
tasks = [self._execute_node(cmd, ctx, cwd, env, timeout, shell) for cmd in commands]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
results = [
|
||||
r
|
||||
@@ -485,7 +493,7 @@ Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
]
|
||||
else:
|
||||
for cmd in commands:
|
||||
result = await self._execute_node(cmd, ctx, cwd, env, timeout)
|
||||
result = await self._execute_node(cmd, ctx, cwd, env, timeout, shell)
|
||||
results.append(result)
|
||||
|
||||
if strict and result.status == NodeStatus.FAILED:
|
||||
@@ -507,6 +515,9 @@ Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
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,
|
||||
shell: Annotated[
|
||||
Optional[str], Field(description="Shell to use: zsh, bash, sh, fish (default: zsh)", default=None)
|
||||
] = None,
|
||||
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=120)] = 120,
|
||||
@@ -519,6 +530,7 @@ Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
command=command,
|
||||
commands=commands,
|
||||
parallel=parallel,
|
||||
shell=shell,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
timeout=timeout,
|
||||
@@ -557,6 +569,74 @@ shell(["a", "b"], parallel=True) # Parallel"""
|
||||
|
||||
@mcp_server.tool(name=self.name, description=self.description)
|
||||
async def shell(
|
||||
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,
|
||||
shell: Annotated[
|
||||
Optional[str], Field(description="Shell to use: zsh, bash, sh, fish", default=None)
|
||||
] = None,
|
||||
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=120)] = 120,
|
||||
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=shell,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
timeout=timeout,
|
||||
strict=strict,
|
||||
quiet=quiet,
|
||||
)
|
||||
|
||||
|
||||
class BashTool(ZshTool):
|
||||
"""Bash shell with DAG support - alias for ZshTool with bash default."""
|
||||
|
||||
name = "bash"
|
||||
|
||||
def _resolve_shell(self) -> str:
|
||||
"""Use bash as default shell."""
|
||||
force_shell = os.environ.get("HANZO_MCP_FORCE_SHELL")
|
||||
if force_shell:
|
||||
return force_shell
|
||||
|
||||
# Prefer bash
|
||||
bash_path = shutil.which("bash")
|
||||
if bash_path:
|
||||
return bash_path
|
||||
return "sh"
|
||||
|
||||
@property
|
||||
@override
|
||||
def description(self) -> str:
|
||||
return """Bash shell with DAG execution support.
|
||||
|
||||
Same capabilities as zsh tool but defaults to bash.
|
||||
Use for bash-specific scripts or when zsh is unavailable.
|
||||
|
||||
Usage:
|
||||
bash("ls -la")
|
||||
bash(["cmd1", "cmd2"]) # Serial
|
||||
bash([...], parallel=True) # Parallel"""
|
||||
|
||||
@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(
|
||||
command: Annotated[
|
||||
Optional[str], Field(description="Single command to execute", default=None)
|
||||
] = None,
|
||||
@@ -587,3 +667,4 @@ shell(["a", "b"], parallel=True) # Parallel"""
|
||||
# Create tool instances
|
||||
zsh_tool = ZshTool()
|
||||
shell_tool = ShellTool()
|
||||
bash_tool = BashTool()
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hanzo-tools-shell"
|
||||
version = "0.3.1"
|
||||
version = "0.3.2"
|
||||
description = "Shell/command execution tools - zsh (with DAG), ps, npx, uvx, open, curl, jq, wget"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
Reference in New Issue
Block a user