mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
chore: gitignore build/ artifacts, remove tracked build files
This commit is contained in:
@@ -11,6 +11,7 @@ __pycache__
|
||||
.venv
|
||||
|
||||
# Build
|
||||
build/
|
||||
dist
|
||||
_dev
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
"""Hanzo Tools namespace package."""
|
||||
|
||||
__path__ = __import__("pkgutil").extend_path(__path__, __name__)
|
||||
@@ -1,362 +0,0 @@
|
||||
"""Shell tools package for Hanzo AI.
|
||||
|
||||
Minimal, orthogonal shell execution with uvloop for high performance.
|
||||
|
||||
SHELL DETECTION:
|
||||
By default, only the user's active shell is exposed to MCP. This avoids
|
||||
cluttering the tool list with shells the user doesn't use.
|
||||
|
||||
Detection order:
|
||||
1. HANZO_MCP_SHELL env var (e.g., "zsh", "bash", "fish")
|
||||
2. HANZO_MCP_FORCE_SHELL env var (e.g., "/opt/homebrew/bin/zsh")
|
||||
3. --shell CLI flag (e.g., --shell=/path/to/fish)
|
||||
4. Invoking shell (the shell that launched this process)
|
||||
5. Login shell (from passwd/Directory Services)
|
||||
6. $SHELL environment variable
|
||||
|
||||
On macOS with Homebrew, this will prefer /opt/homebrew/bin/zsh over /bin/zsh.
|
||||
If detection fails, falls back to 'shell' tool (smart auto-detect).
|
||||
|
||||
Core tools:
|
||||
- <detected>: Your active shell (zsh, bash, fish, or dash)
|
||||
- ps: Process management (list, kill, logs)
|
||||
|
||||
HTTP/Data tools:
|
||||
- curl: HTTP client without shell escaping issues
|
||||
- jq: JSON processor without shell escaping issues
|
||||
- wget: File/site downloads with mirroring support
|
||||
|
||||
Convenience tools:
|
||||
- npx: Node package execution with auto-backgrounding
|
||||
- uvx: Python package execution with auto-backgrounding
|
||||
- open: Open files/URLs in system apps
|
||||
|
||||
Auto-backgrounding: Commands exceeding timeout automatically background.
|
||||
Configure via: export HANZO_AUTO_BACKGROUND_TIMEOUT=30 (default: 30s)
|
||||
export HANZO_AUTO_BACKGROUND_TIMEOUT=0 (disabled)
|
||||
Use ps --logs <id> to view, ps --kill <id> to stop.
|
||||
|
||||
Performance: Uses uvloop on macOS/Linux for 2-4x faster async I/O.
|
||||
Falls back to standard asyncio on Windows.
|
||||
"""
|
||||
|
||||
# Use hanzo-async for unified async I/O and uvloop configuration
|
||||
from hanzo_async import using_uvloop, configure_loop
|
||||
|
||||
# Auto-configure uvloop on import
|
||||
configure_loop()
|
||||
_using_uvloop = using_uvloop()
|
||||
|
||||
from mcp.server import FastMCP
|
||||
|
||||
from hanzo_tools.core import BaseTool, ToolRegistry, PermissionManager
|
||||
from hanzo_tools.shell.jq_tool import JqTool
|
||||
from hanzo_tools.shell.ps_tool import PsTool, ps_tool
|
||||
|
||||
# Core tools
|
||||
from hanzo_tools.shell.cmd_tool import CmdNode, CmdTool, CmdResult, NodeStatus, cmd_tool, create_cmd_tool
|
||||
|
||||
# Backwards compatibility - DagTool is now CmdTool
|
||||
from hanzo_tools.shell.dag_tool import DagNode, DagTool, DagResult, 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
|
||||
|
||||
# HTTP/Data tools (no shell escaping issues)
|
||||
from hanzo_tools.shell.curl_tool import CurlTool
|
||||
|
||||
# Convenience tools
|
||||
from hanzo_tools.shell.open_tool import OpenTool, open_tool
|
||||
|
||||
# HIP-0300: Unified proc tool
|
||||
from hanzo_tools.shell.proc_tool import ProcTool, proc_tool
|
||||
from hanzo_tools.shell.shellflow import parse as parse_shellflow, compile as compile_shellflow
|
||||
from hanzo_tools.shell.wget_tool import WgetTool
|
||||
from hanzo_tools.shell.shell_tools import (
|
||||
CshTool,
|
||||
KshTool,
|
||||
ZshTool,
|
||||
BashTool,
|
||||
DashTool,
|
||||
FishTool,
|
||||
TcshTool,
|
||||
ShellTool,
|
||||
csh_tool,
|
||||
ksh_tool,
|
||||
zsh_tool,
|
||||
bash_tool,
|
||||
dash_tool,
|
||||
fish_tool,
|
||||
tcsh_tool,
|
||||
shell_tool,
|
||||
)
|
||||
|
||||
# Base classes
|
||||
from hanzo_tools.shell.base_process import (
|
||||
AUTO_BACKGROUND_TIMEOUT,
|
||||
ShellExecutor,
|
||||
BaseBinaryTool,
|
||||
BaseScriptTool,
|
||||
ProcessManager,
|
||||
BaseProcessTool,
|
||||
AutoBackgroundExecutor,
|
||||
get_shell_executor,
|
||||
)
|
||||
|
||||
# Shell detection
|
||||
from hanzo_tools.shell.shell_detect import (
|
||||
SUPPORTED_SHELLS,
|
||||
ShellInfo,
|
||||
detect_shells,
|
||||
get_active_shell,
|
||||
clear_shell_cache,
|
||||
resolve_shell_path,
|
||||
get_shell_tool_class,
|
||||
get_cached_active_shell,
|
||||
)
|
||||
|
||||
# Session management
|
||||
from hanzo_tools.shell.session_storage import (
|
||||
SessionInfo,
|
||||
SessionStorage,
|
||||
clear_all_sessions,
|
||||
cleanup_expired_sessions,
|
||||
)
|
||||
|
||||
|
||||
def _get_detected_shell_tools() -> list:
|
||||
"""
|
||||
Get shell tools list with only the detected active shell.
|
||||
|
||||
This reduces tool clutter by only exposing the user's configured shell.
|
||||
Override with:
|
||||
- HANZO_MCP_SHELL=bash (shell name)
|
||||
- HANZO_MCP_FORCE_SHELL=/path/to/shell (full path)
|
||||
- HANZO_MCP_ALL_SHELLS=1 (expose all shells)
|
||||
"""
|
||||
import os
|
||||
|
||||
# Allow exposing all shells via env var (for debugging/testing)
|
||||
if os.environ.get("HANZO_MCP_ALL_SHELLS", "").lower() in ("1", "true", "yes"):
|
||||
return [
|
||||
ZshTool,
|
||||
BashTool,
|
||||
FishTool,
|
||||
DashTool,
|
||||
KshTool,
|
||||
TcshTool,
|
||||
CshTool,
|
||||
PsTool,
|
||||
NpxTool,
|
||||
UvxTool,
|
||||
OpenTool,
|
||||
CurlTool,
|
||||
JqTool,
|
||||
WgetTool,
|
||||
]
|
||||
|
||||
# Detect active shell
|
||||
shell_name, shell_path = get_cached_active_shell()
|
||||
|
||||
# Get the appropriate shell tool class
|
||||
shell_tool_class = get_shell_tool_class(shell_name)
|
||||
|
||||
# Base tools (always included) - no cmd, just the detected shell
|
||||
tools = [PsTool, NpxTool, UvxTool, OpenTool, CurlTool, JqTool, WgetTool]
|
||||
|
||||
# Add detected shell tool, or fall back to ShellTool (smart auto-detect)
|
||||
if shell_tool_class:
|
||||
tools.insert(0, shell_tool_class) # Put detected shell first
|
||||
else:
|
||||
tools.insert(0, ShellTool) # Fallback to smart shell
|
||||
|
||||
return tools
|
||||
|
||||
|
||||
# Tools list for entry point discovery
|
||||
# Only the detected shell is included (not all 4 shell variants)
|
||||
# Override with HANZO_MCP_ALL_SHELLS=1 to expose all shells
|
||||
TOOLS = _get_detected_shell_tools()
|
||||
|
||||
__all__ = [
|
||||
# uvloop status
|
||||
"_using_uvloop",
|
||||
# HIP-0300: Unified proc tool
|
||||
"ProcTool",
|
||||
"proc_tool",
|
||||
# Base classes
|
||||
"ProcessManager",
|
||||
"AutoBackgroundExecutor",
|
||||
"ShellExecutor",
|
||||
"get_shell_executor",
|
||||
"BaseProcessTool",
|
||||
"BaseBinaryTool",
|
||||
"BaseScriptTool",
|
||||
# Session management
|
||||
"SessionStorage",
|
||||
"SessionInfo",
|
||||
"cleanup_expired_sessions",
|
||||
"clear_all_sessions",
|
||||
# Utilities
|
||||
"truncate_response",
|
||||
"truncate_lines",
|
||||
"estimate_tokens",
|
||||
# Shell detection
|
||||
"ShellInfo",
|
||||
"detect_shells",
|
||||
"get_active_shell",
|
||||
"get_cached_active_shell",
|
||||
"clear_shell_cache",
|
||||
"get_shell_tool_class",
|
||||
"resolve_shell_path",
|
||||
"SUPPORTED_SHELLS",
|
||||
# Legacy tools
|
||||
"CmdTool",
|
||||
"CmdResult",
|
||||
"CmdNode",
|
||||
"NodeStatus",
|
||||
"cmd_tool",
|
||||
"create_cmd_tool",
|
||||
"PsTool",
|
||||
"ps_tool",
|
||||
"ZshTool",
|
||||
"zsh_tool",
|
||||
"ShellTool",
|
||||
"shell_tool",
|
||||
"BashTool",
|
||||
"bash_tool",
|
||||
"FishTool",
|
||||
"fish_tool",
|
||||
"DashTool",
|
||||
"dash_tool",
|
||||
"KshTool",
|
||||
"ksh_tool",
|
||||
"TcshTool",
|
||||
"tcsh_tool",
|
||||
"CshTool",
|
||||
"csh_tool",
|
||||
# Backwards compatibility
|
||||
"DagTool",
|
||||
"DagResult",
|
||||
"DagNode",
|
||||
"create_dag_tool",
|
||||
# Convenience tools
|
||||
"OpenTool",
|
||||
"open_tool",
|
||||
"NpxTool",
|
||||
"npx_tool",
|
||||
"UvxTool",
|
||||
"uvx_tool",
|
||||
# HTTP/Data tools
|
||||
"CurlTool",
|
||||
"JqTool",
|
||||
"WgetTool",
|
||||
# Registration
|
||||
"TOOLS",
|
||||
"get_shell_tools",
|
||||
"register_shell_tools",
|
||||
]
|
||||
|
||||
|
||||
def get_shell_tools(
|
||||
permission_manager: PermissionManager,
|
||||
all_tools: dict[str, BaseTool] | None = None,
|
||||
shell_override: str | None = None,
|
||||
) -> list[BaseTool]:
|
||||
"""Create instances of shell tools.
|
||||
|
||||
Only the user's detected/configured shell is included to reduce tool clutter.
|
||||
|
||||
Args:
|
||||
permission_manager: Permission manager for access control
|
||||
all_tools: Dict of all registered tools (for tool invocations)
|
||||
shell_override: Override shell (name like "zsh" or path like "/path/to/fish")
|
||||
|
||||
Returns:
|
||||
List of shell tool instances
|
||||
"""
|
||||
import os
|
||||
|
||||
# Set permission manager for convenience tools
|
||||
npx_tool.permission_manager = permission_manager
|
||||
uvx_tool.permission_manager = permission_manager
|
||||
|
||||
# Base tools list (no cmd - just the detected shell and utilities)
|
||||
tools = [
|
||||
ps_tool, # Process management
|
||||
npx_tool, # Node packages
|
||||
uvx_tool, # Python packages
|
||||
open_tool, # Open files/URLs
|
||||
]
|
||||
|
||||
# Check for all-shells mode
|
||||
if os.environ.get("HANZO_MCP_ALL_SHELLS", "").lower() in ("1", "true", "yes"):
|
||||
tools.insert(0, ZshTool(tools=all_tools or {}))
|
||||
tools.insert(1, BashTool(tools=all_tools or {}))
|
||||
tools.insert(2, FishTool(tools=all_tools or {}))
|
||||
tools.insert(3, DashTool(tools=all_tools or {}))
|
||||
tools.insert(4, KshTool(tools=all_tools or {}))
|
||||
tools.insert(5, TcshTool(tools=all_tools or {}))
|
||||
tools.insert(6, CshTool(tools=all_tools or {}))
|
||||
return tools
|
||||
|
||||
# Determine which shell to expose
|
||||
if shell_override:
|
||||
# Handle path override (e.g., --shell=/opt/homebrew/bin/fish)
|
||||
if "/" in shell_override:
|
||||
shell_name = os.path.basename(shell_override)
|
||||
os.environ["HANZO_MCP_FORCE_SHELL"] = shell_override
|
||||
else:
|
||||
shell_name = shell_override.lower()
|
||||
else:
|
||||
shell_name, _ = get_cached_active_shell()
|
||||
|
||||
# Create and add detected shell tool, or fall back to ShellTool
|
||||
shell_tool_class = get_shell_tool_class(shell_name)
|
||||
if shell_tool_class:
|
||||
shell_instance = shell_tool_class(tools=all_tools or {})
|
||||
tools.insert(0, shell_instance) # Put detected shell first
|
||||
else:
|
||||
tools.insert(0, ShellTool(tools=all_tools or {})) # Fallback to smart shell
|
||||
|
||||
return tools
|
||||
|
||||
|
||||
def register_shell_tools(
|
||||
mcp_server: FastMCP,
|
||||
permission_manager: PermissionManager,
|
||||
all_tools: dict[str, BaseTool] | None = None,
|
||||
shell_override: str | None = None,
|
||||
) -> list[BaseTool]:
|
||||
"""Register shell tools with the MCP server.
|
||||
|
||||
Args:
|
||||
mcp_server: The FastMCP server instance
|
||||
permission_manager: Permission manager for access control
|
||||
all_tools: Dict of all registered tools (for cmd tool invocations)
|
||||
shell_override: Override shell (name like "zsh" or path like "/path/to/fish")
|
||||
|
||||
Returns:
|
||||
List of registered tools
|
||||
"""
|
||||
tools = get_shell_tools(permission_manager, all_tools, shell_override)
|
||||
ToolRegistry.register_tools(mcp_server, tools)
|
||||
return tools
|
||||
|
||||
|
||||
def register_tools(mcp_server: FastMCP, **kwargs) -> list[BaseTool]:
|
||||
"""Register all shell tools with the MCP server.
|
||||
|
||||
This is the standard entry point called by the tool discovery system.
|
||||
|
||||
Supports:
|
||||
- shell_override: Override detected shell (name or path)
|
||||
- permission_manager: Permission manager instance
|
||||
- all_tools: Dict of all registered tools
|
||||
"""
|
||||
from hanzo_tools.core import PermissionManager
|
||||
|
||||
permission_manager = kwargs.get("permission_manager") or PermissionManager()
|
||||
all_tools = kwargs.get("all_tools")
|
||||
shell_override = kwargs.get("shell_override") or kwargs.get("shell")
|
||||
return register_shell_tools(mcp_server, permission_manager, all_tools, shell_override)
|
||||
@@ -1,663 +0,0 @@
|
||||
"""Base classes for process execution tools.
|
||||
|
||||
All process execution uses asyncio.subprocess for consistency.
|
||||
All file I/O uses hanzo_async for non-blocking operations with uvloop support.
|
||||
|
||||
Auto-backgrounding timeout can be configured via:
|
||||
export HANZO_AUTO_BACKGROUND_TIMEOUT=30 # seconds (default: 30)
|
||||
export HANZO_AUTO_BACKGROUND_TIMEOUT=0 # disable auto-backgrounding
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
import asyncio
|
||||
import tempfile
|
||||
from abc import abstractmethod
|
||||
from typing import Any, Dict, List, Tuple, Optional, override
|
||||
from pathlib import Path
|
||||
|
||||
from hanzo_async import mkdir, write_file, append_file
|
||||
|
||||
from hanzo_tools.core import BaseTool, PermissionManager
|
||||
from hanzo_tools.shell.truncate import truncate_response
|
||||
|
||||
# Configurable auto-background timeout (seconds)
|
||||
# Set via HANZO_AUTO_BACKGROUND_TIMEOUT env var
|
||||
# 0 or negative = disabled (never auto-background, use 24h timeout)
|
||||
_raw_timeout = float(os.getenv("HANZO_AUTO_BACKGROUND_TIMEOUT", "30"))
|
||||
AUTO_BACKGROUND_TIMEOUT = _raw_timeout if _raw_timeout > 0 else 86400.0 # 24 hours if disabled
|
||||
|
||||
|
||||
class ProcessManager:
|
||||
"""Singleton manager for background processes.
|
||||
|
||||
All processes are asyncio.subprocess.Process instances.
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
_processes: Dict[str, asyncio.subprocess.Process] = {}
|
||||
_logs: Dict[str, str] = {}
|
||||
_log_dir = Path(tempfile.gettempdir()) / "hanzo_mcp_logs"
|
||||
_initialized: bool = False
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
async def _ensure_log_dir(self) -> None:
|
||||
"""Ensure log directory exists (async-safe)."""
|
||||
if not self._initialized:
|
||||
await mkdir(self._log_dir, parents=True, exist_ok=True)
|
||||
self._initialized = True
|
||||
|
||||
def add_process(self, process_id: str, process: asyncio.subprocess.Process, log_file: str) -> None:
|
||||
"""Add a process to track."""
|
||||
self._processes[process_id] = process
|
||||
self._logs[process_id] = log_file
|
||||
|
||||
def get_process(self, process_id: str) -> Optional[asyncio.subprocess.Process]:
|
||||
"""Get a tracked process."""
|
||||
return self._processes.get(process_id)
|
||||
|
||||
def remove_process(self, process_id: str) -> None:
|
||||
"""Remove a process from tracking."""
|
||||
self._processes.pop(process_id, None)
|
||||
self._logs.pop(process_id, None)
|
||||
|
||||
def list_processes(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""List all tracked processes.
|
||||
|
||||
Note: Uses list(items()) to create snapshot before cleanup,
|
||||
preventing RuntimeError from dict modification during iteration.
|
||||
Completed processes are cleaned up after being reported.
|
||||
"""
|
||||
result = {}
|
||||
for pid, proc in list(self._processes.items()):
|
||||
is_running = proc.returncode is None
|
||||
|
||||
if is_running:
|
||||
result[pid] = {
|
||||
"pid": proc.pid,
|
||||
"running": True,
|
||||
"log_file": self._logs.get(pid),
|
||||
}
|
||||
else:
|
||||
result[pid] = {
|
||||
"pid": proc.pid,
|
||||
"running": False,
|
||||
"return_code": proc.returncode,
|
||||
"log_file": self._logs.get(pid),
|
||||
}
|
||||
self.remove_process(pid)
|
||||
return result
|
||||
|
||||
def get_log_file(self, process_id: str) -> Optional[Path]:
|
||||
"""Get log file path for a process."""
|
||||
log_path = self._logs.get(process_id)
|
||||
return Path(log_path) if log_path else None
|
||||
|
||||
@property
|
||||
def log_dir(self) -> Path:
|
||||
"""Get the log directory."""
|
||||
return self._log_dir
|
||||
|
||||
async def create_log_file(self, process_id: str) -> Path:
|
||||
"""Create a log file for a process (async-safe)."""
|
||||
await self._ensure_log_dir()
|
||||
log_file = self._log_dir / f"{process_id}.log"
|
||||
await write_file(log_file, "")
|
||||
return log_file
|
||||
|
||||
def mark_completed(self, process_id: str, return_code: int) -> None:
|
||||
"""Mark a process as completed."""
|
||||
self.remove_process(process_id)
|
||||
|
||||
|
||||
class AutoBackgroundExecutor:
|
||||
"""Executor that automatically backgrounds long-running processes.
|
||||
|
||||
IMPORTANT: Always backgrounds after AUTO_BACKGROUND_TIMEOUT to keep
|
||||
the agent loop responsive. Longer timeouts only affect the background
|
||||
process lifetime, not the foreground wait time.
|
||||
|
||||
Configure via:
|
||||
export HANZO_AUTO_BACKGROUND_TIMEOUT=30 # 30s timeout (default)
|
||||
export HANZO_AUTO_BACKGROUND_TIMEOUT=0 # disabled (never auto-background)
|
||||
"""
|
||||
|
||||
DEFAULT_TIMEOUT = AUTO_BACKGROUND_TIMEOUT
|
||||
MAX_FOREGROUND_TIMEOUT = AUTO_BACKGROUND_TIMEOUT
|
||||
|
||||
def __init__(self, process_manager: ProcessManager, timeout: float = DEFAULT_TIMEOUT):
|
||||
"""Initialize the auto-background executor."""
|
||||
self.process_manager = process_manager
|
||||
self.default_timeout = timeout
|
||||
|
||||
async def execute_with_auto_background(
|
||||
self,
|
||||
cmd_args: list[str],
|
||||
tool_name: str,
|
||||
cwd: Optional[Path] = None,
|
||||
env: Optional[dict[str, str]] = None,
|
||||
timeout: Optional[float] = None,
|
||||
) -> Tuple[str, bool, Optional[str]]:
|
||||
"""Execute a command with automatic backgrounding if it takes too long.
|
||||
|
||||
Returns:
|
||||
Tuple of (output/status, was_backgrounded, process_id)
|
||||
|
||||
Note: Always backgrounds after MAX_FOREGROUND_TIMEOUT (30s) to keep
|
||||
the agent loop responsive. The passed timeout is stored for reference
|
||||
but doesn't extend foreground wait time.
|
||||
"""
|
||||
# ALWAYS cap at MAX_FOREGROUND_TIMEOUT to keep agent loop responsive
|
||||
# Longer timeouts only affect background process, not foreground wait
|
||||
requested_timeout = timeout if timeout is not None else self.default_timeout
|
||||
effective_timeout = min(requested_timeout, self.MAX_FOREGROUND_TIMEOUT)
|
||||
|
||||
# Fast path for tests - still async but with shorter timeout
|
||||
if os.getenv("HANZO_MCP_FAST_TESTS") == "1":
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd_args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=str(cwd) if cwd else None,
|
||||
env=env,
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30)
|
||||
if proc.returncode != 0:
|
||||
return (
|
||||
f"Command failed with exit code {proc.returncode}:\n"
|
||||
f"{stdout.decode('utf-8', errors='replace')}"
|
||||
f"{stderr.decode('utf-8', errors='replace')}",
|
||||
False,
|
||||
None,
|
||||
)
|
||||
return stdout.decode("utf-8", errors="replace"), False, None
|
||||
except asyncio.TimeoutError:
|
||||
return "Command timed out in test mode", False, None
|
||||
except Exception as e:
|
||||
return f"Error executing command: {e}", False, None
|
||||
|
||||
# Generate process ID
|
||||
process_id = f"{tool_name}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Create log file
|
||||
log_file = await self.process_manager.create_log_file(process_id)
|
||||
|
||||
# Start the process
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd_args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
)
|
||||
|
||||
# Track in process manager
|
||||
self.process_manager.add_process(process_id, process, str(log_file))
|
||||
|
||||
# Try to wait for completion with timeout
|
||||
start_time = time.time()
|
||||
output_lines = []
|
||||
|
||||
try:
|
||||
|
||||
async def read_output():
|
||||
if process.stdout:
|
||||
async for line in process.stdout:
|
||||
line_str = line.decode("utf-8", errors="replace")
|
||||
output_lines.append(line_str)
|
||||
await append_file(log_file, line_str)
|
||||
|
||||
async def wait_for_process():
|
||||
return await process.wait()
|
||||
|
||||
read_task = asyncio.create_task(read_output())
|
||||
wait_task = asyncio.create_task(wait_for_process())
|
||||
|
||||
done, pending = await asyncio.wait(
|
||||
[read_task, wait_task],
|
||||
timeout=effective_timeout,
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
if wait_task in done:
|
||||
return_code = await wait_task
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(read_task, timeout=0.5)
|
||||
except asyncio.TimeoutError:
|
||||
read_task.cancel()
|
||||
try:
|
||||
await read_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
self.process_manager.mark_completed(process_id, return_code)
|
||||
|
||||
output = "".join(output_lines)
|
||||
if return_code != 0:
|
||||
return (
|
||||
f"Command failed with exit code {return_code}:\n{output}",
|
||||
False,
|
||||
None,
|
||||
)
|
||||
else:
|
||||
return output, False, None
|
||||
|
||||
else:
|
||||
# Timeout - background the process
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
|
||||
asyncio.create_task(self._background_reader(process, process_id, log_file))
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
partial_output = "".join(output_lines[-50:])
|
||||
|
||||
return (
|
||||
f"Process automatically backgrounded after {elapsed:.1f}s\n"
|
||||
f"Process ID: {process_id}\n"
|
||||
f"Log file: {log_file}\n\n"
|
||||
f"Use 'ps --logs {process_id}' to view full output\n"
|
||||
f"Use 'ps --kill {process_id}' to stop the process\n\n"
|
||||
f"=== Last output ===\n{partial_output}",
|
||||
True,
|
||||
process_id,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.process_manager.mark_completed(process_id, -1)
|
||||
return f"Error executing command: {str(e)}", False, None
|
||||
|
||||
async def _background_reader(self, process, process_id: str, log_file: Path):
|
||||
"""Continue reading output from a backgrounded process."""
|
||||
try:
|
||||
if process.stdout:
|
||||
async for line in process.stdout:
|
||||
await append_file(log_file, line.decode("utf-8", errors="replace"))
|
||||
|
||||
return_code = await process.wait()
|
||||
self.process_manager.mark_completed(process_id, return_code)
|
||||
|
||||
await append_file(log_file, f"\n\n=== Process completed with exit code {return_code} ===\n")
|
||||
|
||||
except Exception as e:
|
||||
await append_file(log_file, f"\n\n=== Background reader error: {str(e)} ===\n")
|
||||
self.process_manager.mark_completed(process_id, -1)
|
||||
|
||||
|
||||
class BaseProcessTool(BaseTool):
|
||||
"""Base class for all process execution tools."""
|
||||
|
||||
def __init__(self, permission_manager: Optional[PermissionManager] = None):
|
||||
"""Initialize the process tool."""
|
||||
super().__init__()
|
||||
self.permission_manager = permission_manager
|
||||
self.process_manager = ProcessManager()
|
||||
self.auto_background_executor = AutoBackgroundExecutor(self.process_manager)
|
||||
|
||||
@abstractmethod
|
||||
def get_command_args(self, command: str, **kwargs) -> List[str]:
|
||||
"""Get the command arguments for subprocess."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_tool_name(self) -> str:
|
||||
"""Get the name of the tool being used."""
|
||||
pass
|
||||
|
||||
async def execute_sync(
|
||||
self,
|
||||
command: str,
|
||||
cwd: Optional[Path] = None,
|
||||
env: Optional[Dict[str, str]] = None,
|
||||
timeout: Optional[int] = None,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""Execute a command with auto-backgrounding after 2 minutes."""
|
||||
if self.permission_manager and cwd:
|
||||
if not self.permission_manager.is_path_allowed(str(cwd)):
|
||||
raise PermissionError(f"Access denied to path: {cwd}")
|
||||
|
||||
cmd_args = self.get_command_args(command, **kwargs)
|
||||
|
||||
process_env = os.environ.copy()
|
||||
if env:
|
||||
process_env.update(env)
|
||||
|
||||
output, was_backgrounded, process_id = await self.auto_background_executor.execute_with_auto_background(
|
||||
cmd_args=cmd_args,
|
||||
tool_name=self.get_tool_name(),
|
||||
cwd=cwd,
|
||||
env=process_env,
|
||||
timeout=float(timeout) if timeout is not None else None,
|
||||
)
|
||||
|
||||
if was_backgrounded:
|
||||
return output
|
||||
else:
|
||||
if output.startswith("Command failed"):
|
||||
raise RuntimeError(output)
|
||||
max_tokens = int(os.environ.get("HANZO_MCP_MAX_RESPONSE_TOKENS", "25000"))
|
||||
return truncate_response(
|
||||
output,
|
||||
max_tokens=max_tokens,
|
||||
truncation_message=f"\n\n[Command output truncated due to {max_tokens} token limit.]",
|
||||
)
|
||||
|
||||
async def execute_background(
|
||||
self,
|
||||
command: str,
|
||||
cwd: Optional[Path] = None,
|
||||
env: Optional[Dict[str, str]] = None,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute a command in the background."""
|
||||
if self.permission_manager and cwd:
|
||||
if not self.permission_manager.is_path_allowed(str(cwd)):
|
||||
raise PermissionError(f"Access denied to path: {cwd}")
|
||||
|
||||
process_id = f"{self.get_tool_name()}_{uuid.uuid4().hex[:8]}"
|
||||
log_file = self.process_manager.log_dir / f"{process_id}.log"
|
||||
|
||||
cmd_args = self.get_command_args(command, **kwargs)
|
||||
|
||||
process_env = os.environ.copy()
|
||||
if env:
|
||||
process_env.update(env)
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd_args,
|
||||
cwd=cwd,
|
||||
env=process_env,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
|
||||
self.process_manager.add_process(process_id, process, str(log_file))
|
||||
asyncio.create_task(self._write_output_to_log(process, log_file, process_id))
|
||||
|
||||
return {
|
||||
"process_id": process_id,
|
||||
"pid": process.pid,
|
||||
"log_file": str(log_file),
|
||||
"status": "started",
|
||||
}
|
||||
|
||||
async def _write_output_to_log(self, process: asyncio.subprocess.Process, log_file: Path, process_id: str) -> None:
|
||||
"""Write process output to log file in background."""
|
||||
try:
|
||||
# Clear log file
|
||||
await write_file(log_file, "")
|
||||
if process.stdout:
|
||||
async for line in process.stdout:
|
||||
await append_file(log_file, line.decode("utf-8", errors="replace"))
|
||||
|
||||
return_code = await process.wait()
|
||||
|
||||
await append_file(log_file, f"\n=== Process completed with exit code {return_code} ===\n")
|
||||
|
||||
self.process_manager.mark_completed(process_id, return_code)
|
||||
|
||||
except Exception as e:
|
||||
await append_file(log_file, f"\n=== Error: {str(e)} ===\n")
|
||||
self.process_manager.mark_completed(process_id, -1)
|
||||
|
||||
|
||||
class BaseBinaryTool(BaseProcessTool):
|
||||
"""Base class for binary execution tools (like npx, uvx)."""
|
||||
|
||||
@abstractmethod
|
||||
def get_binary_name(self) -> str:
|
||||
"""Get the name of the binary to execute."""
|
||||
pass
|
||||
|
||||
@override
|
||||
def get_command_args(self, command: str, **kwargs) -> List[str]:
|
||||
"""Get command arguments for binary execution."""
|
||||
cmd_args = [self.get_binary_name()]
|
||||
|
||||
if "flags" in kwargs:
|
||||
cmd_args.extend(kwargs["flags"])
|
||||
|
||||
cmd_args.append(command)
|
||||
|
||||
if "args" in kwargs:
|
||||
if isinstance(kwargs["args"], str):
|
||||
cmd_args.extend(kwargs["args"].split())
|
||||
else:
|
||||
cmd_args.extend(kwargs["args"])
|
||||
|
||||
return cmd_args
|
||||
|
||||
@override
|
||||
def get_tool_name(self) -> str:
|
||||
"""Get the tool name (same as binary name by default)."""
|
||||
return self.get_binary_name()
|
||||
|
||||
|
||||
class BaseScriptTool(BaseProcessTool):
|
||||
"""Base class for script execution tools (like bash, python)."""
|
||||
|
||||
@abstractmethod
|
||||
def get_interpreter(self) -> str:
|
||||
"""Get the interpreter to use."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_script_flags(self) -> List[str]:
|
||||
"""Get default flags for the interpreter."""
|
||||
pass
|
||||
|
||||
@override
|
||||
def get_command_args(self, command: str, **kwargs) -> List[str]:
|
||||
"""Get command arguments for script execution."""
|
||||
cmd_args = [self.get_interpreter()]
|
||||
cmd_args.extend(self.get_script_flags())
|
||||
cmd_args.append(command)
|
||||
return cmd_args
|
||||
|
||||
@override
|
||||
def get_tool_name(self) -> str:
|
||||
"""Get the tool name (interpreter name by default)."""
|
||||
return self.get_interpreter()
|
||||
|
||||
|
||||
# Shared singleton for shell execution
|
||||
_shell_executor: Optional["ShellExecutor"] = None
|
||||
|
||||
|
||||
class ShellExecutor:
|
||||
"""Shared async shell executor for all shell tools.
|
||||
|
||||
Ensures consistent auto-backgrounding behavior across dag, zsh, shell, bash tools.
|
||||
Uses a singleton pattern to share process management state.
|
||||
|
||||
Configure via:
|
||||
export HANZO_AUTO_BACKGROUND_TIMEOUT=30 # 30s timeout (default)
|
||||
export HANZO_AUTO_BACKGROUND_TIMEOUT=0 # disabled (never auto-background)
|
||||
"""
|
||||
|
||||
DEFAULT_TIMEOUT = AUTO_BACKGROUND_TIMEOUT
|
||||
|
||||
_instance: Optional["ShellExecutor"] = None
|
||||
|
||||
def __new__(cls) -> "ShellExecutor":
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self) -> None:
|
||||
if self._initialized:
|
||||
return
|
||||
self._initialized = True
|
||||
self._process_manager = ProcessManager()
|
||||
|
||||
@property
|
||||
def process_manager(self) -> ProcessManager:
|
||||
return self._process_manager
|
||||
|
||||
async def run_shell(
|
||||
self,
|
||||
command: str,
|
||||
shell: str,
|
||||
cwd: Optional[str] = None,
|
||||
env: Optional[Dict[str, str]] = None,
|
||||
timeout: float = DEFAULT_TIMEOUT,
|
||||
tool_name: str = "shell",
|
||||
) -> Tuple[str, str, int, bool, Optional[str]]:
|
||||
"""Run a shell command with auto-backgrounding on timeout.
|
||||
|
||||
Args:
|
||||
command: Shell command to execute
|
||||
shell: Shell binary path (e.g., /bin/zsh)
|
||||
cwd: Working directory
|
||||
env: Environment variables
|
||||
timeout: Timeout before auto-backgrounding (default: 30s)
|
||||
tool_name: Tool name for process ID prefix
|
||||
|
||||
Returns:
|
||||
Tuple of (stdout, stderr, exit_code, was_backgrounded, process_id)
|
||||
"""
|
||||
# Cap timeout at 30s for foreground wait - keep agent loop responsive
|
||||
effective_timeout = min(timeout, self.DEFAULT_TIMEOUT)
|
||||
|
||||
run_env = os.environ.copy()
|
||||
if env:
|
||||
run_env.update(env)
|
||||
|
||||
shell_name = os.path.basename(shell)
|
||||
process_id = f"{tool_name}_{uuid.uuid4().hex[:8]}"
|
||||
log_file = await self._process_manager.create_log_file(process_id)
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
shell,
|
||||
"-c",
|
||||
command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=cwd,
|
||||
env=run_env,
|
||||
# Prevent zombie processes - use start_new_session on Unix
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
stdout_chunks: list[bytes] = []
|
||||
stderr_chunks: list[bytes] = []
|
||||
|
||||
async def read_stdout():
|
||||
if proc.stdout:
|
||||
while True:
|
||||
chunk = await proc.stdout.read(8192)
|
||||
if not chunk:
|
||||
break
|
||||
stdout_chunks.append(chunk)
|
||||
|
||||
async def read_stderr():
|
||||
if proc.stderr:
|
||||
while True:
|
||||
chunk = await proc.stderr.read(8192)
|
||||
if not chunk:
|
||||
break
|
||||
stderr_chunks.append(chunk)
|
||||
|
||||
try:
|
||||
# Read streams and wait for process with timeout
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(read_stdout(), read_stderr(), proc.wait()), timeout=effective_timeout
|
||||
)
|
||||
|
||||
exit_code = proc.returncode or 0
|
||||
return (
|
||||
b"".join(stdout_chunks).decode("utf-8", errors="replace"),
|
||||
b"".join(stderr_chunks).decode("utf-8", errors="replace"),
|
||||
exit_code,
|
||||
False, # Not backgrounded
|
||||
None, # No process_id (completed)
|
||||
)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
# Background the process - don't kill it
|
||||
partial_stdout = b"".join(stdout_chunks).decode("utf-8", errors="replace")
|
||||
partial_stderr = b"".join(stderr_chunks).decode("utf-8", errors="replace")
|
||||
|
||||
await write_file(
|
||||
log_file,
|
||||
f"[{shell_name}] Command backgrounded after {effective_timeout}s timeout\n"
|
||||
f"[{shell_name}] Command: {command}\n"
|
||||
f"[{shell_name}] PID: {proc.pid}\n" + "-" * 40 + "\n"
|
||||
f"{partial_stdout}{partial_stderr}",
|
||||
)
|
||||
|
||||
self._process_manager.add_process(process_id, proc, str(log_file))
|
||||
asyncio.create_task(self._capture_background_output(proc, log_file, process_id))
|
||||
|
||||
return (
|
||||
f"[backgrounded] Process {process_id} (PID {proc.pid}) running in background.\n"
|
||||
f"Use: ps --logs {process_id} # view output\n"
|
||||
f"Use: ps --kill {process_id} # stop process",
|
||||
"",
|
||||
0,
|
||||
True, # Was backgrounded
|
||||
process_id,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Clean up on error - kill process if it exists
|
||||
try:
|
||||
if proc and proc.returncode is None:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
except Exception:
|
||||
pass
|
||||
return (
|
||||
"",
|
||||
str(e),
|
||||
1,
|
||||
False,
|
||||
None,
|
||||
)
|
||||
|
||||
async def _capture_background_output(
|
||||
self,
|
||||
proc: asyncio.subprocess.Process,
|
||||
log_file: Path,
|
||||
process_id: str,
|
||||
) -> None:
|
||||
"""Capture output from backgrounded process to log file."""
|
||||
try:
|
||||
|
||||
async def read_stream(stream, prefix: str) -> None:
|
||||
if stream:
|
||||
while True:
|
||||
line = await stream.readline()
|
||||
if not line:
|
||||
break
|
||||
await append_file(log_file, f"{prefix}{line.decode('utf-8', errors='replace')}")
|
||||
|
||||
await asyncio.gather(
|
||||
read_stream(proc.stdout, ""),
|
||||
read_stream(proc.stderr, "[stderr] "),
|
||||
)
|
||||
|
||||
await proc.wait()
|
||||
await append_file(log_file, f"\n[shell] Process exited with code {proc.returncode}\n")
|
||||
|
||||
self._process_manager.mark_completed(process_id, proc.returncode or 0)
|
||||
except Exception:
|
||||
self._process_manager.mark_completed(process_id, -1)
|
||||
|
||||
|
||||
def get_shell_executor() -> ShellExecutor:
|
||||
"""Get the shared shell executor singleton."""
|
||||
global _shell_executor
|
||||
if _shell_executor is None:
|
||||
_shell_executor = ShellExecutor()
|
||||
return _shell_executor
|
||||
@@ -1,512 +0,0 @@
|
||||
"""Cmd tool - unified execution graph (DAG) for command execution.
|
||||
|
||||
The primary command execution tool for hanzo-mcp. Run commands with:
|
||||
- Serial execution (default)
|
||||
- Parallel execution
|
||||
- Mixed execution graphs
|
||||
- Tool invocations
|
||||
- Auto-backgrounding at 45s
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import asyncio
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Union, Optional, Annotated, override
|
||||
from datetime import datetime
|
||||
from dataclasses import field, dataclass
|
||||
|
||||
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.base_process import get_shell_executor
|
||||
|
||||
|
||||
class NodeStatus(Enum):
|
||||
"""Execution status of a command node."""
|
||||
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
SUCCESS = "success"
|
||||
FAILED = "failed"
|
||||
SKIPPED = "skipped"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CmdResult:
|
||||
"""Result from a single command execution."""
|
||||
|
||||
node_id: str
|
||||
command: str
|
||||
stdout: str
|
||||
stderr: str
|
||||
status: NodeStatus
|
||||
exit_code: int
|
||||
duration_ms: int = 0
|
||||
node_type: str = "shell"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CmdNode:
|
||||
"""A node in the execution graph."""
|
||||
|
||||
id: str
|
||||
command: Union[str, Dict[str, Any]]
|
||||
depends_on: List[str] = field(default_factory=list)
|
||||
status: NodeStatus = NodeStatus.PENDING
|
||||
result: Optional[CmdResult] = None
|
||||
|
||||
|
||||
Command = Union[str, Dict[str, Any], List[Any]]
|
||||
|
||||
|
||||
class CmdTool(BaseTool):
|
||||
"""Unified command execution with DAG support.
|
||||
|
||||
The primary tool for running shell commands. Supports:
|
||||
- Simple commands: cmd("ls -la")
|
||||
- Serial execution: cmd(["ls", "pwd", "git status"])
|
||||
- Parallel execution: cmd(["npm i", "cargo build"], parallel=True)
|
||||
- Mixed DAG: cmd(["mkdir dist", {"parallel": ["cp a", "cp b"]}, "zip out"])
|
||||
- Tool invocations: cmd([{"tool": "search", "input": {"pattern": "TODO"}}])
|
||||
- Named deps: cmd([{"id": "a", "run": "...", "after": ["b"]}])
|
||||
|
||||
Auto-backgrounds commands after 45s. Uses zsh by default.
|
||||
"""
|
||||
|
||||
name = "cmd"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tools: Optional[Dict[str, BaseTool]] = None,
|
||||
default_shell: str = "zsh",
|
||||
):
|
||||
"""Initialize command execution tool."""
|
||||
super().__init__()
|
||||
self.tools = tools or {}
|
||||
self.default_shell = self._resolve_shell(default_shell)
|
||||
|
||||
def _resolve_shell(self, preferred: str) -> str:
|
||||
"""Resolve shell - prefer zsh, fallback to bash/fish/sh.
|
||||
|
||||
Supports: zsh, bash, fish, sh, dash, ksh, tcsh, csh
|
||||
"""
|
||||
# Allow override via environment
|
||||
force_shell = os.environ.get("HANZO_MCP_FORCE_SHELL")
|
||||
if force_shell:
|
||||
return force_shell
|
||||
|
||||
# Shell priority: modern shells first
|
||||
shell_priority = ["zsh", "bash", "fish", "dash", "sh"]
|
||||
search_paths = [
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
"/bin",
|
||||
"/usr/bin",
|
||||
"/usr/local/fish/bin", # Common fish location
|
||||
]
|
||||
|
||||
for shell in shell_priority:
|
||||
for prefix in search_paths:
|
||||
full_path = f"{prefix}/{shell}"
|
||||
if os.path.isfile(full_path) and os.access(full_path, os.X_OK):
|
||||
return full_path
|
||||
found = shutil.which(shell)
|
||||
if found:
|
||||
return found
|
||||
|
||||
return "sh"
|
||||
|
||||
@property
|
||||
@override
|
||||
def description(self) -> str:
|
||||
shell_name = os.path.basename(self.default_shell)
|
||||
return f"""Execute commands with DAG support (shell: {shell_name}).
|
||||
|
||||
SIMPLE:
|
||||
cmd("ls -la") # Single command
|
||||
cmd("A ; B ; C") # Sequential via shell
|
||||
|
||||
ARRAYS:
|
||||
cmd(["a", "b", "c"]) # Sequential
|
||||
cmd(["a", "b"], parallel=True) # All parallel
|
||||
cmd(["a", ["b", "c"], "d"]) # Nested = parallel
|
||||
|
||||
DAG:
|
||||
cmd([
|
||||
"mkdir dist",
|
||||
{{"parallel": ["cp a dist/", "cp b dist/"]}},
|
||||
"zip -r out.zip dist/"
|
||||
])
|
||||
|
||||
TOOL INVOCATION:
|
||||
cmd([{{"tool": "search", "input": {{"pattern": "TODO"}}}}])
|
||||
|
||||
OPTIONS:
|
||||
parallel: Run ALL top-level commands concurrently
|
||||
shell: Use different shell (zsh, bash, fish, dash, sh)
|
||||
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."""
|
||||
|
||||
async def _run_shell(
|
||||
self,
|
||||
cmd: str,
|
||||
cwd: Optional[str] = None,
|
||||
env: Optional[Dict[str, str]] = None,
|
||||
timeout: int = 30,
|
||||
shell: Optional[str] = None,
|
||||
) -> CmdResult:
|
||||
"""Run a shell command with auto-backgrounding on timeout.
|
||||
|
||||
Uses the shared ShellExecutor for consistent 45s auto-backgrounding.
|
||||
"""
|
||||
start_time = datetime.now()
|
||||
node_id = f"shell_{id(cmd)}"
|
||||
executor = get_shell_executor()
|
||||
use_shell = shell or self.default_shell
|
||||
|
||||
stdout, stderr, exit_code, was_backgrounded, process_id = await executor.run_shell(
|
||||
command=cmd,
|
||||
shell=use_shell,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
timeout=float(timeout),
|
||||
tool_name="cmd",
|
||||
)
|
||||
|
||||
duration = int((datetime.now() - start_time).total_seconds() * 1000)
|
||||
|
||||
if was_backgrounded:
|
||||
return CmdResult(
|
||||
node_id=node_id,
|
||||
command=cmd,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
status=NodeStatus.SUCCESS, # Backgrounded = success from caller's POV
|
||||
exit_code=0,
|
||||
duration_ms=duration,
|
||||
node_type="shell",
|
||||
)
|
||||
|
||||
return CmdResult(
|
||||
node_id=node_id,
|
||||
command=cmd,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
status=NodeStatus.SUCCESS if exit_code == 0 else NodeStatus.FAILED,
|
||||
exit_code=exit_code,
|
||||
duration_ms=duration,
|
||||
node_type="shell",
|
||||
)
|
||||
|
||||
async def _run_tool(self, tool_name: str, tool_input: Dict[str, Any], ctx: MCPContext) -> CmdResult:
|
||||
"""Run an MCP tool invocation."""
|
||||
start_time = datetime.now()
|
||||
node_id = f"tool_{tool_name}"
|
||||
|
||||
if tool_name not in self.tools:
|
||||
return CmdResult(
|
||||
node_id=node_id,
|
||||
command=f"tool:{tool_name}",
|
||||
stdout="",
|
||||
stderr=f"Tool '{tool_name}' not found. Available: {list(self.tools.keys())}",
|
||||
status=NodeStatus.FAILED,
|
||||
exit_code=1,
|
||||
duration_ms=0,
|
||||
node_type="tool",
|
||||
)
|
||||
|
||||
try:
|
||||
tool = self.tools[tool_name]
|
||||
result = await tool.call(ctx, **tool_input)
|
||||
duration = int((datetime.now() - start_time).total_seconds() * 1000)
|
||||
|
||||
return CmdResult(
|
||||
node_id=node_id,
|
||||
command=f"tool:{tool_name}",
|
||||
stdout=str(result) if result else "",
|
||||
stderr="",
|
||||
status=NodeStatus.SUCCESS,
|
||||
exit_code=0,
|
||||
duration_ms=duration,
|
||||
node_type="tool",
|
||||
)
|
||||
except Exception as e:
|
||||
duration = int((datetime.now() - start_time).total_seconds() * 1000)
|
||||
return CmdResult(
|
||||
node_id=node_id,
|
||||
command=f"tool:{tool_name}",
|
||||
stdout="",
|
||||
stderr=str(e),
|
||||
status=NodeStatus.FAILED,
|
||||
exit_code=1,
|
||||
duration_ms=duration,
|
||||
node_type="tool",
|
||||
)
|
||||
|
||||
async def _execute_node(
|
||||
self,
|
||||
cmd: Command,
|
||||
ctx: MCPContext,
|
||||
cwd: Optional[str],
|
||||
env: Optional[Dict[str, str]],
|
||||
timeout: int,
|
||||
shell: Optional[str] = None,
|
||||
) -> CmdResult:
|
||||
"""Execute a single command node.
|
||||
|
||||
Supports:
|
||||
- str: Single command
|
||||
- list: Nested array = parallel execution
|
||||
- dict with "parallel": Explicit parallel block
|
||||
- dict with "tool": Tool invocation
|
||||
- dict with "run": Command wrapper
|
||||
"""
|
||||
|
||||
if isinstance(cmd, str):
|
||||
return await self._run_shell(cmd, cwd, env, timeout, shell)
|
||||
|
||||
# Nested array = parallel execution
|
||||
if isinstance(cmd, list):
|
||||
tasks = [self._execute_node(c, ctx, cwd, env, timeout, shell) for c in cmd]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
combined_stdout = []
|
||||
combined_stderr = []
|
||||
all_success = True
|
||||
total_duration = 0
|
||||
|
||||
for i, r in enumerate(results):
|
||||
if isinstance(r, Exception):
|
||||
combined_stderr.append(f"[{i}] Error: {r}")
|
||||
all_success = False
|
||||
else:
|
||||
if r.stdout:
|
||||
combined_stdout.append(f"[{i}] {r.stdout.rstrip()}")
|
||||
if r.stderr:
|
||||
combined_stderr.append(f"[{i}] {r.stderr.rstrip()}")
|
||||
if r.status != NodeStatus.SUCCESS:
|
||||
all_success = False
|
||||
total_duration = max(total_duration, r.duration_ms)
|
||||
|
||||
return CmdResult(
|
||||
node_id=f"parallel_{len(cmd)}",
|
||||
command=f"parallel[{len(cmd)} tasks]",
|
||||
stdout="\n".join(combined_stdout),
|
||||
stderr="\n".join(combined_stderr),
|
||||
status=NodeStatus.SUCCESS if all_success else NodeStatus.FAILED,
|
||||
exit_code=0 if all_success else 1,
|
||||
duration_ms=total_duration,
|
||||
node_type="parallel",
|
||||
)
|
||||
|
||||
if isinstance(cmd, dict):
|
||||
if "tool" in cmd:
|
||||
return await self._run_tool(cmd["tool"], cmd.get("input", {}), ctx)
|
||||
|
||||
if "parallel" in cmd:
|
||||
parallel_cmds = cmd["parallel"]
|
||||
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 = []
|
||||
combined_stderr = []
|
||||
all_success = True
|
||||
total_duration = 0
|
||||
|
||||
for i, r in enumerate(results):
|
||||
if isinstance(r, Exception):
|
||||
combined_stderr.append(f"[{i}] Error: {r}")
|
||||
all_success = False
|
||||
else:
|
||||
if r.stdout:
|
||||
combined_stdout.append(f"[{i}] {r.stdout.rstrip()}")
|
||||
if r.stderr:
|
||||
combined_stderr.append(f"[{i}] {r.stderr.rstrip()}")
|
||||
if r.status != NodeStatus.SUCCESS:
|
||||
all_success = False
|
||||
total_duration = max(total_duration, r.duration_ms)
|
||||
|
||||
return CmdResult(
|
||||
node_id=f"parallel_{len(parallel_cmds)}",
|
||||
command=f"parallel[{len(parallel_cmds)} tasks]",
|
||||
stdout="\n".join(combined_stdout),
|
||||
stderr="\n".join(combined_stderr),
|
||||
status=NodeStatus.SUCCESS if all_success else NodeStatus.FAILED,
|
||||
exit_code=0 if all_success else 1,
|
||||
duration_ms=total_duration,
|
||||
node_type="parallel",
|
||||
)
|
||||
|
||||
if "run" in cmd:
|
||||
run_cmd = cmd["run"]
|
||||
return await self._execute_node(run_cmd, ctx, cwd, env, timeout, shell)
|
||||
|
||||
return CmdResult(
|
||||
node_id="unknown",
|
||||
command=str(cmd),
|
||||
stdout="",
|
||||
stderr=f"Unknown command format: {cmd}",
|
||||
status=NodeStatus.FAILED,
|
||||
exit_code=1,
|
||||
duration_ms=0,
|
||||
node_type="unknown",
|
||||
)
|
||||
|
||||
return CmdResult(
|
||||
node_id="unknown",
|
||||
command=str(cmd),
|
||||
stdout="",
|
||||
stderr=f"Unknown command type: {type(cmd).__name__}",
|
||||
status=NodeStatus.FAILED,
|
||||
exit_code=1,
|
||||
duration_ms=0,
|
||||
node_type="unknown",
|
||||
)
|
||||
|
||||
def _format_output(self, results: List[CmdResult], quiet: bool = False) -> str:
|
||||
"""Format command execution results."""
|
||||
output_parts = []
|
||||
total_duration = 0
|
||||
failed_count = 0
|
||||
|
||||
for r in results:
|
||||
total_duration += r.duration_ms
|
||||
|
||||
if r.status == NodeStatus.FAILED:
|
||||
failed_count += 1
|
||||
|
||||
if r.stdout and not quiet:
|
||||
output_parts.append(r.stdout.rstrip())
|
||||
|
||||
if r.stderr:
|
||||
output_parts.append(f"[stderr] {r.stderr.rstrip()}")
|
||||
|
||||
if len(results) > 1:
|
||||
status = "✓" if failed_count == 0 else f"✗ ({failed_count} failed)"
|
||||
output_parts.append(f"\n[cmd] {len(results)} nodes, {total_duration}ms, {status}")
|
||||
|
||||
return "\n".join(output_parts) if output_parts else "(no output)"
|
||||
|
||||
@override
|
||||
@auto_timeout("cmd")
|
||||
async def call(
|
||||
self,
|
||||
ctx: MCPContext,
|
||||
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 = 30,
|
||||
strict: bool = False,
|
||||
quiet: bool = False,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""Execute commands with optional DAG semantics.
|
||||
|
||||
Args:
|
||||
command: Single command string (simple mode)
|
||||
commands: List of commands for DAG mode
|
||||
parallel: Run all commands concurrently
|
||||
shell: Shell to use (default: zsh)
|
||||
cwd: Working directory
|
||||
env: Additional environment variables
|
||||
timeout: Per-command timeout in seconds (default: 45)
|
||||
strict: Stop on first error
|
||||
quiet: Suppress stdout
|
||||
"""
|
||||
tool_ctx = create_tool_context(ctx)
|
||||
await tool_ctx.set_tool_info(self.name)
|
||||
|
||||
# Single command mode
|
||||
if command and not commands:
|
||||
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
|
||||
|
||||
# DAG mode
|
||||
if not commands:
|
||||
return "Error: Provide either 'command' (string) or 'commands' (list)"
|
||||
|
||||
results: List[CmdResult] = []
|
||||
|
||||
if parallel:
|
||||
tasks = [self._execute_node(cmd, ctx, cwd, env, timeout, shell) for cmd in commands]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
results = [
|
||||
r
|
||||
if not isinstance(r, Exception)
|
||||
else CmdResult(
|
||||
node_id=f"error_{i}",
|
||||
command=str(commands[i]),
|
||||
stdout="",
|
||||
stderr=str(r),
|
||||
status=NodeStatus.FAILED,
|
||||
exit_code=1,
|
||||
duration_ms=0,
|
||||
node_type="error",
|
||||
)
|
||||
for i, r in enumerate(results)
|
||||
]
|
||||
else:
|
||||
for cmd in commands:
|
||||
result = await self._execute_node(cmd, ctx, cwd, env, timeout, shell)
|
||||
results.append(result)
|
||||
|
||||
if strict and result.status == NodeStatus.FAILED:
|
||||
break
|
||||
|
||||
return self._format_output(results, quiet)
|
||||
|
||||
@override
|
||||
def register(self, mcp_server: FastMCP) -> None:
|
||||
"""Register cmd tool with MCP server."""
|
||||
tool_self = self
|
||||
|
||||
@mcp_server.tool(name=self.name, description=self.description)
|
||||
async def cmd_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,
|
||||
shell: Annotated[Optional[str], Field(description="Shell: zsh, bash, sh", 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=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=shell,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
timeout=timeout,
|
||||
strict=strict,
|
||||
quiet=quiet,
|
||||
)
|
||||
|
||||
|
||||
def create_cmd_tool(tools: Optional[Dict[str, BaseTool]] = None, default_shell: str = "zsh") -> CmdTool:
|
||||
"""Factory to create command execution tool."""
|
||||
return CmdTool(tools, default_shell)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
cmd_tool = CmdTool()
|
||||
@@ -1,189 +0,0 @@
|
||||
"""Curl tool - HTTP client without shell escaping issues.
|
||||
|
||||
Provides a clean interface for HTTP requests, avoiding shell quoting problems.
|
||||
"""
|
||||
|
||||
import json
|
||||
import asyncio
|
||||
from typing import Literal, Optional, Annotated, final, 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
|
||||
|
||||
Method = Annotated[
|
||||
Literal["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
|
||||
Field(description="HTTP method"),
|
||||
]
|
||||
|
||||
|
||||
@final
|
||||
class CurlTool(BaseTool):
|
||||
"""HTTP client tool - curl without shell escaping nightmares.
|
||||
|
||||
Clean interface for HTTP requests with proper JSON handling.
|
||||
"""
|
||||
|
||||
name = "curl"
|
||||
|
||||
@property
|
||||
@override
|
||||
def description(self) -> str:
|
||||
return """HTTP client - make requests without shell escaping issues.
|
||||
|
||||
Examples:
|
||||
curl --url "https://api.example.com/health"
|
||||
curl --url "https://api.example.com/data" --method POST --json '{"key": "value"}'
|
||||
curl --url "https://api.example.com/auth" --headers '{"Authorization": "Bearer token"}'
|
||||
|
||||
Parameters:
|
||||
url: Request URL (required)
|
||||
method: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS (default: GET)
|
||||
json: JSON body (will be serialized properly)
|
||||
data: Raw body data
|
||||
headers: Additional headers as JSON object
|
||||
timeout: Request timeout in seconds (default: 30)
|
||||
follow_redirects: Follow redirects (default: true)
|
||||
verbose: Include response headers (default: false)
|
||||
|
||||
Returns formatted response with status, headers (if verbose), and body.
|
||||
"""
|
||||
|
||||
@override
|
||||
@auto_timeout("curl")
|
||||
async def call(
|
||||
self,
|
||||
ctx: MCPContext,
|
||||
url: str,
|
||||
method: str = "GET",
|
||||
json_body: Optional[str] = None,
|
||||
data: Optional[str] = None,
|
||||
headers: Optional[str] = None,
|
||||
timeout: int = 30,
|
||||
follow_redirects: bool = True,
|
||||
verbose: bool = False,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""Make HTTP request."""
|
||||
tool_ctx = create_tool_context(ctx)
|
||||
await tool_ctx.set_tool_info(self.name)
|
||||
|
||||
# Build curl command
|
||||
cmd = ["curl", "-s", "-S"] # Silent but show errors
|
||||
|
||||
# Method
|
||||
if method != "GET":
|
||||
cmd.extend(["-X", method])
|
||||
|
||||
# Follow redirects
|
||||
if follow_redirects:
|
||||
cmd.append("-L")
|
||||
|
||||
# Timeout
|
||||
cmd.extend(["--max-time", str(timeout)])
|
||||
|
||||
# Include headers in output if verbose
|
||||
if verbose:
|
||||
cmd.append("-i")
|
||||
|
||||
# Headers
|
||||
default_headers = {}
|
||||
if headers:
|
||||
try:
|
||||
parsed_headers = json.loads(headers)
|
||||
if isinstance(parsed_headers, dict):
|
||||
default_headers.update(parsed_headers)
|
||||
except json.JSONDecodeError:
|
||||
return f"Error: Invalid headers JSON: {headers}"
|
||||
|
||||
# JSON body
|
||||
if json_body:
|
||||
default_headers.setdefault("Content-Type", "application/json")
|
||||
# Parse and re-serialize to ensure valid JSON
|
||||
try:
|
||||
parsed_json = json.loads(json_body)
|
||||
serialized = json.dumps(parsed_json)
|
||||
cmd.extend(["-d", serialized])
|
||||
except json.JSONDecodeError:
|
||||
return f"Error: Invalid JSON body: {json_body}"
|
||||
elif data:
|
||||
cmd.extend(["-d", data])
|
||||
|
||||
# Add all headers
|
||||
for key, value in default_headers.items():
|
||||
cmd.extend(["-H", f"{key}: {value}"])
|
||||
|
||||
# URL (must be last)
|
||||
cmd.append(url)
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(),
|
||||
timeout=timeout + 5, # Give curl time to timeout first
|
||||
)
|
||||
|
||||
output = stdout.decode("utf-8", errors="replace")
|
||||
errors = stderr.decode("utf-8", errors="replace")
|
||||
|
||||
if proc.returncode != 0:
|
||||
return f"curl failed (exit {proc.returncode}):\n{errors}\n{output}"
|
||||
|
||||
# Try to pretty-print JSON responses
|
||||
if not verbose: # Don't try to parse if headers included
|
||||
try:
|
||||
parsed = json.loads(output)
|
||||
return json.dumps(parsed, indent=2)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
return output
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
return f"Request timed out after {timeout}s"
|
||||
except FileNotFoundError:
|
||||
return "Error: curl not found. Install curl to use this tool."
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
def register(self, mcp_server: FastMCP) -> None:
|
||||
"""Register with MCP server."""
|
||||
tool_instance = self
|
||||
|
||||
@mcp_server.tool()
|
||||
async def curl(
|
||||
url: Annotated[str, Field(description="Request URL")],
|
||||
method: Method = "GET",
|
||||
json_body: Annotated[
|
||||
Optional[str], Field(description="JSON body (will be properly escaped)", alias="json")
|
||||
] = None,
|
||||
data: Annotated[Optional[str], Field(description="Raw body data")] = None,
|
||||
headers: Annotated[Optional[str], Field(description="Headers as JSON object")] = None,
|
||||
timeout: Annotated[int, Field(description="Timeout in seconds")] = 30,
|
||||
follow_redirects: Annotated[bool, Field(description="Follow redirects")] = True,
|
||||
verbose: Annotated[bool, Field(description="Include response headers")] = False,
|
||||
ctx: MCPContext = None,
|
||||
) -> str:
|
||||
"""HTTP client - curl without shell escaping issues.
|
||||
|
||||
Make HTTP requests with proper JSON handling.
|
||||
No more shell quoting nightmares!
|
||||
"""
|
||||
return await tool_instance.call(
|
||||
ctx,
|
||||
url=url,
|
||||
method=method,
|
||||
json_body=json_body,
|
||||
data=data,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
follow_redirects=follow_redirects,
|
||||
verbose=verbose,
|
||||
)
|
||||
@@ -1,485 +0,0 @@
|
||||
"""DAG execution tool - directed acyclic graph for command execution.
|
||||
|
||||
Run commands/tools with proper dependency ordering using DAG semantics.
|
||||
Supports serial (default), parallel, and complex mixed execution graphs.
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
import shutil
|
||||
import asyncio
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Union, Optional, Annotated, override
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from dataclasses import field, dataclass
|
||||
|
||||
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.base_process import get_shell_executor
|
||||
|
||||
|
||||
class NodeStatus(Enum):
|
||||
"""Execution status of a DAG node."""
|
||||
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
SUCCESS = "success"
|
||||
FAILED = "failed"
|
||||
SKIPPED = "skipped"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DagResult:
|
||||
"""Result from a single DAG node execution."""
|
||||
|
||||
node_id: str
|
||||
command: str
|
||||
stdout: str
|
||||
stderr: str
|
||||
status: NodeStatus
|
||||
exit_code: int
|
||||
duration_ms: int = 0
|
||||
node_type: str = "shell"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DagNode:
|
||||
"""A node in the execution DAG."""
|
||||
|
||||
id: str
|
||||
command: Union[str, Dict[str, Any]]
|
||||
depends_on: List[str] = field(default_factory=list)
|
||||
status: NodeStatus = NodeStatus.PENDING
|
||||
result: Optional[DagResult] = None
|
||||
|
||||
|
||||
Command = Union[str, Dict[str, Any], List[Any]]
|
||||
|
||||
|
||||
class DagTool(BaseTool):
|
||||
"""DAG-based execution with proper dependency ordering.
|
||||
|
||||
Execute commands (shell or tools) with directed acyclic graph semantics.
|
||||
Supports serial, parallel, and complex mixed execution patterns.
|
||||
|
||||
USAGE PATTERNS:
|
||||
|
||||
1. Serial (default) - sequential execution:
|
||||
dag(["ls", "pwd", "git status"])
|
||||
|
||||
2. Parallel - concurrent execution:
|
||||
dag(["npm install", "cargo build"], parallel=True)
|
||||
|
||||
3. DAG - mixed serial and parallel blocks:
|
||||
dag([
|
||||
"mkdir -p dist",
|
||||
{"parallel": [
|
||||
"cp manifest.json dist/",
|
||||
"cp -rf assets/ dist/",
|
||||
]},
|
||||
"zip -r package.zip dist/"
|
||||
])
|
||||
|
||||
4. Tool invocations - not just shell:
|
||||
dag([
|
||||
{"tool": "read", "input": {"file_path": "config.json"}},
|
||||
{"tool": "search", "input": {"pattern": "TODO"}},
|
||||
])
|
||||
|
||||
5. Named nodes with explicit dependencies:
|
||||
dag([
|
||||
{"id": "setup", "run": "mkdir -p dist"},
|
||||
{"id": "copy", "run": "cp *.txt dist/", "after": ["setup"]},
|
||||
{"id": "test", "run": "pytest", "after": ["setup"]},
|
||||
{"id": "package", "run": "tar -czf out.tar.gz dist/", "after": ["copy", "test"]},
|
||||
])
|
||||
|
||||
Uses zsh for shell execution.
|
||||
"""
|
||||
|
||||
name = "dag"
|
||||
|
||||
def __init__(self, tools: Optional[Dict[str, BaseTool]] = None, default_shell: str = "zsh"):
|
||||
"""Initialize DAG execution tool."""
|
||||
super().__init__()
|
||||
self.tools = tools or {}
|
||||
self.default_shell = self._resolve_shell(default_shell)
|
||||
|
||||
def _resolve_shell(self, preferred: str) -> str:
|
||||
"""Resolve shell - prefer zsh, fallback to bash."""
|
||||
shell_priority = ["zsh", "bash"]
|
||||
search_paths = [
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
"/bin",
|
||||
"/usr/bin",
|
||||
]
|
||||
|
||||
for shell in shell_priority:
|
||||
for prefix in search_paths:
|
||||
full_path = f"{prefix}/{shell}"
|
||||
if os.path.isfile(full_path) and os.access(full_path, os.X_OK):
|
||||
return full_path
|
||||
found = shutil.which(shell)
|
||||
if found:
|
||||
return found
|
||||
|
||||
return "sh"
|
||||
|
||||
@property
|
||||
@override
|
||||
def description(self) -> str:
|
||||
return """DAG execution - run commands with dependency ordering.
|
||||
|
||||
Execute shell commands or tools with DAG (directed acyclic graph) semantics.
|
||||
Supports serial, parallel, and complex mixed execution patterns.
|
||||
|
||||
MODES:
|
||||
|
||||
Serial (default): dag(["ls", "pwd"])
|
||||
Commands run in sequence.
|
||||
|
||||
Parallel: dag(["npm install", "cargo build"], parallel=True)
|
||||
Commands run concurrently.
|
||||
|
||||
Mixed DAG:
|
||||
dag([
|
||||
"mkdir -p dist",
|
||||
{"parallel": ["cp a.txt dist/", "cp b.txt dist/"]},
|
||||
"zip -r out.zip dist/"
|
||||
])
|
||||
|
||||
Tool invocations:
|
||||
dag([{"tool": "search", "input": {"pattern": "TODO"}}])
|
||||
|
||||
Named with dependencies:
|
||||
dag([
|
||||
{"id": "build", "run": "make build"},
|
||||
{"id": "test", "run": "make test", "after": ["build"]},
|
||||
])
|
||||
|
||||
Uses zsh for shell execution.
|
||||
|
||||
AUTO-BACKGROUNDING: Commands that exceed timeout are automatically
|
||||
backgrounded. Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
|
||||
async def _run_shell(
|
||||
self,
|
||||
cmd: str,
|
||||
shell: str,
|
||||
cwd: Optional[str] = None,
|
||||
env: Optional[Dict[str, str]] = None,
|
||||
timeout: int = 30,
|
||||
) -> DagResult:
|
||||
"""Run a shell command with auto-backgrounding on timeout.
|
||||
|
||||
Uses the shared ShellExecutor for consistent 45s auto-backgrounding.
|
||||
"""
|
||||
start_time = datetime.now()
|
||||
node_id = f"shell_{id(cmd)}"
|
||||
executor = get_shell_executor()
|
||||
|
||||
stdout, stderr, exit_code, was_backgrounded, process_id = await executor.run_shell(
|
||||
command=cmd,
|
||||
shell=shell,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
timeout=float(timeout),
|
||||
tool_name="dag",
|
||||
)
|
||||
|
||||
duration = int((datetime.now() - start_time).total_seconds() * 1000)
|
||||
|
||||
if was_backgrounded:
|
||||
return DagResult(
|
||||
node_id=node_id,
|
||||
command=cmd,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
status=NodeStatus.SUCCESS, # Backgrounded = success from caller's POV
|
||||
exit_code=0,
|
||||
duration_ms=duration,
|
||||
node_type="shell",
|
||||
)
|
||||
|
||||
return DagResult(
|
||||
node_id=node_id,
|
||||
command=cmd,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
status=NodeStatus.SUCCESS if exit_code == 0 else NodeStatus.FAILED,
|
||||
exit_code=exit_code,
|
||||
duration_ms=duration,
|
||||
node_type="shell",
|
||||
)
|
||||
|
||||
async def _run_tool(self, tool_name: str, tool_input: Dict[str, Any], ctx: MCPContext) -> DagResult:
|
||||
"""Run an MCP tool invocation."""
|
||||
start_time = datetime.now()
|
||||
node_id = f"tool_{tool_name}"
|
||||
|
||||
if tool_name not in self.tools:
|
||||
return DagResult(
|
||||
node_id=node_id,
|
||||
command=f"tool:{tool_name}",
|
||||
stdout="",
|
||||
stderr=f"Tool '{tool_name}' not found. Available: {list(self.tools.keys())}",
|
||||
status=NodeStatus.FAILED,
|
||||
exit_code=1,
|
||||
duration_ms=0,
|
||||
node_type="tool",
|
||||
)
|
||||
|
||||
try:
|
||||
tool = self.tools[tool_name]
|
||||
result = await tool.call(ctx, **tool_input)
|
||||
duration = int((datetime.now() - start_time).total_seconds() * 1000)
|
||||
|
||||
return DagResult(
|
||||
node_id=node_id,
|
||||
command=f"tool:{tool_name}",
|
||||
stdout=str(result) if result else "",
|
||||
stderr="",
|
||||
status=NodeStatus.SUCCESS,
|
||||
exit_code=0,
|
||||
duration_ms=duration,
|
||||
node_type="tool",
|
||||
)
|
||||
except Exception as e:
|
||||
duration = int((datetime.now() - start_time).total_seconds() * 1000)
|
||||
return DagResult(
|
||||
node_id=node_id,
|
||||
command=f"tool:{tool_name}",
|
||||
stdout="",
|
||||
stderr=str(e),
|
||||
status=NodeStatus.FAILED,
|
||||
exit_code=1,
|
||||
duration_ms=duration,
|
||||
node_type="tool",
|
||||
)
|
||||
|
||||
async def _execute_node(
|
||||
self,
|
||||
cmd: Command,
|
||||
ctx: MCPContext,
|
||||
shell: str,
|
||||
cwd: Optional[str],
|
||||
env: Optional[Dict[str, str]],
|
||||
timeout: int,
|
||||
) -> DagResult:
|
||||
"""Execute a single DAG node."""
|
||||
|
||||
if isinstance(cmd, str):
|
||||
return await self._run_shell(cmd, shell, cwd, env, timeout)
|
||||
|
||||
# Nested array = auto-parallel (e.g., ["a", ["b", "c"], "d"] runs b,c in parallel)
|
||||
if isinstance(cmd, list):
|
||||
tasks = [self._execute_node(c, ctx, shell, cwd, env, timeout) for c in cmd]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
combined_stdout = []
|
||||
combined_stderr = []
|
||||
all_success = True
|
||||
total_duration = 0
|
||||
|
||||
for i, r in enumerate(results):
|
||||
if isinstance(r, Exception):
|
||||
combined_stderr.append(f"[{i}] Error: {r}")
|
||||
all_success = False
|
||||
else:
|
||||
if r.stdout:
|
||||
combined_stdout.append(f"[{i}] {r.stdout.rstrip()}")
|
||||
if r.stderr:
|
||||
combined_stderr.append(f"[{i}] {r.stderr.rstrip()}")
|
||||
if r.status != NodeStatus.SUCCESS:
|
||||
all_success = False
|
||||
total_duration = max(total_duration, r.duration_ms)
|
||||
|
||||
return DagResult(
|
||||
node_id=f"auto_parallel_{len(cmd)}",
|
||||
command=f"parallel[{len(cmd)} tasks]",
|
||||
stdout="\n".join(combined_stdout),
|
||||
stderr="\n".join(combined_stderr),
|
||||
status=NodeStatus.SUCCESS if all_success else NodeStatus.FAILED,
|
||||
exit_code=0 if all_success else 1,
|
||||
duration_ms=total_duration,
|
||||
node_type="parallel",
|
||||
)
|
||||
|
||||
if isinstance(cmd, dict):
|
||||
if "tool" in cmd:
|
||||
return await self._run_tool(cmd["tool"], cmd.get("input", {}), ctx)
|
||||
|
||||
if "parallel" in cmd:
|
||||
parallel_cmds = cmd["parallel"]
|
||||
tasks = [self._execute_node(c, ctx, shell, cwd, env, timeout) for c in parallel_cmds]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
combined_stdout = []
|
||||
combined_stderr = []
|
||||
all_success = True
|
||||
total_duration = 0
|
||||
|
||||
for i, r in enumerate(results):
|
||||
if isinstance(r, Exception):
|
||||
combined_stderr.append(f"[{i}] Error: {r}")
|
||||
all_success = False
|
||||
else:
|
||||
if r.stdout:
|
||||
combined_stdout.append(f"[{i}] {r.stdout.rstrip()}")
|
||||
if r.stderr:
|
||||
combined_stderr.append(f"[{i}] {r.stderr.rstrip()}")
|
||||
if r.status != NodeStatus.SUCCESS:
|
||||
all_success = False
|
||||
total_duration = max(total_duration, r.duration_ms)
|
||||
|
||||
return DagResult(
|
||||
node_id=f"parallel_{len(parallel_cmds)}",
|
||||
command=f"parallel[{len(parallel_cmds)} tasks]",
|
||||
stdout="\n".join(combined_stdout),
|
||||
stderr="\n".join(combined_stderr),
|
||||
status=NodeStatus.SUCCESS if all_success else NodeStatus.FAILED,
|
||||
exit_code=0 if all_success else 1,
|
||||
duration_ms=total_duration,
|
||||
node_type="parallel",
|
||||
)
|
||||
|
||||
if "run" in cmd:
|
||||
run_cmd = cmd["run"]
|
||||
return await self._execute_node(run_cmd, ctx, shell, cwd, env, timeout)
|
||||
|
||||
return DagResult(
|
||||
node_id="unknown",
|
||||
command=str(cmd),
|
||||
stdout="",
|
||||
stderr=f"Unknown command format: {cmd}",
|
||||
status=NodeStatus.FAILED,
|
||||
exit_code=1,
|
||||
duration_ms=0,
|
||||
node_type="unknown",
|
||||
)
|
||||
|
||||
return DagResult(
|
||||
node_id="unknown",
|
||||
command=str(cmd),
|
||||
stdout="",
|
||||
stderr=f"Unknown command type: {type(cmd).__name__}",
|
||||
status=NodeStatus.FAILED,
|
||||
exit_code=1,
|
||||
duration_ms=0,
|
||||
node_type="unknown",
|
||||
)
|
||||
|
||||
@override
|
||||
@auto_timeout("dag")
|
||||
async def call(
|
||||
self,
|
||||
ctx: MCPContext,
|
||||
commands: List[Command],
|
||||
parallel: bool = False,
|
||||
shell: Optional[str] = None,
|
||||
cwd: Optional[str] = None,
|
||||
env: Optional[Dict[str, str]] = None,
|
||||
timeout: int = 30,
|
||||
strict: bool = False,
|
||||
quiet: bool = False,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""Execute commands with DAG semantics."""
|
||||
tool_ctx = create_tool_context(ctx)
|
||||
await tool_ctx.set_tool_info(self.name)
|
||||
|
||||
shell = shell or self.default_shell
|
||||
results: List[DagResult] = []
|
||||
|
||||
if parallel:
|
||||
tasks = [self._execute_node(cmd, ctx, shell, cwd, env, timeout) for cmd in commands]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
results = [
|
||||
r
|
||||
if not isinstance(r, Exception)
|
||||
else DagResult(
|
||||
node_id=f"error_{i}",
|
||||
command=str(commands[i]),
|
||||
stdout="",
|
||||
stderr=str(r),
|
||||
status=NodeStatus.FAILED,
|
||||
exit_code=1,
|
||||
duration_ms=0,
|
||||
node_type="error",
|
||||
)
|
||||
for i, r in enumerate(results)
|
||||
]
|
||||
else:
|
||||
for cmd in commands:
|
||||
result = await self._execute_node(cmd, ctx, shell, cwd, env, timeout)
|
||||
results.append(result)
|
||||
|
||||
if strict and result.status == NodeStatus.FAILED:
|
||||
break
|
||||
|
||||
return self._format_output(results, quiet)
|
||||
|
||||
def _format_output(self, results: List[DagResult], quiet: bool = False) -> str:
|
||||
"""Format DAG execution results."""
|
||||
output_parts = []
|
||||
total_duration = 0
|
||||
failed_count = 0
|
||||
|
||||
for r in results:
|
||||
total_duration += r.duration_ms
|
||||
|
||||
if r.status == NodeStatus.FAILED:
|
||||
failed_count += 1
|
||||
|
||||
if r.stdout and not quiet:
|
||||
output_parts.append(r.stdout.rstrip())
|
||||
|
||||
if r.stderr:
|
||||
output_parts.append(f"[stderr] {r.stderr.rstrip()}")
|
||||
|
||||
if len(results) > 1:
|
||||
status = "✓" if failed_count == 0 else f"✗ ({failed_count} failed)"
|
||||
output_parts.append(f"\n[dag] {len(results)} nodes, {total_duration}ms, {status}")
|
||||
|
||||
return "\n".join(output_parts) if output_parts else "(no output)"
|
||||
|
||||
@override
|
||||
def register(self, mcp_server: FastMCP) -> None:
|
||||
"""Register DAG tool with MCP server."""
|
||||
tool_self = self
|
||||
|
||||
@mcp_server.tool(name=self.name, description=self.description)
|
||||
async def dag_handler(
|
||||
commands: Annotated[
|
||||
List[Any], Field(description="Commands to execute (strings, tool dicts, or parallel blocks)")
|
||||
],
|
||||
parallel: Annotated[bool, Field(description="Run all commands in parallel", default=False)] = False,
|
||||
shell: Annotated[Optional[str], Field(description="Shell to use (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=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,
|
||||
commands=commands,
|
||||
parallel=parallel,
|
||||
shell=shell,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
timeout=timeout,
|
||||
strict=strict,
|
||||
quiet=quiet,
|
||||
)
|
||||
|
||||
|
||||
def create_dag_tool(tools: Optional[Dict[str, BaseTool]] = None, default_shell: str = "zsh") -> DagTool:
|
||||
"""Factory to create DAG execution tool."""
|
||||
return DagTool(tools, default_shell)
|
||||
@@ -1,167 +0,0 @@
|
||||
"""JQ tool - JSON processing without shell escaping issues.
|
||||
|
||||
Provides a clean interface for jq queries, avoiding shell quoting problems.
|
||||
"""
|
||||
|
||||
import json
|
||||
import asyncio
|
||||
from typing import Optional, Annotated, final, 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
|
||||
|
||||
|
||||
@final
|
||||
class JqTool(BaseTool):
|
||||
"""JQ tool - JSON processing without shell escaping nightmares.
|
||||
|
||||
Clean interface for jq queries with proper handling.
|
||||
"""
|
||||
|
||||
name = "jq"
|
||||
|
||||
@property
|
||||
@override
|
||||
def description(self) -> str:
|
||||
return """JSON processor - jq without shell escaping issues.
|
||||
|
||||
Examples:
|
||||
jq --filter ".result.data" --input '{"result": {"data": [1,2,3]}}'
|
||||
jq --filter ".[] | select(.active)" --file data.json
|
||||
jq --filter "keys" --input '{"a": 1, "b": 2}'
|
||||
jq --filter '.checks | to_entries[] | select(.value.error != null)' --file health.json
|
||||
|
||||
Parameters:
|
||||
filter: jq filter expression (required)
|
||||
input: JSON input as string
|
||||
file: Path to JSON file (alternative to input)
|
||||
raw: Output raw strings without quotes (default: false)
|
||||
compact: Compact output (default: false)
|
||||
slurp: Read entire input as single array (default: false)
|
||||
sort_keys: Sort object keys (default: false)
|
||||
|
||||
The filter is passed directly to jq without shell interpretation,
|
||||
so you don't need to escape special characters like ! or |
|
||||
"""
|
||||
|
||||
@override
|
||||
@auto_timeout("jq")
|
||||
async def call(
|
||||
self,
|
||||
ctx: MCPContext,
|
||||
filter: str,
|
||||
input: Optional[str] = None,
|
||||
file: Optional[str] = None,
|
||||
raw: bool = False,
|
||||
compact: bool = False,
|
||||
slurp: bool = False,
|
||||
sort_keys: bool = False,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""Process JSON with jq."""
|
||||
tool_ctx = create_tool_context(ctx)
|
||||
await tool_ctx.set_tool_info(self.name)
|
||||
|
||||
if not input and not file:
|
||||
return "Error: Either 'input' or 'file' is required"
|
||||
|
||||
# Build jq command
|
||||
cmd = ["jq"]
|
||||
|
||||
# Options
|
||||
if raw:
|
||||
cmd.append("-r")
|
||||
if compact:
|
||||
cmd.append("-c")
|
||||
if slurp:
|
||||
cmd.append("-s")
|
||||
if sort_keys:
|
||||
cmd.append("-S")
|
||||
|
||||
# Filter (passed as argument, not through shell)
|
||||
cmd.append(filter)
|
||||
|
||||
# File input
|
||||
if file:
|
||||
cmd.append(file)
|
||||
|
||||
try:
|
||||
if input:
|
||||
# Validate input is valid JSON first
|
||||
try:
|
||||
json.loads(input)
|
||||
except json.JSONDecodeError as e:
|
||||
return f"Error: Invalid JSON input: {e}"
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(input=input.encode("utf-8")),
|
||||
timeout=30,
|
||||
)
|
||||
else:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(),
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
output = stdout.decode("utf-8", errors="replace")
|
||||
errors = stderr.decode("utf-8", errors="replace")
|
||||
|
||||
if proc.returncode != 0:
|
||||
# Provide helpful error message
|
||||
if "syntax error" in errors.lower():
|
||||
return f"jq syntax error in filter:\n {filter}\n\nError: {errors}"
|
||||
return f"jq failed (exit {proc.returncode}):\n{errors}"
|
||||
|
||||
return output.rstrip()
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
return "jq timed out after 30s"
|
||||
except FileNotFoundError:
|
||||
return "Error: jq not found. Install jq: brew install jq"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
def register(self, mcp_server: FastMCP) -> None:
|
||||
"""Register with MCP server."""
|
||||
tool_instance = self
|
||||
|
||||
@mcp_server.tool()
|
||||
async def jq(
|
||||
filter: Annotated[str, Field(description="jq filter expression")],
|
||||
input: Annotated[Optional[str], Field(description="JSON input string")] = None,
|
||||
file: Annotated[Optional[str], Field(description="Path to JSON file")] = None,
|
||||
raw: Annotated[bool, Field(description="Output raw strings")] = False,
|
||||
compact: Annotated[bool, Field(description="Compact output")] = False,
|
||||
slurp: Annotated[bool, Field(description="Read as single array")] = False,
|
||||
sort_keys: Annotated[bool, Field(description="Sort object keys")] = False,
|
||||
ctx: MCPContext = None,
|
||||
) -> str:
|
||||
"""JSON processor - jq without shell escaping issues.
|
||||
|
||||
Process JSON with jq filters. No shell quoting nightmares!
|
||||
Supports all standard jq operations.
|
||||
"""
|
||||
return await tool_instance.call(
|
||||
ctx,
|
||||
filter=filter,
|
||||
input=input,
|
||||
file=file,
|
||||
raw=raw,
|
||||
compact=compact,
|
||||
slurp=slurp,
|
||||
sort_keys=sort_keys,
|
||||
)
|
||||
@@ -1,90 +0,0 @@
|
||||
"""NPX tool for both sync and background execution."""
|
||||
|
||||
from typing import Optional, override
|
||||
from pathlib import Path
|
||||
|
||||
from mcp.server import FastMCP
|
||||
from mcp.server.fastmcp import Context as MCPContext
|
||||
|
||||
from hanzo_tools.core import auto_timeout
|
||||
from hanzo_tools.shell.base_process import BaseBinaryTool
|
||||
|
||||
|
||||
class NpxTool(BaseBinaryTool):
|
||||
"""Tool for running npx commands."""
|
||||
|
||||
name = "npx"
|
||||
|
||||
@property
|
||||
@override
|
||||
def description(self) -> str:
|
||||
"""Get the tool description."""
|
||||
return """Run npx packages with automatic backgrounding for long-running processes.
|
||||
|
||||
Commands that run for more than 2 minutes will automatically continue in the background.
|
||||
|
||||
Usage:
|
||||
npx create-react-app my-app
|
||||
npx http-server -p 8080 # Auto-backgrounds after 2 minutes
|
||||
npx prettier --write "**/*.js"
|
||||
npx json-server db.json # Auto-backgrounds if needed"""
|
||||
|
||||
@override
|
||||
def get_binary_name(self) -> str:
|
||||
"""Get the binary name."""
|
||||
return "npx"
|
||||
|
||||
@override
|
||||
async def run(
|
||||
self,
|
||||
ctx: MCPContext,
|
||||
package: str,
|
||||
args: str = "",
|
||||
cwd: Optional[str] = None,
|
||||
yes: bool = True,
|
||||
) -> str:
|
||||
"""Run an npx command with auto-backgrounding."""
|
||||
work_dir = Path(cwd).resolve() if cwd else Path.cwd()
|
||||
|
||||
flags = []
|
||||
if yes:
|
||||
flags.append("-y")
|
||||
|
||||
full_args = args.split() if args else []
|
||||
|
||||
return await self.execute_sync(
|
||||
package,
|
||||
cwd=work_dir,
|
||||
flags=flags,
|
||||
args=full_args,
|
||||
timeout=None,
|
||||
)
|
||||
|
||||
def register(self, server: FastMCP) -> None:
|
||||
"""Register the tool with the MCP server."""
|
||||
tool_self = self
|
||||
|
||||
@server.tool(name=self.name, description=self.description)
|
||||
async def npx(
|
||||
ctx: MCPContext,
|
||||
package: str,
|
||||
args: str = "",
|
||||
cwd: Optional[str] = None,
|
||||
yes: bool = True,
|
||||
) -> str:
|
||||
return await tool_self.run(ctx, package=package, args=args, cwd=cwd, yes=yes)
|
||||
|
||||
@auto_timeout("npx")
|
||||
async def call(self, ctx: MCPContext, **params) -> str:
|
||||
"""Call the tool with arguments."""
|
||||
return await self.run(
|
||||
ctx,
|
||||
package=params["package"],
|
||||
args=params.get("args", ""),
|
||||
cwd=params.get("cwd"),
|
||||
yes=params.get("yes", True),
|
||||
)
|
||||
|
||||
|
||||
# Create tool instance
|
||||
npx_tool = NpxTool()
|
||||
@@ -1,117 +0,0 @@
|
||||
"""Open files or URLs in the default application."""
|
||||
|
||||
import asyncio
|
||||
import platform
|
||||
import webbrowser
|
||||
from typing import override
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from mcp.server import FastMCP
|
||||
from mcp.server.fastmcp import Context as MCPContext
|
||||
|
||||
from hanzo_tools.core import BaseTool, auto_timeout
|
||||
|
||||
|
||||
class OpenTool(BaseTool):
|
||||
"""Tool for opening files or URLs in the default application."""
|
||||
|
||||
name = "open"
|
||||
|
||||
def register(self, server: FastMCP) -> None:
|
||||
"""Register the tool with the MCP server."""
|
||||
tool_self = self
|
||||
|
||||
@server.tool(name=self.name, description=self.description)
|
||||
async def open(path: str, ctx: MCPContext) -> str:
|
||||
return await tool_self.run(ctx, path)
|
||||
|
||||
@auto_timeout("open")
|
||||
async def call(self, ctx: MCPContext, **params) -> str:
|
||||
"""Call the tool with arguments."""
|
||||
return await self.run(ctx, params["path"])
|
||||
|
||||
@property
|
||||
@override
|
||||
def description(self) -> str:
|
||||
"""Get the tool description."""
|
||||
return """Open files or URLs. Platform-aware.
|
||||
|
||||
Usage:
|
||||
open https://example.com
|
||||
open ./document.pdf
|
||||
open /path/to/image.png"""
|
||||
|
||||
async def _run_opener(self, cmd: list[str], file_path: str) -> bool:
|
||||
"""Run an opener command asynchronously (fire-and-forget pattern)."""
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=5)
|
||||
return proc.returncode == 0
|
||||
except asyncio.TimeoutError:
|
||||
# Process is still running, which is fine for opener commands
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def run(self, ctx: MCPContext, path: str) -> str:
|
||||
"""Open a file or URL in the default application."""
|
||||
parsed = urlparse(path)
|
||||
is_url = parsed.scheme in ("http", "https", "ftp", "file")
|
||||
|
||||
if is_url:
|
||||
try:
|
||||
# Run webbrowser.open in executor to avoid blocking
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, webbrowser.open, path)
|
||||
return f"Opened URL in browser: {path}"
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to open URL: {e}")
|
||||
|
||||
file_path = Path(path).expanduser().resolve()
|
||||
|
||||
# Check file exists asynchronously
|
||||
loop = asyncio.get_running_loop()
|
||||
exists = await loop.run_in_executor(None, file_path.exists)
|
||||
if not exists:
|
||||
raise RuntimeError(f"File not found: {file_path}")
|
||||
|
||||
system = platform.system().lower()
|
||||
|
||||
try:
|
||||
if system == "darwin": # macOS
|
||||
if await self._run_opener(["open", str(file_path)], str(file_path)):
|
||||
return f"Opened file: {file_path}"
|
||||
raise RuntimeError("Failed to open file with 'open' command")
|
||||
elif system == "linux":
|
||||
# Try xdg-open first
|
||||
if await self._run_opener(["xdg-open", str(file_path)], str(file_path)):
|
||||
return f"Opened file: {file_path}"
|
||||
# Fallback to other openers
|
||||
for opener in ["gnome-open", "kde-open", "exo-open"]:
|
||||
if await self._run_opener([opener, str(file_path)], str(file_path)):
|
||||
return f"Opened file: {file_path}"
|
||||
raise RuntimeError("No suitable file opener found on Linux")
|
||||
elif system == "windows":
|
||||
import os
|
||||
|
||||
# Run os.startfile in executor to avoid blocking
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, os.startfile, str(file_path))
|
||||
return f"Opened file: {file_path}"
|
||||
else:
|
||||
raise RuntimeError(f"Unsupported platform: {system}")
|
||||
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Error opening file: {e}")
|
||||
|
||||
|
||||
# Create tool instance
|
||||
open_tool = OpenTool()
|
||||
@@ -1,370 +0,0 @@
|
||||
"""Unified process tool for HIP-0300 architecture.
|
||||
|
||||
This module provides a single unified 'proc' tool that handles all process operations:
|
||||
- exec: Execute commands (the ONE execution primitive)
|
||||
- ps: List processes
|
||||
- kill: Kill processes
|
||||
- logs: Get process logs
|
||||
|
||||
Following Unix philosophy: one tool for the Execution axis.
|
||||
All command execution goes through proc.exec.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import signal
|
||||
import asyncio
|
||||
from typing import Any, Dict, ClassVar, Optional
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
from mcp.server import FastMCP
|
||||
from mcp.server.fastmcp import Context as MCPContext
|
||||
|
||||
from hanzo_tools.core import (
|
||||
Paging,
|
||||
BaseTool,
|
||||
ToolError,
|
||||
NotFoundError,
|
||||
InvalidParamsError,
|
||||
)
|
||||
from hanzo_tools.shell.base_process import (
|
||||
AUTO_BACKGROUND_TIMEOUT,
|
||||
ProcessManager,
|
||||
get_shell_executor,
|
||||
)
|
||||
|
||||
|
||||
class ProcTool(BaseTool):
|
||||
"""Unified process execution tool (HIP-0300).
|
||||
|
||||
Handles all process operations on a single axis:
|
||||
- exec: Execute commands
|
||||
- ps: List processes
|
||||
- kill: Kill processes
|
||||
- logs: Get process logs
|
||||
|
||||
CRITICAL: exec is the ONE execution primitive.
|
||||
All command execution goes through proc.exec.
|
||||
"""
|
||||
|
||||
name: ClassVar[str] = "proc"
|
||||
VERSION: ClassVar[str] = "0.12.0"
|
||||
|
||||
def __init__(self, tools: Optional[Dict[str, Any]] = None):
|
||||
super().__init__()
|
||||
self.tools = tools or {}
|
||||
self.process_manager = ProcessManager()
|
||||
self._shell = self._resolve_shell()
|
||||
self._register_proc_actions()
|
||||
|
||||
def _resolve_shell(self) -> str:
|
||||
"""Resolve shell - prefer zsh, fallback to bash/sh."""
|
||||
import shutil
|
||||
|
||||
force_shell = os.environ.get("HANZO_MCP_FORCE_SHELL")
|
||||
if force_shell:
|
||||
return force_shell
|
||||
|
||||
for shell in ["zsh", "bash", "fish", "dash", "sh"]:
|
||||
for prefix in ["/opt/homebrew/bin", "/usr/local/bin", "/bin", "/usr/bin"]:
|
||||
full_path = f"{prefix}/{shell}"
|
||||
if os.path.isfile(full_path) and os.access(full_path, os.X_OK):
|
||||
return full_path
|
||||
found = shutil.which(shell)
|
||||
if found:
|
||||
return found
|
||||
|
||||
return "sh"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
shell_name = os.path.basename(self._shell)
|
||||
return f"""Unified process execution tool (HIP-0300).
|
||||
|
||||
Actions:
|
||||
- exec: Execute command (shell: {shell_name})
|
||||
- ps: List processes
|
||||
- kill: Kill process
|
||||
- logs: Get process logs
|
||||
|
||||
Returns: {{proc_id, exit_code, stdout_ref, stderr_ref}}
|
||||
Auto-backgrounds commands after {AUTO_BACKGROUND_TIMEOUT}s.
|
||||
"""
|
||||
|
||||
def _register_proc_actions(self):
|
||||
"""Register all process actions."""
|
||||
|
||||
@self.action("exec", "Execute command")
|
||||
async def exec_cmd(
|
||||
ctx: MCPContext,
|
||||
command: str,
|
||||
cwd: str | None = None,
|
||||
env: dict | None = None,
|
||||
timeout: int | None = None,
|
||||
shell: str | None = None,
|
||||
) -> dict:
|
||||
"""Execute a command.
|
||||
|
||||
This is the ONE execution primitive per HIP-0300.
|
||||
|
||||
Args:
|
||||
command: Command to execute
|
||||
cwd: Working directory
|
||||
env: Environment variables
|
||||
timeout: Timeout in seconds (default: auto-background at 45s)
|
||||
shell: Shell to use (default: detected shell)
|
||||
|
||||
Returns:
|
||||
proc_id, exit_code, stdout_ref, stderr_ref
|
||||
"""
|
||||
if not command:
|
||||
raise InvalidParamsError("Command is required", param="command")
|
||||
|
||||
# Resolve shell
|
||||
shell_path = shell or self._shell
|
||||
|
||||
# Build environment
|
||||
process_env = os.environ.copy()
|
||||
if env:
|
||||
process_env.update(env)
|
||||
|
||||
# Get shell executor
|
||||
executor = get_shell_executor()
|
||||
|
||||
try:
|
||||
# Execute with auto-background support
|
||||
result = await executor.execute(
|
||||
command,
|
||||
shell=shell_path,
|
||||
cwd=cwd,
|
||||
env=process_env,
|
||||
timeout=timeout or AUTO_BACKGROUND_TIMEOUT,
|
||||
)
|
||||
|
||||
# Check if backgrounded
|
||||
if result.get("backgrounded"):
|
||||
proc_id = result.get("proc_id", "unknown")
|
||||
return {
|
||||
"proc_id": proc_id,
|
||||
"exit_code": None,
|
||||
"stdout_ref": f"proc:{proc_id}:stdout",
|
||||
"stderr_ref": f"proc:{proc_id}:stderr",
|
||||
"status": "running",
|
||||
"message": f"Command backgrounded after {timeout or AUTO_BACKGROUND_TIMEOUT}s. Use proc(action='logs', proc_id='{proc_id}') to view output.",
|
||||
}
|
||||
|
||||
# Command completed
|
||||
exit_code = result.get("return_code", result.get("exit_code", 0))
|
||||
stdout = result.get("stdout", "")
|
||||
stderr = result.get("stderr", "")
|
||||
|
||||
return {
|
||||
"proc_id": result.get("proc_id"),
|
||||
"exit_code": exit_code,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"duration_ms": result.get("duration_ms"),
|
||||
"status": "success" if exit_code == 0 else "failed",
|
||||
}
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
raise ToolError(
|
||||
code="TIMEOUT",
|
||||
message=f"Command timed out after {timeout}s",
|
||||
)
|
||||
except Exception as e:
|
||||
raise ToolError(
|
||||
code="INTERNAL_ERROR",
|
||||
message=f"Execution failed: {e}",
|
||||
)
|
||||
|
||||
@self.action("ps", "List processes")
|
||||
async def ps(
|
||||
ctx: MCPContext,
|
||||
proc_id: str | None = None,
|
||||
filter: str | None = None,
|
||||
) -> dict:
|
||||
"""List tracked processes.
|
||||
|
||||
Args:
|
||||
proc_id: Get specific process
|
||||
filter: Filter by command pattern
|
||||
|
||||
Returns:
|
||||
List of processes with status
|
||||
"""
|
||||
processes = []
|
||||
|
||||
for pid, info in self.process_manager.list_processes().items():
|
||||
# Filter by proc_id
|
||||
if proc_id and pid != proc_id:
|
||||
continue
|
||||
|
||||
# Filter by command pattern
|
||||
cmd = info.get("cmd", "")
|
||||
if filter and filter.lower() not in cmd.lower():
|
||||
continue
|
||||
|
||||
processes.append(
|
||||
{
|
||||
"proc_id": pid,
|
||||
"pid": info.get("pid"),
|
||||
"command": cmd,
|
||||
"running": info.get("running", False),
|
||||
"exit_code": info.get("return_code"),
|
||||
"started": info.get("started"),
|
||||
"log_file": info.get("log_file"),
|
||||
}
|
||||
)
|
||||
|
||||
if proc_id and not processes:
|
||||
raise NotFoundError(f"Process not found: {proc_id}")
|
||||
|
||||
return {
|
||||
"processes": processes,
|
||||
"total": len(processes),
|
||||
}
|
||||
|
||||
@self.action("kill", "Kill process")
|
||||
async def kill(
|
||||
ctx: MCPContext,
|
||||
proc_id: str,
|
||||
signal: str | int = "TERM",
|
||||
) -> dict:
|
||||
"""Kill a process.
|
||||
|
||||
Args:
|
||||
proc_id: Process ID to kill
|
||||
signal: Signal to send (default: TERM)
|
||||
|
||||
Returns:
|
||||
Success status
|
||||
"""
|
||||
if not proc_id:
|
||||
raise InvalidParamsError("proc_id is required", param="proc_id")
|
||||
|
||||
# Find process
|
||||
procs = self.process_manager.list_processes()
|
||||
if proc_id not in procs:
|
||||
raise NotFoundError(f"Process not found: {proc_id}")
|
||||
|
||||
info = procs[proc_id]
|
||||
pid = info.get("pid")
|
||||
|
||||
if not pid:
|
||||
raise ToolError(
|
||||
code="INTERNAL_ERROR",
|
||||
message="Process has no PID",
|
||||
)
|
||||
|
||||
# Resolve signal
|
||||
if isinstance(signal, str):
|
||||
signal_map = {
|
||||
"TERM": 15,
|
||||
"KILL": 9,
|
||||
"INT": 2,
|
||||
"HUP": 1,
|
||||
"QUIT": 3,
|
||||
}
|
||||
sig_num = signal_map.get(signal.upper(), 15)
|
||||
else:
|
||||
sig_num = signal
|
||||
|
||||
try:
|
||||
os.kill(pid, sig_num)
|
||||
return {
|
||||
"proc_id": proc_id,
|
||||
"pid": pid,
|
||||
"signal": sig_num,
|
||||
"killed": True,
|
||||
}
|
||||
except ProcessLookupError:
|
||||
return {
|
||||
"proc_id": proc_id,
|
||||
"pid": pid,
|
||||
"signal": sig_num,
|
||||
"killed": False,
|
||||
"message": "Process already terminated",
|
||||
}
|
||||
except PermissionError:
|
||||
raise ToolError(
|
||||
code="PERMISSION_DENIED",
|
||||
message=f"Cannot kill process {pid}: permission denied",
|
||||
)
|
||||
|
||||
@self.action("logs", "Get process logs")
|
||||
async def logs(
|
||||
ctx: MCPContext,
|
||||
proc_id: str,
|
||||
tail: int = 100,
|
||||
since: str | None = None,
|
||||
) -> dict:
|
||||
"""Get process stdout/stderr.
|
||||
|
||||
Args:
|
||||
proc_id: Process ID
|
||||
tail: Number of lines (default: 100)
|
||||
since: ISO timestamp to filter from
|
||||
|
||||
Returns:
|
||||
stdout and stderr content
|
||||
"""
|
||||
if not proc_id:
|
||||
raise InvalidParamsError("proc_id is required", param="proc_id")
|
||||
|
||||
procs = self.process_manager.list_processes()
|
||||
if proc_id not in procs:
|
||||
raise NotFoundError(f"Process not found: {proc_id}")
|
||||
|
||||
info = procs[proc_id]
|
||||
log_file = info.get("log_file")
|
||||
|
||||
if not log_file or not Path(log_file).exists():
|
||||
return {
|
||||
"proc_id": proc_id,
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
"message": "No log file available",
|
||||
}
|
||||
|
||||
try:
|
||||
from hanzo_async import read_file
|
||||
|
||||
content = await read_file(log_file, encoding="utf-8", errors="replace")
|
||||
|
||||
# Apply tail
|
||||
lines = content.splitlines()
|
||||
if tail and len(lines) > tail:
|
||||
lines = lines[-tail:]
|
||||
|
||||
return {
|
||||
"proc_id": proc_id,
|
||||
"output": "\n".join(lines),
|
||||
"running": info.get("running", False),
|
||||
"exit_code": info.get("return_code"),
|
||||
"total_lines": len(content.splitlines()),
|
||||
}
|
||||
except Exception as e:
|
||||
raise ToolError(
|
||||
code="INTERNAL_ERROR",
|
||||
message=f"Failed to read logs: {e}",
|
||||
)
|
||||
|
||||
def register(self, mcp_server: FastMCP) -> None:
|
||||
"""Register as 'proc' tool with MCP server."""
|
||||
tool_name = self.name
|
||||
tool_description = self.description
|
||||
|
||||
@mcp_server.tool(name=tool_name, description=tool_description)
|
||||
async def handler(
|
||||
ctx: MCPContext,
|
||||
action: str = "help",
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
result = await self.call(ctx, action=action, **kwargs)
|
||||
return json.dumps(result, indent=2, default=str)
|
||||
|
||||
|
||||
# Backward compatibility
|
||||
proc_tool = ProcTool
|
||||
@@ -1,212 +0,0 @@
|
||||
"""Process management tool (ps).
|
||||
|
||||
List, monitor, and control background processes.
|
||||
Clean UNIX-style API.
|
||||
"""
|
||||
|
||||
import signal
|
||||
import asyncio
|
||||
from typing import Any, Dict, List, Optional, Annotated, override
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pydantic import Field
|
||||
from mcp.server import FastMCP
|
||||
from hanzo_async import read_file, path_exists
|
||||
from mcp.server.fastmcp import Context as MCPContext
|
||||
|
||||
from hanzo_tools.core import BaseTool, auto_timeout
|
||||
from hanzo_tools.shell.base_process import ProcessManager
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessInfo:
|
||||
"""Process information."""
|
||||
|
||||
id: str
|
||||
pid: int
|
||||
cmd: str
|
||||
running: bool
|
||||
exit_code: Optional[int] = None
|
||||
started: Optional[datetime] = None
|
||||
log_file: Optional[Path] = None
|
||||
|
||||
|
||||
class PsTool(BaseTool):
|
||||
"""Process management - list, kill, logs.
|
||||
|
||||
USAGE:
|
||||
|
||||
ps() # List all processes
|
||||
ps(id="abc123") # Get specific process info
|
||||
ps(kill="abc123") # Kill process (SIGTERM)
|
||||
ps(kill="abc123", signal="KILL") # Kill with SIGKILL
|
||||
ps(logs="abc123") # Get process logs
|
||||
ps(logs="abc123", n=50) # Last 50 lines
|
||||
"""
|
||||
|
||||
name = "ps"
|
||||
|
||||
_instance: Optional["PsTool"] = None
|
||||
_process_manager: Optional[ProcessManager] = None
|
||||
|
||||
def __new__(cls):
|
||||
"""Singleton - share process manager across instances."""
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._process_manager = ProcessManager()
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.process_manager = self._process_manager
|
||||
|
||||
@property
|
||||
@override
|
||||
def description(self) -> str:
|
||||
return """Process management - list, kill, and read logs.
|
||||
|
||||
USAGE:
|
||||
ps List all background processes
|
||||
ps --id ID Get specific process info
|
||||
ps --kill ID Kill process (SIGTERM)
|
||||
ps --kill ID --sig 9 Kill with specific signal
|
||||
ps --logs ID Get process stdout/stderr
|
||||
ps --logs ID --n 50 Last N lines of logs"""
|
||||
|
||||
def _list_processes(self) -> List[ProcessInfo]:
|
||||
"""Get all tracked processes."""
|
||||
processes = []
|
||||
for proc_id, info in self.process_manager.list_processes().items():
|
||||
processes.append(
|
||||
ProcessInfo(
|
||||
id=proc_id,
|
||||
pid=info.get("pid", 0),
|
||||
cmd=info.get("cmd", ""),
|
||||
running=info.get("running", False),
|
||||
exit_code=info.get("return_code"),
|
||||
log_file=Path(info["log_file"]) if info.get("log_file") else None,
|
||||
)
|
||||
)
|
||||
return processes
|
||||
|
||||
def _get_process(self, proc_id: str) -> Optional[ProcessInfo]:
|
||||
"""Get specific process info."""
|
||||
for p in self._list_processes():
|
||||
if p.id == proc_id or str(p.pid) == proc_id:
|
||||
return p
|
||||
return None
|
||||
|
||||
async def _kill_process(self, proc_id: str, sig: int = signal.SIGTERM) -> str:
|
||||
"""Kill a process."""
|
||||
process = self.process_manager.get_process(proc_id)
|
||||
if not process:
|
||||
return f"Process not found: {proc_id}"
|
||||
|
||||
try:
|
||||
process.send_signal(sig)
|
||||
sig_name = signal.Signals(sig).name
|
||||
return f"Sent {sig_name} to {proc_id} (PID {process.pid})"
|
||||
except ProcessLookupError:
|
||||
return f"Process {proc_id} already terminated"
|
||||
except Exception as e:
|
||||
return f"Failed to kill {proc_id}: {e}"
|
||||
|
||||
async def _get_logs(self, proc_id: str, n: int = 100) -> str:
|
||||
"""Get process logs."""
|
||||
import asyncio
|
||||
|
||||
log_file = self.process_manager.get_log_file(proc_id)
|
||||
if not log_file:
|
||||
return f"No logs for process: {proc_id}"
|
||||
|
||||
# Check file exists asynchronously
|
||||
if not await path_exists(log_file):
|
||||
return f"No logs for process: {proc_id}"
|
||||
|
||||
try:
|
||||
content = await read_file(log_file)
|
||||
lines = content.splitlines()
|
||||
|
||||
if len(lines) > n:
|
||||
lines = lines[-n:]
|
||||
|
||||
return "\n".join(lines)
|
||||
except Exception as e:
|
||||
return f"Error reading logs: {e}"
|
||||
|
||||
def _format_list(self, processes: List[ProcessInfo]) -> str:
|
||||
"""Format process list for display."""
|
||||
if not processes:
|
||||
return "No background processes"
|
||||
|
||||
lines = ["PID ID STATUS CMD"]
|
||||
lines.append("-" * 60)
|
||||
|
||||
for p in processes:
|
||||
status = "running" if p.running else f"exit({p.exit_code})"
|
||||
cmd = p.cmd[:30] + "..." if len(p.cmd) > 30 else p.cmd
|
||||
lines.append(f"{p.pid:<7} {p.id:<15} {status:<9} {cmd}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@override
|
||||
@auto_timeout("ps")
|
||||
async def call(
|
||||
self,
|
||||
ctx: MCPContext,
|
||||
id: Optional[str] = None,
|
||||
kill: Optional[str] = None,
|
||||
logs: Optional[str] = None,
|
||||
sig: int = 15, # SIGTERM
|
||||
n: int = 100,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""Process management.
|
||||
|
||||
Args:
|
||||
ctx: MCP context
|
||||
id: Get specific process info
|
||||
kill: Kill process by ID
|
||||
logs: Get logs for process
|
||||
sig: Signal number for kill (default: 15/SIGTERM)
|
||||
n: Number of log lines to show
|
||||
|
||||
Returns:
|
||||
Process information or action result
|
||||
"""
|
||||
if kill:
|
||||
return await self._kill_process(kill, sig)
|
||||
|
||||
if logs:
|
||||
return await self._get_logs(logs, n)
|
||||
|
||||
if id:
|
||||
proc = self._get_process(id)
|
||||
if not proc:
|
||||
return f"Process not found: {id}"
|
||||
status = "running" if proc.running else f"exited ({proc.exit_code})"
|
||||
return f"ID: {proc.id}\nPID: {proc.pid}\nStatus: {status}\nCommand: {proc.cmd}"
|
||||
|
||||
return self._format_list(self._list_processes())
|
||||
|
||||
@override
|
||||
def register(self, mcp_server: FastMCP) -> None:
|
||||
"""Register ps tool with MCP server."""
|
||||
tool_self = self
|
||||
|
||||
@mcp_server.tool(name=self.name, description=self.description)
|
||||
async def ps_handler(
|
||||
id: Annotated[Optional[str], Field(description="Get specific process by ID", default=None)] = None,
|
||||
kill: Annotated[Optional[str], Field(description="Kill process by ID", default=None)] = None,
|
||||
logs: Annotated[Optional[str], Field(description="Get logs for process ID", default=None)] = None,
|
||||
sig: Annotated[int, Field(description="Signal for kill (default: 15/SIGTERM)", default=15)] = 15,
|
||||
n: Annotated[int, Field(description="Number of log lines", default=100)] = 100,
|
||||
ctx: MCPContext = None,
|
||||
) -> str:
|
||||
return await tool_self.call(ctx, id=id, kill=kill, logs=logs, sig=sig, n=n)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
ps_tool = PsTool()
|
||||
@@ -1,281 +0,0 @@
|
||||
"""Session storage for managing shell sessions.
|
||||
|
||||
This module provides session management with cleanup capabilities
|
||||
for shell processes and background tasks.
|
||||
|
||||
Usage:
|
||||
from hanzo_tools.shell.session_storage import SessionStorage
|
||||
|
||||
# Clean up expired sessions (older than max_age_seconds)
|
||||
SessionStorage.cleanup_expired_sessions(max_age_seconds=300)
|
||||
|
||||
# Clear all active sessions
|
||||
cleared = SessionStorage.clear_all_sessions()
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
import signal
|
||||
import threading
|
||||
from typing import Dict, ClassVar, Optional
|
||||
from pathlib import Path
|
||||
from dataclasses import field, dataclass
|
||||
|
||||
from hanzo_tools.shell.base_process import ProcessManager
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionInfo:
|
||||
"""Information about a shell session."""
|
||||
|
||||
session_id: str
|
||||
pid: int
|
||||
command: str
|
||||
created_at: float = field(default_factory=time.time)
|
||||
last_activity: float = field(default_factory=time.time)
|
||||
log_file: Optional[Path] = None
|
||||
|
||||
|
||||
class SessionStorage:
|
||||
"""Singleton storage for managing shell sessions.
|
||||
|
||||
Provides centralized tracking and cleanup of shell processes
|
||||
to prevent resource leaks and orphaned processes.
|
||||
"""
|
||||
|
||||
_instance: ClassVar[Optional["SessionStorage"]] = None
|
||||
_lock: ClassVar[threading.Lock] = threading.Lock()
|
||||
_sessions: ClassVar[Dict[str, SessionInfo]] = {}
|
||||
_process_manager: ClassVar[Optional[ProcessManager]] = None
|
||||
|
||||
def __new__(cls) -> "SessionStorage":
|
||||
"""Singleton pattern for session storage."""
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._process_manager = ProcessManager()
|
||||
return cls._instance
|
||||
|
||||
@classmethod
|
||||
def add_session(
|
||||
cls,
|
||||
session_id: str,
|
||||
pid: int,
|
||||
command: str,
|
||||
log_file: Optional[Path] = None,
|
||||
) -> SessionInfo:
|
||||
"""Add a new session to storage.
|
||||
|
||||
Args:
|
||||
session_id: Unique identifier for the session
|
||||
pid: Process ID of the session
|
||||
command: Command that started the session
|
||||
log_file: Optional path to session log file
|
||||
|
||||
Returns:
|
||||
SessionInfo for the new session
|
||||
"""
|
||||
with cls._lock:
|
||||
session = SessionInfo(
|
||||
session_id=session_id,
|
||||
pid=pid,
|
||||
command=command,
|
||||
log_file=log_file,
|
||||
)
|
||||
cls._sessions[session_id] = session
|
||||
return session
|
||||
|
||||
@classmethod
|
||||
def remove_session(cls, session_id: str) -> Optional[SessionInfo]:
|
||||
"""Remove a session from storage.
|
||||
|
||||
Args:
|
||||
session_id: Session identifier to remove
|
||||
|
||||
Returns:
|
||||
Removed SessionInfo or None if not found
|
||||
"""
|
||||
with cls._lock:
|
||||
return cls._sessions.pop(session_id, None)
|
||||
|
||||
@classmethod
|
||||
def get_session(cls, session_id: str) -> Optional[SessionInfo]:
|
||||
"""Get a session by ID.
|
||||
|
||||
Args:
|
||||
session_id: Session identifier
|
||||
|
||||
Returns:
|
||||
SessionInfo or None if not found
|
||||
"""
|
||||
with cls._lock:
|
||||
return cls._sessions.get(session_id)
|
||||
|
||||
@classmethod
|
||||
def list_sessions(cls) -> Dict[str, SessionInfo]:
|
||||
"""List all active sessions.
|
||||
|
||||
Returns:
|
||||
Dictionary of session_id -> SessionInfo
|
||||
"""
|
||||
with cls._lock:
|
||||
return cls._sessions.copy()
|
||||
|
||||
@classmethod
|
||||
def update_activity(cls, session_id: str) -> bool:
|
||||
"""Update last activity timestamp for a session.
|
||||
|
||||
Args:
|
||||
session_id: Session to update
|
||||
|
||||
Returns:
|
||||
True if session exists and was updated
|
||||
"""
|
||||
with cls._lock:
|
||||
if session_id in cls._sessions:
|
||||
cls._sessions[session_id].last_activity = time.time()
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def cleanup_expired_sessions(cls, max_age_seconds: int = 300) -> int:
|
||||
"""Clean up sessions older than max_age_seconds.
|
||||
|
||||
This method terminates processes for expired sessions and
|
||||
removes them from storage.
|
||||
|
||||
Args:
|
||||
max_age_seconds: Maximum session age in seconds (default: 5 minutes)
|
||||
|
||||
Returns:
|
||||
Number of sessions cleaned up
|
||||
"""
|
||||
current_time = time.time()
|
||||
expired_ids = []
|
||||
|
||||
with cls._lock:
|
||||
for session_id, session in cls._sessions.items():
|
||||
age = current_time - session.last_activity
|
||||
if age > max_age_seconds:
|
||||
expired_ids.append(session_id)
|
||||
|
||||
cleaned = 0
|
||||
for session_id in expired_ids:
|
||||
if cls._terminate_session(session_id):
|
||||
cleaned += 1
|
||||
|
||||
return cleaned
|
||||
|
||||
@classmethod
|
||||
def clear_all_sessions(cls) -> int:
|
||||
"""Clear all active sessions.
|
||||
|
||||
Terminates all tracked processes and clears storage.
|
||||
|
||||
Returns:
|
||||
Number of sessions cleared
|
||||
"""
|
||||
with cls._lock:
|
||||
session_ids = list(cls._sessions.keys())
|
||||
|
||||
cleared = 0
|
||||
for session_id in session_ids:
|
||||
if cls._terminate_session(session_id):
|
||||
cleared += 1
|
||||
|
||||
# Also clean up any processes tracked by ProcessManager
|
||||
if cls._process_manager:
|
||||
for proc_id in list(cls._process_manager.list_processes().keys()):
|
||||
proc = cls._process_manager.get_process(proc_id)
|
||||
if proc and proc.returncode is None:
|
||||
try:
|
||||
proc.terminate()
|
||||
cleared += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return cleared
|
||||
|
||||
@classmethod
|
||||
def _terminate_session(cls, session_id: str) -> bool:
|
||||
"""Terminate a session's process and remove from storage.
|
||||
|
||||
Args:
|
||||
session_id: Session to terminate
|
||||
|
||||
Returns:
|
||||
True if session was terminated successfully
|
||||
"""
|
||||
session = cls.remove_session(session_id)
|
||||
if session is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
# Try to terminate the process gracefully
|
||||
os.kill(session.pid, signal.SIGTERM)
|
||||
|
||||
# Give it a moment to terminate
|
||||
time.sleep(0.1)
|
||||
|
||||
# Force kill if still running
|
||||
try:
|
||||
os.kill(session.pid, 0) # Check if process exists
|
||||
os.kill(session.pid, signal.SIGKILL)
|
||||
except OSError:
|
||||
pass # Process already terminated
|
||||
|
||||
return True
|
||||
|
||||
except OSError:
|
||||
# Process doesn't exist or permission denied
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def get_stats(cls) -> dict:
|
||||
"""Get session storage statistics.
|
||||
|
||||
Returns:
|
||||
Dictionary with stats about sessions
|
||||
"""
|
||||
with cls._lock:
|
||||
sessions = list(cls._sessions.values())
|
||||
|
||||
if not sessions:
|
||||
return {
|
||||
"total_sessions": 0,
|
||||
"oldest_session_age": 0,
|
||||
"newest_session_age": 0,
|
||||
}
|
||||
|
||||
current_time = time.time()
|
||||
ages = [current_time - s.last_activity for s in sessions]
|
||||
|
||||
return {
|
||||
"total_sessions": len(sessions),
|
||||
"oldest_session_age": max(ages) if ages else 0,
|
||||
"newest_session_age": min(ages) if ages else 0,
|
||||
}
|
||||
|
||||
|
||||
# Module-level convenience functions
|
||||
def cleanup_expired_sessions(max_age_seconds: int = 300) -> int:
|
||||
"""Clean up expired sessions."""
|
||||
return SessionStorage.cleanup_expired_sessions(max_age_seconds)
|
||||
|
||||
|
||||
def clear_all_sessions() -> int:
|
||||
"""Clear all sessions."""
|
||||
return SessionStorage.clear_all_sessions()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SessionStorage",
|
||||
"SessionInfo",
|
||||
"cleanup_expired_sessions",
|
||||
"clear_all_sessions",
|
||||
]
|
||||
@@ -1,410 +0,0 @@
|
||||
"""Shell detection for hanzo-mcp.
|
||||
|
||||
Detects the user's active shell and login shell to expose only
|
||||
the relevant shell tool via MCP. On Mac with Homebrew, this will
|
||||
detect and use /opt/homebrew/bin/zsh if that's what the user has
|
||||
configured.
|
||||
|
||||
Environment variables for override:
|
||||
- HANZO_MCP_SHELL: Force a specific shell (e.g., "zsh", "bash")
|
||||
- HANZO_MCP_FORCE_SHELL: Force a specific shell path (e.g., "/opt/homebrew/bin/zsh")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import platform
|
||||
import subprocess
|
||||
from typing import Dict, List, Tuple, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
# Common shells (names you might see from `ps -o comm=`) and typical full paths.
|
||||
KNOWN_SHELL_NAMES = {
|
||||
"zsh",
|
||||
"bash",
|
||||
"fish",
|
||||
"sh",
|
||||
"ksh",
|
||||
"tcsh",
|
||||
"csh",
|
||||
"dash",
|
||||
"nu",
|
||||
"xonsh",
|
||||
"pwsh",
|
||||
"powershell",
|
||||
}
|
||||
KNOWN_SHELL_PATH_BASENAMES = KNOWN_SHELL_NAMES | {"busybox"} # sometimes sh is busybox
|
||||
|
||||
# Shells we support with dedicated tools
|
||||
SUPPORTED_SHELLS = {"zsh", "bash", "fish", "dash", "ksh", "tcsh", "csh"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ShellInfo:
|
||||
"""Information about detected shells."""
|
||||
|
||||
# "Login shell" as configured on the user account (what ssh/login uses).
|
||||
login_shell: Optional[str]
|
||||
# The shell that appears to have invoked this process (best-effort).
|
||||
invoking_shell: Optional[str]
|
||||
# $SHELL (often, but not always, matches login shell)
|
||||
env_shell: Optional[str]
|
||||
# Debug evidence for logging / telemetry.
|
||||
evidence: Dict[str, str]
|
||||
|
||||
|
||||
def _run(cmd: List[str]) -> Tuple[int, str, str]:
|
||||
"""Run a command and return (returncode, stdout, stderr)."""
|
||||
try:
|
||||
p = subprocess.run(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
return p.returncode, p.stdout.strip(), p.stderr.strip()
|
||||
except Exception as e:
|
||||
return 127, "", f"{type(e).__name__}: {e}"
|
||||
|
||||
|
||||
def _which(cmd: str) -> Optional[str]:
|
||||
"""Find command in PATH."""
|
||||
paths = os.environ.get("PATH", "").split(os.pathsep)
|
||||
for d in paths:
|
||||
p = os.path.join(d, cmd)
|
||||
if os.path.isfile(p) and os.access(p, os.X_OK):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def _parse_passwd_line(line: str) -> Optional[str]:
|
||||
"""Parse passwd format: name:pw:uid:gid:gecos:dir:shell"""
|
||||
parts = line.split(":")
|
||||
if len(parts) >= 7:
|
||||
shell = parts[6].strip()
|
||||
return shell or None
|
||||
return None
|
||||
|
||||
|
||||
def get_login_shell() -> Tuple[Optional[str], Dict[str, str]]:
|
||||
"""
|
||||
Tries multiple sources to determine the *account login shell*.
|
||||
Order of preference:
|
||||
1) Python's pwd database (POSIX)
|
||||
2) macOS: `id -P user` (NSS/passwd view)
|
||||
3) Linux: `getent passwd user`
|
||||
"""
|
||||
evidence: Dict[str, str] = {}
|
||||
user = os.environ.get("USER") or os.environ.get("LOGNAME") or ""
|
||||
|
||||
# POSIX: pwd module uses NSS; on macOS it generally reflects Directory Services.
|
||||
if os.name == "posix":
|
||||
try:
|
||||
import pwd
|
||||
|
||||
if user:
|
||||
shell = pwd.getpwnam(user).pw_shell
|
||||
evidence["pwd.getpwnam"] = shell
|
||||
if shell:
|
||||
return shell, evidence
|
||||
except Exception as e:
|
||||
evidence["pwd.getpwnam_error"] = f"{type(e).__name__}: {e}"
|
||||
|
||||
system = platform.system().lower()
|
||||
|
||||
# macOS: `id -P user` prints passwd-style record.
|
||||
if system == "darwin" and user:
|
||||
rc, out, err = _run(["id", "-P", user])
|
||||
evidence["id_-P_rc"] = str(rc)
|
||||
if err:
|
||||
evidence["id_-P_err"] = err
|
||||
if rc == 0 and out:
|
||||
evidence["id_-P_out"] = out
|
||||
# id -P output is colon-separated, shell is the last field.
|
||||
shell = out.split(":")[-1].strip() or None
|
||||
if shell:
|
||||
return shell, evidence
|
||||
|
||||
# Linux/BSD: getent is the most robust CLI view into NSS.
|
||||
if user and _which("getent"):
|
||||
rc, out, err = _run(["getent", "passwd", user])
|
||||
evidence["getent_rc"] = str(rc)
|
||||
if err:
|
||||
evidence["getent_err"] = err
|
||||
if rc == 0 and out:
|
||||
evidence["getent_out"] = out
|
||||
shell = _parse_passwd_line(out)
|
||||
if shell:
|
||||
return shell, evidence
|
||||
|
||||
# As a last resort: $SHELL (not authoritative for login shell)
|
||||
env_shell = os.environ.get("SHELL")
|
||||
if env_shell:
|
||||
evidence["fallback_env_SHELL"] = env_shell
|
||||
return env_shell, evidence
|
||||
|
||||
|
||||
def _ps_comm(pid: int) -> Optional[str]:
|
||||
"""Get executable name for a process."""
|
||||
rc, out, _ = _run(["ps", "-p", str(pid), "-o", "comm="])
|
||||
if rc == 0 and out:
|
||||
return out.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _ps_args(pid: int) -> Optional[str]:
|
||||
"""Get full command line for a process."""
|
||||
rc, out, _ = _run(["ps", "-p", str(pid), "-o", "args="])
|
||||
if rc == 0 and out:
|
||||
return out.strip()
|
||||
return None
|
||||
|
||||
|
||||
def get_invoking_shell(max_hops: int = 12) -> Tuple[Optional[str], Dict[str, str]]:
|
||||
"""
|
||||
Best-effort guess of the *shell that invoked this process* by walking parent PIDs.
|
||||
This is useful if hanzo-mcp is launched from a user's interactive shell.
|
||||
If your process is daemonized / launched by a service manager, this may return None.
|
||||
"""
|
||||
evidence: Dict[str, str] = {}
|
||||
if os.name != "posix":
|
||||
return None, {"note": "invoking shell detection is POSIX-only"}
|
||||
|
||||
pid = os.getpid()
|
||||
ppid = os.getppid()
|
||||
evidence["self_pid"] = str(pid)
|
||||
evidence["self_ppid"] = str(ppid)
|
||||
|
||||
# Walk parent chain looking for a known shell.
|
||||
cur = ppid
|
||||
for hop in range(max_hops):
|
||||
comm = _ps_comm(cur) or ""
|
||||
args = _ps_args(cur) or ""
|
||||
evidence[f"hop_{hop}_pid"] = str(cur)
|
||||
if comm:
|
||||
evidence[f"hop_{hop}_comm"] = comm
|
||||
if args:
|
||||
evidence[f"hop_{hop}_args"] = args
|
||||
|
||||
base = os.path.basename(comm).strip()
|
||||
if base in KNOWN_SHELL_PATH_BASENAMES:
|
||||
# Try to return a plausible full path if args contains one.
|
||||
if os.path.isabs(comm):
|
||||
return comm, evidence
|
||||
# Pull first token from args if it looks like a path to shell.
|
||||
try:
|
||||
argv0 = shlex.split(args)[0]
|
||||
except Exception:
|
||||
argv0 = ""
|
||||
if argv0 and (os.path.isabs(argv0) or os.path.basename(argv0) in KNOWN_SHELL_NAMES):
|
||||
return argv0, evidence
|
||||
return comm, evidence
|
||||
|
||||
# Next parent: use `ps -o ppid=` to get parent of parent.
|
||||
rc, out, _ = _run(["ps", "-p", str(cur), "-o", "ppid="])
|
||||
if rc != 0 or not out.strip():
|
||||
break
|
||||
next_ppid = int(out.strip())
|
||||
if next_ppid <= 1 or next_ppid == cur:
|
||||
break
|
||||
cur = next_ppid
|
||||
|
||||
return None, evidence
|
||||
|
||||
|
||||
def normalize_shell(shell: Optional[str]) -> Optional[str]:
|
||||
"""Normalize shell path - return as-is if valid."""
|
||||
if not shell:
|
||||
return None
|
||||
shell = shell.strip()
|
||||
return shell or None
|
||||
|
||||
|
||||
def shell_basename(shell: Optional[str]) -> Optional[str]:
|
||||
"""Get the basename of a shell path (e.g., '/opt/homebrew/bin/zsh' -> 'zsh')."""
|
||||
if not shell:
|
||||
return None
|
||||
return os.path.basename(shell).strip() or None
|
||||
|
||||
|
||||
def choose_default_shell(info: ShellInfo) -> str:
|
||||
"""
|
||||
Policy: prefer invoking shell if detected; else login shell; else $SHELL; else /bin/sh.
|
||||
"""
|
||||
for candidate in (info.invoking_shell, info.login_shell, info.env_shell):
|
||||
c = normalize_shell(candidate)
|
||||
if c:
|
||||
return c
|
||||
# Very conservative fallback
|
||||
return "C:\\Windows\\System32\\cmd.exe" if os.name == "nt" else "/bin/sh"
|
||||
|
||||
|
||||
def detect_shells() -> ShellInfo:
|
||||
"""Detect all shell information."""
|
||||
login_shell, login_ev = get_login_shell()
|
||||
invoking_shell, inv_ev = get_invoking_shell()
|
||||
env_shell = os.environ.get("SHELL")
|
||||
|
||||
evidence: Dict[str, str] = {}
|
||||
evidence.update({f"login.{k}": v for k, v in login_ev.items()})
|
||||
evidence.update({f"invoking.{k}": v for k, v in inv_ev.items()})
|
||||
if env_shell:
|
||||
evidence["env.SHELL"] = env_shell
|
||||
|
||||
return ShellInfo(
|
||||
login_shell=normalize_shell(login_shell),
|
||||
invoking_shell=normalize_shell(invoking_shell),
|
||||
env_shell=normalize_shell(env_shell),
|
||||
evidence=evidence,
|
||||
)
|
||||
|
||||
|
||||
def resolve_shell_path(shell_name: str) -> Optional[str]:
|
||||
"""
|
||||
Resolve a shell name to its full path, preferring Homebrew on macOS.
|
||||
|
||||
Args:
|
||||
shell_name: Shell name (e.g., 'zsh', 'bash')
|
||||
|
||||
Returns:
|
||||
Full path to shell or None if not found
|
||||
"""
|
||||
# Check environment override first
|
||||
force_shell = os.environ.get("HANZO_MCP_FORCE_SHELL")
|
||||
if force_shell:
|
||||
if os.path.isfile(force_shell) and os.access(force_shell, os.X_OK):
|
||||
return force_shell
|
||||
|
||||
# Priority paths - Homebrew first on macOS
|
||||
search_paths = [
|
||||
f"/opt/homebrew/bin/{shell_name}", # Apple Silicon Homebrew
|
||||
f"/usr/local/bin/{shell_name}", # Intel Homebrew
|
||||
f"/bin/{shell_name}", # System
|
||||
f"/usr/bin/{shell_name}", # System alternative
|
||||
]
|
||||
|
||||
for path in search_paths:
|
||||
if os.path.isfile(path) and os.access(path, os.X_OK):
|
||||
return path
|
||||
|
||||
# Fallback to which
|
||||
return shutil.which(shell_name)
|
||||
|
||||
|
||||
def get_active_shell() -> Tuple[str, str]:
|
||||
"""
|
||||
Get the user's active shell name and path.
|
||||
|
||||
Respects environment overrides:
|
||||
- HANZO_MCP_SHELL: Force shell name (e.g., "zsh")
|
||||
- HANZO_MCP_FORCE_SHELL: Force shell path (e.g., "/opt/homebrew/bin/zsh")
|
||||
|
||||
Returns:
|
||||
Tuple of (shell_name, shell_path)
|
||||
e.g., ("zsh", "/opt/homebrew/bin/zsh")
|
||||
"""
|
||||
# Check for explicit override
|
||||
override_shell = os.environ.get("HANZO_MCP_SHELL")
|
||||
if override_shell:
|
||||
override_shell = override_shell.strip().lower()
|
||||
if override_shell in SUPPORTED_SHELLS:
|
||||
path = resolve_shell_path(override_shell)
|
||||
if path:
|
||||
return override_shell, path
|
||||
|
||||
# Check for explicit path override
|
||||
force_path = os.environ.get("HANZO_MCP_FORCE_SHELL")
|
||||
if force_path and os.path.isfile(force_path) and os.access(force_path, os.X_OK):
|
||||
name = shell_basename(force_path)
|
||||
if name and name in SUPPORTED_SHELLS:
|
||||
return name, force_path
|
||||
# If it's an unknown shell, still use it but expose as "shell"
|
||||
return name or "shell", force_path
|
||||
|
||||
# Detect from environment
|
||||
info = detect_shells()
|
||||
chosen_path = choose_default_shell(info)
|
||||
chosen_name = shell_basename(chosen_path)
|
||||
|
||||
# Map to supported shell name
|
||||
if chosen_name in SUPPORTED_SHELLS:
|
||||
# Resolve to best available path (prefer Homebrew)
|
||||
best_path = resolve_shell_path(chosen_name)
|
||||
return chosen_name, best_path or chosen_path
|
||||
|
||||
# Fallback: if detected shell isn't supported, default to zsh
|
||||
for fallback in ["zsh", "bash", "fish", "dash"]:
|
||||
path = resolve_shell_path(fallback)
|
||||
if path:
|
||||
return fallback, path
|
||||
|
||||
# Ultimate fallback
|
||||
return "sh", "/bin/sh"
|
||||
|
||||
|
||||
def get_shell_tool_class(shell_name: str):
|
||||
"""
|
||||
Get the appropriate shell tool class for the given shell name.
|
||||
|
||||
Args:
|
||||
shell_name: Shell name (e.g., 'zsh', 'bash')
|
||||
|
||||
Returns:
|
||||
Shell tool class or None if not supported
|
||||
"""
|
||||
# Import here to avoid circular imports
|
||||
from hanzo_tools.shell.shell_tools import CshTool, KshTool, ZshTool, BashTool, DashTool, FishTool, TcshTool
|
||||
|
||||
shell_map = {
|
||||
"zsh": ZshTool,
|
||||
"bash": BashTool,
|
||||
"fish": FishTool,
|
||||
"dash": DashTool,
|
||||
"ksh": KshTool,
|
||||
"ksh93": KshTool, # Alias
|
||||
"pdksh": KshTool, # Alias
|
||||
"mksh": KshTool, # Alias
|
||||
"tcsh": TcshTool,
|
||||
"csh": CshTool,
|
||||
}
|
||||
|
||||
return shell_map.get(shell_name.lower())
|
||||
|
||||
|
||||
# Cache for shell detection (avoid repeated subprocess calls)
|
||||
_cached_shell: Optional[Tuple[str, str]] = None
|
||||
|
||||
|
||||
def get_cached_active_shell() -> Tuple[str, str]:
|
||||
"""Get cached active shell info (detects once per process)."""
|
||||
global _cached_shell
|
||||
if _cached_shell is None:
|
||||
_cached_shell = get_active_shell()
|
||||
return _cached_shell
|
||||
|
||||
|
||||
def clear_shell_cache():
|
||||
"""Clear the shell detection cache (useful for testing)."""
|
||||
global _cached_shell
|
||||
_cached_shell = None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
info = detect_shells()
|
||||
shell_name, shell_path = get_active_shell()
|
||||
|
||||
print("=== Shell Detection ===")
|
||||
print(f"login_shell : {info.login_shell}")
|
||||
print(f"invoking_shell: {info.invoking_shell}")
|
||||
print(f"env_shell : {info.env_shell}")
|
||||
print(f"")
|
||||
print(f"=== Active Shell ===")
|
||||
print(f"name : {shell_name}")
|
||||
print(f"path : {shell_path}")
|
||||
print(f"")
|
||||
print(f"=== Environment ===")
|
||||
print(f"HANZO_MCP_SHELL : {os.environ.get('HANZO_MCP_SHELL', '(not set)')}")
|
||||
print(f"HANZO_MCP_FORCE_SHELL: {os.environ.get('HANZO_MCP_FORCE_SHELL', '(not set)')}")
|
||||
@@ -1,652 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,498 +0,0 @@
|
||||
"""Shellflow - minimal DSL for DAG execution.
|
||||
|
||||
Syntax:
|
||||
A ; B ; C → sequential (do)
|
||||
{ A & B & C } → parallel (all)
|
||||
A ; { B & C } ; D → mixed
|
||||
|
||||
Compiles to JSON AST:
|
||||
{"type": "do", "steps": ["A", {"type": "all", "steps": ["B", "C"]}, "D"]}
|
||||
|
||||
Examples:
|
||||
mkdir -p dist ; { cp a.txt dist/ & cp b.txt dist/ } ; zip -r out.zip dist/
|
||||
|
||||
Performance:
|
||||
Optimized for high throughput with:
|
||||
- Precompiled regex patterns
|
||||
- Local variable caching
|
||||
- Fast-path for simple commands
|
||||
- LRU cache for repeated patterns
|
||||
- Full type annotations for mypyc compilation
|
||||
|
||||
Compile with mypyc for 2-5x speedup:
|
||||
mypyc hanzo_tools/shell/shellflow.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, List, Final, Tuple, Union
|
||||
from functools import lru_cache # noqa: TID251 - not using Stainless SDK
|
||||
|
||||
# Type aliases with full annotations
|
||||
ASTDict = Dict[str, Any]
|
||||
ASTNode = Union[str, ASTDict]
|
||||
ASTTuple = Tuple[str, Tuple[Any, ...]]
|
||||
TokenList = List[str]
|
||||
CommandList = List[Any]
|
||||
|
||||
# Constants
|
||||
TYPE_DO: Final[str] = "do"
|
||||
TYPE_ALL: Final[str] = "all"
|
||||
|
||||
# Precompiled regex for performance
|
||||
# Match: quoted strings, braces, semicolon, shell operators, single &
|
||||
_TOKEN_PATTERN: Final[str] = r"""
|
||||
(?P<dquote>"(?:[^"\\]|\\.)*") # Double-quoted string
|
||||
|(?P<squote>'(?:[^'\\]|\\.)*') # Single-quoted string
|
||||
|(?P<lbrace>\{) # Left brace
|
||||
|(?P<rbrace>\}) # Right brace
|
||||
|(?P<semi>;) # Semicolon
|
||||
|(?P<shell_op>&&|\|\|) # Shell operators (keep together)
|
||||
|(?P<and>&) # Single & (parallel separator)
|
||||
|(?P<text>[^{};'"&|]+) # Other text (no & or |)
|
||||
|(?P<pipe>\|(?!\|)) # Single | (not ||)
|
||||
"""
|
||||
|
||||
_TOKEN_RE: Final = re.compile(_TOKEN_PATTERN, re.VERBOSE)
|
||||
|
||||
# Preserved token types (tuple for faster `in` check than frozenset for small n)
|
||||
_PRESERVED_KINDS: Final[Tuple[str, ...]] = ("dquote", "squote", "shell_op", "pipe")
|
||||
|
||||
# Local reference for hot path
|
||||
_finditer = _TOKEN_RE.finditer
|
||||
|
||||
|
||||
def parse(source: str) -> ASTDict:
|
||||
"""Parse shellflow source to AST.
|
||||
|
||||
Args:
|
||||
source: Shellflow source string
|
||||
|
||||
Returns:
|
||||
AST in object form: {"type": "do"|"all", "steps": [...]}
|
||||
"""
|
||||
# Strip and fast-path empty
|
||||
source = source.strip()
|
||||
if not source:
|
||||
return {"type": TYPE_DO, "steps": []}
|
||||
|
||||
# Fast path: single simple command (no operators)
|
||||
# Using 'in' is faster than regex for simple checks
|
||||
if ";" not in source and "{" not in source and "&" not in source:
|
||||
return {"type": TYPE_DO, "steps": [source]}
|
||||
|
||||
# Tokenize and parse
|
||||
tokens: TokenList = _tokenize_fast(source)
|
||||
ast: ASTDict = _parse_tokens(tokens)
|
||||
return _normalize(ast)
|
||||
|
||||
|
||||
def _tokenize_fast(source: str) -> TokenList:
|
||||
"""Tokenize shellflow into commands and operators.
|
||||
|
||||
Optimized with local variable caching and type hints for mypyc.
|
||||
"""
|
||||
tokens: TokenList = []
|
||||
tokens_append = tokens.append
|
||||
current_parts: List[str] = []
|
||||
parts_append = current_parts.append
|
||||
depth: int = 0
|
||||
|
||||
for match in _finditer(source):
|
||||
kind: str | None = match.lastgroup
|
||||
value: str = match.group()
|
||||
|
||||
if kind in _PRESERVED_KINDS:
|
||||
parts_append(value)
|
||||
elif kind == "lbrace":
|
||||
if depth == 0 and current_parts:
|
||||
cmd: str = "".join(current_parts).strip()
|
||||
if cmd:
|
||||
tokens_append(cmd)
|
||||
current_parts = []
|
||||
parts_append = current_parts.append
|
||||
depth += 1
|
||||
parts_append(value)
|
||||
elif kind == "rbrace":
|
||||
parts_append(value)
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
block: str = "".join(current_parts).strip()
|
||||
if block:
|
||||
tokens_append(block)
|
||||
current_parts = []
|
||||
parts_append = current_parts.append
|
||||
elif kind == "semi" and depth == 0:
|
||||
cmd = "".join(current_parts).strip()
|
||||
if cmd:
|
||||
tokens_append(cmd)
|
||||
tokens_append(";")
|
||||
current_parts = []
|
||||
parts_append = current_parts.append
|
||||
elif kind == "and" and depth == 0:
|
||||
cmd = "".join(current_parts).strip()
|
||||
if cmd:
|
||||
tokens_append(cmd)
|
||||
tokens_append("&")
|
||||
current_parts = []
|
||||
parts_append = current_parts.append
|
||||
else:
|
||||
parts_append(value)
|
||||
|
||||
# Flush remaining
|
||||
if current_parts:
|
||||
cmd = "".join(current_parts).strip()
|
||||
if cmd:
|
||||
tokens_append(cmd)
|
||||
|
||||
return tokens
|
||||
|
||||
|
||||
def _tokenize_parallel_fast(source: str) -> TokenList:
|
||||
"""Tokenize content inside { } splitting on & (but not &&).
|
||||
|
||||
Optimized version with full type hints.
|
||||
"""
|
||||
tokens: TokenList = []
|
||||
tokens_append = tokens.append
|
||||
current_parts: List[str] = []
|
||||
parts_append = current_parts.append
|
||||
depth: int = 0
|
||||
|
||||
for match in _finditer(source):
|
||||
kind: str | None = match.lastgroup
|
||||
value: str = match.group()
|
||||
|
||||
if kind in _PRESERVED_KINDS:
|
||||
parts_append(value)
|
||||
elif kind == "lbrace":
|
||||
depth += 1
|
||||
parts_append(value)
|
||||
elif kind == "rbrace":
|
||||
depth -= 1
|
||||
parts_append(value)
|
||||
elif kind == "and" and depth == 0:
|
||||
cmd: str = "".join(current_parts).strip()
|
||||
if cmd:
|
||||
tokens_append(cmd)
|
||||
current_parts = []
|
||||
parts_append = current_parts.append
|
||||
else:
|
||||
parts_append(value)
|
||||
|
||||
if current_parts:
|
||||
cmd = "".join(current_parts).strip()
|
||||
if cmd:
|
||||
tokens_append(cmd)
|
||||
|
||||
return tokens
|
||||
|
||||
|
||||
def _parse_tokens(tokens: TokenList) -> ASTDict:
|
||||
"""Parse token list to AST."""
|
||||
if not tokens:
|
||||
return {"type": TYPE_DO, "steps": []}
|
||||
|
||||
# Check if this is a single block
|
||||
if len(tokens) == 1:
|
||||
t: str = tokens[0]
|
||||
if t.startswith("{") and t.endswith("}"):
|
||||
inner: str = t[1:-1].strip()
|
||||
inner_tokens: TokenList = _tokenize_parallel_fast(inner)
|
||||
steps: List[ASTNode] = [_parse_single(tok) for tok in inner_tokens if tok and tok != "&"]
|
||||
return {"type": TYPE_ALL, "steps": steps}
|
||||
|
||||
# Parse as sequential
|
||||
steps = []
|
||||
steps_append = steps.append
|
||||
|
||||
for token in tokens:
|
||||
if token == ";":
|
||||
continue
|
||||
elif token.startswith("{") and token.endswith("}"):
|
||||
inner = token[1:-1].strip()
|
||||
inner_tokens = _tokenize_parallel_fast(inner)
|
||||
inner_steps: List[ASTNode] = [_parse_single(tok) for tok in inner_tokens if tok and tok != "&"]
|
||||
if len(inner_steps) == 1:
|
||||
steps_append(inner_steps[0])
|
||||
else:
|
||||
steps_append({"type": TYPE_ALL, "steps": inner_steps})
|
||||
else:
|
||||
steps_append(token)
|
||||
|
||||
if len(steps) == 1:
|
||||
s: ASTNode = steps[0]
|
||||
if isinstance(s, dict):
|
||||
return s
|
||||
return {"type": TYPE_DO, "steps": steps}
|
||||
|
||||
return {"type": TYPE_DO, "steps": steps}
|
||||
|
||||
|
||||
def _parse_single(token: str) -> ASTNode:
|
||||
"""Parse a single token (command or nested block)."""
|
||||
token = token.strip()
|
||||
if token.startswith("{") and token.endswith("}"):
|
||||
inner: str = token[1:-1].strip()
|
||||
inner_tokens: TokenList = _tokenize_parallel_fast(inner)
|
||||
steps: List[ASTNode] = [_parse_single(t) for t in inner_tokens if t]
|
||||
return {"type": TYPE_ALL, "steps": steps}
|
||||
return token
|
||||
|
||||
|
||||
def _normalize(ast: ASTNode) -> ASTDict:
|
||||
"""Normalize AST: flatten nested do/all, drop singletons."""
|
||||
if isinstance(ast, str):
|
||||
return {"type": TYPE_DO, "steps": [ast]}
|
||||
|
||||
node_type: str = ast.get("type", TYPE_DO)
|
||||
steps: List[ASTNode] = ast.get("steps", [])
|
||||
|
||||
# Recursively normalize children
|
||||
normalized_steps: List[ASTNode] = []
|
||||
normalized_append = normalized_steps.append
|
||||
|
||||
for step in steps:
|
||||
if isinstance(step, dict):
|
||||
step = _normalize(step)
|
||||
# Flatten same-type nesting
|
||||
step_type: str = step.get("type", "")
|
||||
if step_type == node_type:
|
||||
normalized_steps.extend(step.get("steps", []))
|
||||
else:
|
||||
normalized_append(step)
|
||||
elif step:
|
||||
normalized_append(step)
|
||||
|
||||
# Drop singleton wrappers
|
||||
if len(normalized_steps) == 1:
|
||||
ns: ASTNode = normalized_steps[0]
|
||||
if isinstance(ns, dict):
|
||||
return ns
|
||||
return {"type": TYPE_DO, "steps": normalized_steps}
|
||||
|
||||
if not normalized_steps:
|
||||
return {"type": TYPE_DO, "steps": []}
|
||||
|
||||
return {"type": node_type, "steps": normalized_steps}
|
||||
|
||||
|
||||
def to_sexp(ast: ASTNode) -> Any:
|
||||
"""Convert object-form AST to S-expression form.
|
||||
|
||||
{"type": "do", "steps": ["A", {"type": "all", "steps": ["B", "C"]}]}
|
||||
→ ["do", "A", ["all", "B", "C"]]
|
||||
"""
|
||||
if isinstance(ast, str):
|
||||
return ast
|
||||
|
||||
node_type: str = ast.get("type", TYPE_DO)
|
||||
steps: List[ASTNode] = ast.get("steps", [])
|
||||
|
||||
return [node_type] + [to_sexp(s) for s in steps]
|
||||
|
||||
|
||||
def from_sexp(sexp: Any) -> ASTNode:
|
||||
"""Convert S-expression to object-form AST.
|
||||
|
||||
["do", "A", ["all", "B", "C"]]
|
||||
→ {"type": "do", "steps": ["A", {"type": "all", "steps": ["B", "C"]}]}
|
||||
"""
|
||||
if isinstance(sexp, str):
|
||||
return sexp
|
||||
|
||||
if not sexp:
|
||||
return {"type": TYPE_DO, "steps": []}
|
||||
|
||||
node_type: str = sexp[0]
|
||||
steps: List[ASTNode] = [from_sexp(s) for s in sexp[1:]]
|
||||
|
||||
return {"type": node_type, "steps": steps}
|
||||
|
||||
|
||||
def to_commands(ast: ASTNode) -> CommandList:
|
||||
"""Convert AST to commands list for existing DAG executor.
|
||||
|
||||
Transforms:
|
||||
- {"type": "do", "steps": [...]} → [...] (serial)
|
||||
- {"type": "all", "steps": [...]} → [[...]] (nested = parallel)
|
||||
- str → [str]
|
||||
"""
|
||||
if isinstance(ast, str):
|
||||
return [ast]
|
||||
|
||||
node_type: str = ast.get("type", TYPE_DO)
|
||||
steps: List[ASTNode] = ast.get("steps", [])
|
||||
|
||||
if node_type == TYPE_ALL:
|
||||
# Parallel: return as nested list
|
||||
result: CommandList = []
|
||||
result_append = result.append
|
||||
for step in steps:
|
||||
if isinstance(step, str):
|
||||
result_append(step)
|
||||
else:
|
||||
result.extend(to_commands(step))
|
||||
return [result]
|
||||
|
||||
# Sequential (do)
|
||||
result = []
|
||||
result_append = result.append
|
||||
|
||||
for step in steps:
|
||||
if isinstance(step, str):
|
||||
result_append(step)
|
||||
elif isinstance(step, dict):
|
||||
step_type: str = step.get("type", "")
|
||||
if step_type == TYPE_ALL:
|
||||
parallel_cmds: CommandList = []
|
||||
p_append = parallel_cmds.append
|
||||
for s in step.get("steps", []):
|
||||
if isinstance(s, str):
|
||||
p_append(s)
|
||||
else:
|
||||
parallel_cmds.extend(to_commands(s))
|
||||
result_append(parallel_cmds)
|
||||
else:
|
||||
result.extend(to_commands(step))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def render_ascii(ast: ASTNode, indent: int = 0) -> str:
|
||||
"""Render AST as ASCII tree."""
|
||||
prefix: str = " " * indent
|
||||
|
||||
if isinstance(ast, str):
|
||||
return f"{prefix}└─ {ast}\n"
|
||||
|
||||
node_type: str = ast.get("type", TYPE_DO)
|
||||
steps: List[ASTNode] = ast.get("steps", [])
|
||||
|
||||
lines: List[str] = [f"{prefix}{'├─' if indent else ''}[{node_type}]\n"]
|
||||
lines_append = lines.append
|
||||
|
||||
num_steps: int = len(steps)
|
||||
for i, step in enumerate(steps):
|
||||
is_last: bool = i == num_steps - 1
|
||||
if isinstance(step, str):
|
||||
marker: str = "└─" if is_last else "├─"
|
||||
lines_append(f"{prefix} {marker} {step}\n")
|
||||
else:
|
||||
lines_append(render_ascii(step, indent + 1))
|
||||
|
||||
return "".join(lines)
|
||||
|
||||
|
||||
# Cached compile for repeated patterns
|
||||
@lru_cache(maxsize=256)
|
||||
def _parse_cached(source: str) -> ASTTuple:
|
||||
"""Cached version of parse that returns a hashable tuple form."""
|
||||
ast: ASTDict = parse(source)
|
||||
return _dict_to_tuple(ast)
|
||||
|
||||
|
||||
def _dict_to_tuple(d: ASTNode) -> ASTTuple | str:
|
||||
"""Convert AST dict to hashable tuple."""
|
||||
if isinstance(d, str):
|
||||
return d
|
||||
node_type: str = d.get("type", TYPE_DO)
|
||||
steps: List[ASTNode] = d.get("steps", [])
|
||||
return (node_type, tuple(_dict_to_tuple(s) for s in steps))
|
||||
|
||||
|
||||
def _tuple_to_dict(t: ASTTuple | str) -> ASTNode:
|
||||
"""Convert tuple back to AST dict."""
|
||||
if isinstance(t, str):
|
||||
return t
|
||||
return {"type": t[0], "steps": [_tuple_to_dict(s) for s in t[1]]}
|
||||
|
||||
|
||||
def compile(source: str, format: str = "commands", cached: bool = False) -> Any:
|
||||
"""Compile shellflow to various formats.
|
||||
|
||||
Args:
|
||||
source: Shellflow source string
|
||||
format: Output format - "ast", "sexp", "commands", "ascii"
|
||||
cached: Use LRU cache for repeated patterns (default: False)
|
||||
|
||||
Returns:
|
||||
Compiled output in requested format
|
||||
"""
|
||||
ast: ASTDict
|
||||
if cached:
|
||||
ast_tuple: ASTTuple | str = _parse_cached(source)
|
||||
result: ASTNode = _tuple_to_dict(ast_tuple)
|
||||
ast = result if isinstance(result, dict) else {"type": TYPE_DO, "steps": [result]}
|
||||
else:
|
||||
ast = parse(source)
|
||||
|
||||
if format == "ast":
|
||||
return ast
|
||||
elif format == "sexp":
|
||||
return to_sexp(ast)
|
||||
elif format == "commands":
|
||||
return to_commands(ast)
|
||||
elif format == "ascii":
|
||||
return render_ascii(ast)
|
||||
else:
|
||||
raise ValueError(f"Unknown format: {format}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Inline optimized versions for hot paths
|
||||
# These use minimal function calls and are designed for mypyc compilation
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def parse_fast(source: str) -> ASTDict:
|
||||
"""Ultra-fast parse for simple sequential commands.
|
||||
|
||||
Falls back to full parse for complex syntax.
|
||||
"""
|
||||
source = source.strip()
|
||||
if not source:
|
||||
return {"type": TYPE_DO, "steps": []}
|
||||
|
||||
# Fast path: no special characters at all
|
||||
if ";" not in source and "{" not in source and "&" not in source:
|
||||
return {"type": TYPE_DO, "steps": [source]}
|
||||
|
||||
# Fast path: simple sequential (only semicolons, no braces or ampersands)
|
||||
if "{" not in source and "&" not in source:
|
||||
# Split on semicolon, strip each part
|
||||
parts: List[str] = [p.strip() for p in source.split(";") if p.strip()]
|
||||
if parts:
|
||||
return {"type": TYPE_DO, "steps": parts}
|
||||
return {"type": TYPE_DO, "steps": []}
|
||||
|
||||
# Fall back to full parse
|
||||
return parse(source)
|
||||
|
||||
|
||||
def compile_to_commands_fast(source: str) -> CommandList:
|
||||
"""Compile directly to commands list, optimized path.
|
||||
|
||||
Skips intermediate representations when possible.
|
||||
"""
|
||||
source = source.strip()
|
||||
if not source:
|
||||
return []
|
||||
|
||||
# Fast path: single command
|
||||
if ";" not in source and "{" not in source and "&" not in source:
|
||||
return [source]
|
||||
|
||||
# Fast path: simple sequential
|
||||
if "{" not in source and "&" not in source:
|
||||
return [p.strip() for p in source.split(";") if p.strip()]
|
||||
|
||||
# Full compilation
|
||||
ast: ASTDict = parse(source)
|
||||
return to_commands(ast)
|
||||
|
||||
|
||||
# Aliases for backwards compatibility
|
||||
_tokenize = _tokenize_fast
|
||||
_tokenize_parallel = _tokenize_parallel_fast
|
||||
@@ -1,98 +0,0 @@
|
||||
"""Response truncation utilities for shell tools.
|
||||
|
||||
Ensures shell command output doesn't exceed token limits.
|
||||
"""
|
||||
|
||||
import tiktoken
|
||||
|
||||
|
||||
def estimate_tokens(text: str, model: str = "gpt-4") -> int:
|
||||
"""Estimate the number of tokens in a text string.
|
||||
|
||||
Args:
|
||||
text: The text to estimate tokens for
|
||||
model: The model to use for token estimation (default: gpt-4)
|
||||
|
||||
Returns:
|
||||
Estimated number of tokens
|
||||
"""
|
||||
try:
|
||||
encoding = tiktoken.encoding_for_model(model)
|
||||
except KeyError:
|
||||
encoding = tiktoken.get_encoding("cl100k_base")
|
||||
|
||||
return len(encoding.encode(text))
|
||||
|
||||
|
||||
def truncate_response(
|
||||
response: str,
|
||||
max_tokens: int = 20000,
|
||||
truncation_message: str = "\n\n[Response truncated due to length. Please use pagination, filtering, or limit parameters to see more.]",
|
||||
) -> str:
|
||||
"""Truncate a response to fit within token limits.
|
||||
|
||||
Args:
|
||||
response: The response text to truncate
|
||||
max_tokens: Maximum number of tokens allowed (default: 20000)
|
||||
truncation_message: Message to append when truncating
|
||||
|
||||
Returns:
|
||||
Truncated response if needed, original response otherwise
|
||||
"""
|
||||
# Quick check - if response is short, no need to count tokens
|
||||
if len(response) < max_tokens * 2: # Rough estimate: 1 token ≈ 2-4 chars
|
||||
return response
|
||||
|
||||
# Estimate tokens
|
||||
token_count = estimate_tokens(response)
|
||||
|
||||
# If within limit, return as-is
|
||||
if token_count <= max_tokens:
|
||||
return response
|
||||
|
||||
# Need to truncate
|
||||
left, right = 0, len(response)
|
||||
truncation_msg_tokens = estimate_tokens(truncation_message)
|
||||
target_tokens = max_tokens - truncation_msg_tokens
|
||||
|
||||
while left < right - 1:
|
||||
mid = (left + right) // 2
|
||||
mid_tokens = estimate_tokens(response[:mid])
|
||||
|
||||
if mid_tokens <= target_tokens:
|
||||
left = mid
|
||||
else:
|
||||
right = mid
|
||||
|
||||
# Find a good break point (newline or space)
|
||||
truncate_at = left
|
||||
for i in range(min(100, left), -1, -1):
|
||||
if response[left - i] in "\n ":
|
||||
truncate_at = left - i
|
||||
break
|
||||
|
||||
return response[:truncate_at] + truncation_message
|
||||
|
||||
|
||||
def truncate_lines(
|
||||
response: str,
|
||||
max_lines: int = 1000,
|
||||
truncation_message: str = "\n\n[Response truncated to {max_lines} lines. Please use pagination or filtering to see more.]",
|
||||
) -> str:
|
||||
"""Truncate a response by number of lines.
|
||||
|
||||
Args:
|
||||
response: The response text to truncate
|
||||
max_lines: Maximum number of lines allowed (default: 1000)
|
||||
truncation_message: Message template to append when truncating
|
||||
|
||||
Returns:
|
||||
Truncated response if needed, original response otherwise
|
||||
"""
|
||||
lines = response.split("\n")
|
||||
|
||||
if len(lines) <= max_lines:
|
||||
return response
|
||||
|
||||
truncated = "\n".join(lines[:max_lines])
|
||||
return truncated + truncation_message.format(max_lines=max_lines)
|
||||
@@ -1,90 +0,0 @@
|
||||
"""UVX tool for both sync and background execution."""
|
||||
|
||||
from typing import Optional, override
|
||||
from pathlib import Path
|
||||
|
||||
from mcp.server import FastMCP
|
||||
from mcp.server.fastmcp import Context as MCPContext
|
||||
|
||||
from hanzo_tools.core import auto_timeout
|
||||
from hanzo_tools.shell.base_process import BaseBinaryTool
|
||||
|
||||
|
||||
class UvxTool(BaseBinaryTool):
|
||||
"""Tool for running uvx commands."""
|
||||
|
||||
name = "uvx"
|
||||
|
||||
@property
|
||||
@override
|
||||
def description(self) -> str:
|
||||
"""Get the tool description."""
|
||||
return """Run Python packages with uvx with automatic backgrounding for long-running processes.
|
||||
|
||||
Commands that run for more than 2 minutes will automatically continue in the background.
|
||||
|
||||
Usage:
|
||||
uvx ruff check .
|
||||
uvx mkdocs serve # Auto-backgrounds after 2 minutes
|
||||
uvx black --check src/
|
||||
uvx jupyter lab --port 8888 # Auto-backgrounds if needed"""
|
||||
|
||||
@override
|
||||
def get_binary_name(self) -> str:
|
||||
"""Get the binary name."""
|
||||
return "uvx"
|
||||
|
||||
@override
|
||||
async def run(
|
||||
self,
|
||||
ctx: MCPContext,
|
||||
package: str,
|
||||
args: str = "",
|
||||
cwd: Optional[str] = None,
|
||||
python: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Run a uvx command with auto-backgrounding."""
|
||||
work_dir = Path(cwd).resolve() if cwd else Path.cwd()
|
||||
|
||||
flags = []
|
||||
if python:
|
||||
flags.extend(["--python", python])
|
||||
|
||||
full_args = args.split() if args else []
|
||||
|
||||
return await self.execute_sync(
|
||||
package,
|
||||
cwd=work_dir,
|
||||
flags=flags,
|
||||
args=full_args,
|
||||
timeout=None,
|
||||
)
|
||||
|
||||
def register(self, server: FastMCP) -> None:
|
||||
"""Register the tool with the MCP server."""
|
||||
tool_self = self
|
||||
|
||||
@server.tool(name=self.name, description=self.description)
|
||||
async def uvx(
|
||||
ctx: MCPContext,
|
||||
package: str,
|
||||
args: str = "",
|
||||
cwd: Optional[str] = None,
|
||||
python: Optional[str] = None,
|
||||
) -> str:
|
||||
return await tool_self.run(ctx, package=package, args=args, cwd=cwd, python=python)
|
||||
|
||||
@auto_timeout("uvx")
|
||||
async def call(self, ctx: MCPContext, **params) -> str:
|
||||
"""Call the tool with arguments."""
|
||||
return await self.run(
|
||||
ctx,
|
||||
package=params["package"],
|
||||
args=params.get("args", ""),
|
||||
cwd=params.get("cwd"),
|
||||
python=params.get("python"),
|
||||
)
|
||||
|
||||
|
||||
# Create tool instance
|
||||
uvx_tool = UvxTool()
|
||||
@@ -1,233 +0,0 @@
|
||||
"""Wget tool - reliable file and site downloads.
|
||||
|
||||
Provides a clean interface for wget with proper handling of recursive downloads.
|
||||
"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
from typing import Optional, Annotated, final, 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
|
||||
|
||||
|
||||
@final
|
||||
class WgetTool(BaseTool):
|
||||
"""Wget tool - reliable downloads without shell escaping issues.
|
||||
|
||||
Supports single file downloads and full site mirroring.
|
||||
"""
|
||||
|
||||
name = "wget"
|
||||
|
||||
@property
|
||||
@override
|
||||
def description(self) -> str:
|
||||
return """Download files and mirror websites reliably.
|
||||
|
||||
Examples:
|
||||
wget --url "https://example.com/file.zip"
|
||||
wget --url "https://example.com/file.zip" --output "/tmp/myfile.zip"
|
||||
wget --url "https://docs.example.com" --mirror true
|
||||
wget --url "https://docs.example.com" --recursive true --depth 2
|
||||
wget --url "https://example.com" --recursive true --accept "*.pdf,*.doc"
|
||||
|
||||
Parameters:
|
||||
url: URL to download (required)
|
||||
output: Output file or directory
|
||||
mirror: Mirror site for offline viewing (sets recursive + timestamps)
|
||||
recursive: Download recursively
|
||||
depth: Maximum recursion depth (default: 5 for recursive)
|
||||
accept: Accept only files matching pattern (e.g., "*.pdf,*.html")
|
||||
reject: Reject files matching pattern
|
||||
domains: Limit to specific domains
|
||||
no_parent: Don't ascend to parent directory
|
||||
continue_download: Continue partial downloads
|
||||
timeout: Timeout in seconds (default: 300 for mirrors, 60 for files)
|
||||
quiet: Suppress output
|
||||
|
||||
Returns status and download summary.
|
||||
"""
|
||||
|
||||
@override
|
||||
@auto_timeout("wget")
|
||||
async def call(
|
||||
self,
|
||||
ctx: MCPContext,
|
||||
url: str,
|
||||
output: Optional[str] = None,
|
||||
mirror: bool = False,
|
||||
recursive: bool = False,
|
||||
depth: Optional[int] = None,
|
||||
accept: Optional[str] = None,
|
||||
reject: Optional[str] = None,
|
||||
domains: Optional[str] = None,
|
||||
no_parent: bool = True,
|
||||
continue_download: bool = False,
|
||||
timeout: Optional[int] = None,
|
||||
quiet: bool = False,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""Download files or mirror websites."""
|
||||
tool_ctx = create_tool_context(ctx)
|
||||
await tool_ctx.set_tool_info(self.name)
|
||||
|
||||
# Build wget command
|
||||
cmd = ["wget"]
|
||||
|
||||
# Mirror mode (most common for site downloads)
|
||||
if mirror:
|
||||
cmd.extend(
|
||||
[
|
||||
"--mirror", # Turn on mirroring
|
||||
"--convert-links", # Convert links for offline viewing
|
||||
"--adjust-extension", # Add .html extension
|
||||
"--page-requisites", # Get all assets (css, js, images)
|
||||
"--no-host-directories", # Don't create host directory
|
||||
]
|
||||
)
|
||||
recursive = True # Mirror implies recursive
|
||||
|
||||
# Recursive download
|
||||
if recursive:
|
||||
cmd.append("-r")
|
||||
if depth is not None:
|
||||
cmd.extend(["-l", str(depth)])
|
||||
elif not mirror:
|
||||
cmd.extend(["-l", "5"]) # Default depth limit
|
||||
|
||||
# No parent directory traversal (safety)
|
||||
if no_parent:
|
||||
cmd.append("--no-parent")
|
||||
|
||||
# Continue partial downloads
|
||||
if continue_download:
|
||||
cmd.append("-c")
|
||||
|
||||
# Accept/reject patterns
|
||||
if accept:
|
||||
cmd.extend(["-A", accept])
|
||||
if reject:
|
||||
cmd.extend(["-R", reject])
|
||||
|
||||
# Domain restrictions
|
||||
if domains:
|
||||
cmd.extend(["-D", domains])
|
||||
|
||||
# Output location
|
||||
if output:
|
||||
if recursive or mirror:
|
||||
cmd.extend(["-P", output]) # Directory for recursive
|
||||
else:
|
||||
cmd.extend(["-O", output]) # File for single download
|
||||
|
||||
# Timeout
|
||||
actual_timeout = timeout or (300 if (mirror or recursive) else 60)
|
||||
cmd.extend(["--timeout", str(min(actual_timeout, 30))]) # Per-request timeout
|
||||
cmd.extend(["--tries", "3"]) # Retry count
|
||||
cmd.extend(["--waitretry", "1"]) # Wait between retries
|
||||
|
||||
# Progress
|
||||
if quiet:
|
||||
cmd.append("-q")
|
||||
else:
|
||||
cmd.append("--progress=dot:mega") # Show progress for large files
|
||||
|
||||
# User agent (avoid blocks)
|
||||
cmd.extend(["--user-agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"])
|
||||
|
||||
# URL (last)
|
||||
cmd.append(url)
|
||||
|
||||
try:
|
||||
# Check if output is a directory (non-blocking)
|
||||
cwd = None
|
||||
if output:
|
||||
loop = asyncio.get_event_loop()
|
||||
is_dir = await loop.run_in_executor(None, os.path.isdir, output)
|
||||
if is_dir:
|
||||
cwd = output
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=cwd,
|
||||
)
|
||||
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(),
|
||||
timeout=actual_timeout + 30,
|
||||
)
|
||||
|
||||
output_text = stdout.decode("utf-8", errors="replace")
|
||||
errors = stderr.decode("utf-8", errors="replace")
|
||||
|
||||
# Combine stdout and stderr (wget uses stderr for progress)
|
||||
combined = f"{errors}\n{output_text}".strip()
|
||||
|
||||
if proc.returncode != 0:
|
||||
return f"wget failed (exit {proc.returncode}):\n{combined}"
|
||||
|
||||
# Summarize success
|
||||
lines = combined.split("\n")
|
||||
summary_lines = [
|
||||
l
|
||||
for l in lines
|
||||
if any(x in l.lower() for x in ["saved", "downloaded", "finished", "total", "retrieved"])
|
||||
]
|
||||
|
||||
if summary_lines:
|
||||
return "Download complete:\n" + "\n".join(summary_lines[-5:])
|
||||
return f"Download complete:\n{combined[-500:]}"
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
return f"Download timed out after {actual_timeout}s"
|
||||
except FileNotFoundError:
|
||||
return "Error: wget not found. Install wget: brew install wget"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
def register(self, mcp_server: FastMCP) -> None:
|
||||
"""Register with MCP server."""
|
||||
tool_instance = self
|
||||
|
||||
@mcp_server.tool()
|
||||
async def wget(
|
||||
url: Annotated[str, Field(description="URL to download")],
|
||||
output: Annotated[Optional[str], Field(description="Output file or directory")] = None,
|
||||
mirror: Annotated[bool, Field(description="Mirror site for offline viewing")] = False,
|
||||
recursive: Annotated[bool, Field(description="Download recursively")] = False,
|
||||
depth: Annotated[Optional[int], Field(description="Max recursion depth")] = None,
|
||||
accept: Annotated[Optional[str], Field(description="Accept pattern (e.g. '*.pdf')")] = None,
|
||||
reject: Annotated[Optional[str], Field(description="Reject pattern")] = None,
|
||||
domains: Annotated[Optional[str], Field(description="Limit to domains")] = None,
|
||||
no_parent: Annotated[bool, Field(description="Don't go to parent directories")] = True,
|
||||
continue_download: Annotated[bool, Field(description="Continue partial downloads")] = False,
|
||||
timeout: Annotated[Optional[int], Field(description="Timeout in seconds")] = None,
|
||||
quiet: Annotated[bool, Field(description="Suppress output")] = False,
|
||||
ctx: MCPContext = None,
|
||||
) -> str:
|
||||
"""Download files and mirror websites reliably.
|
||||
|
||||
Supports single file downloads and full site mirroring.
|
||||
Handles retries, timeouts, and link conversion automatically.
|
||||
"""
|
||||
return await tool_instance.call(
|
||||
ctx,
|
||||
url=url,
|
||||
output=output,
|
||||
mirror=mirror,
|
||||
recursive=recursive,
|
||||
depth=depth,
|
||||
accept=accept,
|
||||
reject=reject,
|
||||
domains=domains,
|
||||
no_parent=no_parent,
|
||||
continue_download=continue_download,
|
||||
timeout=timeout,
|
||||
quiet=quiet,
|
||||
)
|
||||
Reference in New Issue
Block a user