mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
fix: update test_no_stubs to recognize execute method and call aliases
This commit is contained in:
@@ -388,6 +388,9 @@ class BrowserTool:
|
||||
logger.exception(f"Browser action failed: {action}")
|
||||
return {"error": str(e), "action": action}
|
||||
|
||||
# Alias call to execute for compatibility with BaseTool interface
|
||||
call = execute
|
||||
|
||||
def register(self, mcp_server) -> None:
|
||||
"""Register the browser tool with an MCP server."""
|
||||
from mcp.server import FastMCP
|
||||
|
||||
@@ -86,7 +86,11 @@ class SearchTool(BaseTool):
|
||||
|
||||
Runs ALL available engines in parallel (grep, ast, lsp, file, git).
|
||||
No configuration needed. Results deduplicated and ranked.
|
||||
|
||||
|
||||
DISPLAY INSTRUCTIONS: Output is formatted text with numbered results.
|
||||
Each result has file:line, matched text, and [type] tag.
|
||||
Header shows query, count, and timing. Display directly as text.
|
||||
|
||||
For manual control: exec.parallel([grep(...), ast(...), lsp(...)])
|
||||
"""
|
||||
|
||||
|
||||
@@ -163,6 +163,10 @@ class DagTool(BaseTool):
|
||||
Execute shell commands or tools with DAG (directed acyclic graph) semantics.
|
||||
Supports serial, parallel, and complex mixed execution patterns.
|
||||
|
||||
DISPLAY INSTRUCTIONS: This tool returns plain text output. Show the output
|
||||
in a fenced code block. The final line shows execution summary:
|
||||
[dag] N nodes, Xms, ✓ (or ✗ if failed)
|
||||
|
||||
MODES:
|
||||
|
||||
Serial (default): dag(["ls", "pwd"])
|
||||
@@ -187,10 +191,8 @@ Named with dependencies:
|
||||
{"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>"""
|
||||
Uses zsh for shell execution. Commands exceeding timeout auto-background.
|
||||
Use ps --logs <id> to view output, ps --kill <id> to stop."""
|
||||
|
||||
async def _run_shell(
|
||||
self,
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Shell output formatting for Claude Code display.
|
||||
|
||||
Returns structured JSON with markdown-ready fields that Claude knows how to render.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def format_shell_result(
|
||||
command: str,
|
||||
stdout: str,
|
||||
stderr: str = "",
|
||||
exit_code: int = 0,
|
||||
cwd: Optional[str] = None,
|
||||
was_backgrounded: bool = False,
|
||||
process_id: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Format shell command result as structured JSON with markdown-ready fields.
|
||||
|
||||
Args:
|
||||
command: The command that was executed
|
||||
stdout: Standard output from the command
|
||||
stderr: Standard error from the command
|
||||
exit_code: Exit code (0 = success)
|
||||
cwd: Working directory where command ran
|
||||
was_backgrounded: Whether the command was auto-backgrounded
|
||||
process_id: Process ID if backgrounded
|
||||
|
||||
Returns:
|
||||
JSON string with summary, stdout_markdown, stderr_markdown, etc.
|
||||
"""
|
||||
# Determine status
|
||||
if was_backgrounded:
|
||||
status = "backgrounded"
|
||||
summary = f"Command `{_truncate_cmd(command)}` backgrounded (process: {process_id})"
|
||||
elif exit_code == 0:
|
||||
status = "success"
|
||||
summary = _generate_summary(command, stdout, cwd)
|
||||
else:
|
||||
status = "error"
|
||||
summary = f"Command `{_truncate_cmd(command)}` failed with exit code {exit_code}"
|
||||
|
||||
# Build result
|
||||
result = {
|
||||
"summary": summary,
|
||||
"stdout_markdown": _format_output_markdown(stdout) if stdout.strip() else "",
|
||||
"stderr_markdown": _format_output_markdown(stderr, is_stderr=True) if stderr.strip() else "",
|
||||
"status": status,
|
||||
"exit_code": exit_code,
|
||||
"command": command,
|
||||
}
|
||||
|
||||
if cwd:
|
||||
result["cwd"] = cwd
|
||||
|
||||
if was_backgrounded and process_id:
|
||||
result["process_id"] = process_id
|
||||
result["hint"] = f"Use `ps --logs {process_id}` to view output, `ps --kill {process_id}` to stop"
|
||||
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
|
||||
def _truncate_cmd(command: str, max_len: int = 50) -> str:
|
||||
"""Truncate command for display."""
|
||||
if len(command) <= max_len:
|
||||
return command
|
||||
return command[: max_len - 3] + "..."
|
||||
|
||||
|
||||
def _generate_summary(command: str, stdout: str, cwd: Optional[str] = None) -> str:
|
||||
"""Generate a brief summary of command execution."""
|
||||
lines = stdout.strip().split("\n") if stdout.strip() else []
|
||||
line_count = len(lines)
|
||||
|
||||
# Get first word of command for context
|
||||
cmd_name = command.split()[0] if command else "command"
|
||||
|
||||
# Smart summaries based on command type
|
||||
if cmd_name in ("ls", "find", "tree"):
|
||||
return f"Listed {line_count} items" + (f" in {cwd}" if cwd else "")
|
||||
elif cmd_name == "git":
|
||||
subcmd = command.split()[1] if len(command.split()) > 1 else ""
|
||||
if subcmd == "status":
|
||||
return f"Git status: {line_count} lines of output"
|
||||
elif subcmd == "log":
|
||||
return f"Git log: {line_count} lines"
|
||||
elif subcmd in ("commit", "push", "pull"):
|
||||
return f"Git {subcmd} completed"
|
||||
return f"Git command completed ({line_count} lines)"
|
||||
elif cmd_name in ("npm", "pnpm", "yarn"):
|
||||
return f"Package manager completed ({line_count} lines)"
|
||||
elif cmd_name in ("make", "cargo", "go"):
|
||||
return f"Build command completed ({line_count} lines)"
|
||||
elif cmd_name == "cat":
|
||||
return f"File contents ({line_count} lines)"
|
||||
elif cmd_name == "echo":
|
||||
return f"Output: {stdout.strip()[:100]}"
|
||||
|
||||
# Default summary
|
||||
if line_count == 0:
|
||||
return f"Command `{_truncate_cmd(cmd_name)}` completed (no output)"
|
||||
elif line_count == 1:
|
||||
return f"Output: {stdout.strip()[:100]}"
|
||||
else:
|
||||
return f"Command completed with {line_count} lines of output"
|
||||
|
||||
|
||||
def _format_output_markdown(output: str, is_stderr: bool = False) -> str:
|
||||
"""Format output as markdown code block."""
|
||||
if not output.strip():
|
||||
return ""
|
||||
|
||||
# Detect language hint based on content
|
||||
lang = ""
|
||||
stripped = output.strip()
|
||||
|
||||
# Common output patterns
|
||||
if stripped.startswith("{") or stripped.startswith("["):
|
||||
lang = "json"
|
||||
elif "error:" in stripped.lower() or "warning:" in stripped.lower():
|
||||
lang = "text"
|
||||
elif stripped.startswith("diff ") or stripped.startswith("---"):
|
||||
lang = "diff"
|
||||
|
||||
prefix = "[stderr]\n" if is_stderr else ""
|
||||
return f"{prefix}```{lang}\n{output.rstrip()}\n```"
|
||||
|
||||
|
||||
def format_plain_output(stdout: str, stderr: str = "", exit_code: int = 0) -> str:
|
||||
"""Format output as plain text (for backward compatibility).
|
||||
|
||||
Args:
|
||||
stdout: Standard output
|
||||
stderr: Standard error
|
||||
exit_code: Exit code
|
||||
|
||||
Returns:
|
||||
Plain text output
|
||||
"""
|
||||
parts = []
|
||||
|
||||
if stdout.strip():
|
||||
parts.append(stdout.rstrip())
|
||||
|
||||
if stderr.strip():
|
||||
parts.append(f"[stderr]\n{stderr.rstrip()}")
|
||||
|
||||
if exit_code != 0:
|
||||
parts.append(f"\n[exit code: {exit_code}]")
|
||||
|
||||
return "\n".join(parts) if parts else "(no output)"
|
||||
@@ -3,6 +3,7 @@
|
||||
import os
|
||||
import shutil
|
||||
import platform
|
||||
import json
|
||||
from typing import Optional, override
|
||||
from pathlib import Path
|
||||
|
||||
@@ -11,6 +12,7 @@ from mcp.server.fastmcp import Context as MCPContext
|
||||
|
||||
from hanzo_mcp.tools.shell.base_process import BaseScriptTool
|
||||
from hanzo_mcp.tools.common.auto_timeout import auto_timeout
|
||||
from hanzo_mcp.tools.shell.output_format import format_shell_result
|
||||
|
||||
|
||||
class ZshTool(BaseScriptTool):
|
||||
@@ -47,22 +49,33 @@ class ZshTool(BaseScriptTool):
|
||||
@override
|
||||
def description(self) -> str:
|
||||
"""Get the tool description."""
|
||||
return """Run commands in Zsh shell with enhanced features like better globbing and completion.
|
||||
return """Run commands in Zsh shell with enhanced features.
|
||||
|
||||
Zsh provides advanced features over bash:
|
||||
- Extended globbing patterns
|
||||
- Better tab completion
|
||||
- Array and associative array support
|
||||
- Powerful command line editing
|
||||
- Plugin ecosystem (oh-my-zsh, etc.)
|
||||
This tool returns JSON with these fields:
|
||||
- summary: Brief description of what happened
|
||||
- stdout_markdown: Output formatted as markdown code block
|
||||
- stderr_markdown: Errors formatted as markdown (if any)
|
||||
- status: "success", "error", or "backgrounded"
|
||||
- exit_code: Command exit code (0 = success)
|
||||
|
||||
Commands that run for more than 2 minutes will automatically continue in the background.
|
||||
DISPLAY INSTRUCTIONS: Show the summary as a bullet point, then render
|
||||
stdout_markdown and stderr_markdown directly as markdown. Never show raw JSON.
|
||||
|
||||
Example rendering:
|
||||
• Listed 12 items in /project
|
||||
```
|
||||
total 48K
|
||||
drwxr-xr-x 5 user staff 160 Dec 26 10:00 .
|
||||
...
|
||||
```
|
||||
|
||||
Commands that exceed timeout automatically continue in background.
|
||||
Use `ps --logs <id>` to view output, `ps --kill <id>` to stop.
|
||||
|
||||
Usage:
|
||||
zsh "ls -la"
|
||||
zsh "echo $ZSH_VERSION"
|
||||
zsh "git status && git diff"
|
||||
zsh "npm run dev" --cwd ./frontend # Auto-backgrounds if needed"""
|
||||
zsh "npm run dev" --cwd ./frontend"""
|
||||
|
||||
@override
|
||||
def get_interpreter(self) -> str:
|
||||
@@ -127,18 +140,75 @@ zsh "npm run dev" --cwd ./frontend # Auto-backgrounds if needed"""
|
||||
timeout: Command timeout in seconds (ignored - auto-backgrounds after 2 minutes)
|
||||
|
||||
Returns:
|
||||
Command output or background status
|
||||
JSON with summary, stdout_markdown, stderr_markdown, status, exit_code
|
||||
"""
|
||||
# Check if zsh is available
|
||||
if not shutil.which("zsh") and platform.system() != "Windows":
|
||||
return "Error: Zsh is not installed. Please install zsh first."
|
||||
return format_shell_result(
|
||||
command=command,
|
||||
stdout="",
|
||||
stderr="Zsh is not installed. Please install zsh first.",
|
||||
exit_code=1,
|
||||
cwd=str(cwd) if cwd else None,
|
||||
)
|
||||
|
||||
# Prepare working directory
|
||||
work_dir = Path(cwd).resolve() if cwd else Path.cwd()
|
||||
|
||||
# Use execute_sync which has auto-backgrounding
|
||||
output = await self.execute_sync(command, cwd=work_dir, env=env, timeout=timeout)
|
||||
return output if output else "Command completed successfully (no output)"
|
||||
try:
|
||||
output = await self.execute_sync(command, cwd=work_dir, env=env, timeout=timeout)
|
||||
|
||||
# Check if it was backgrounded
|
||||
if output and "automatically backgrounded" in output.lower():
|
||||
# Extract process ID from the message
|
||||
import re
|
||||
|
||||
pid_match = re.search(r"Process ID: (\S+)", output)
|
||||
process_id = pid_match.group(1) if pid_match else None
|
||||
|
||||
return format_shell_result(
|
||||
command=command,
|
||||
stdout=output,
|
||||
exit_code=0,
|
||||
cwd=str(work_dir),
|
||||
was_backgrounded=True,
|
||||
process_id=process_id,
|
||||
)
|
||||
|
||||
# Check for error in output
|
||||
if output and output.startswith("Command failed"):
|
||||
# Extract exit code from message
|
||||
import re
|
||||
|
||||
exit_match = re.search(r"exit code (\d+)", output)
|
||||
exit_code = int(exit_match.group(1)) if exit_match else 1
|
||||
|
||||
return format_shell_result(
|
||||
command=command,
|
||||
stdout="",
|
||||
stderr=output,
|
||||
exit_code=exit_code,
|
||||
cwd=str(work_dir),
|
||||
)
|
||||
|
||||
# Success case
|
||||
return format_shell_result(
|
||||
command=command,
|
||||
stdout=output if output else "",
|
||||
exit_code=0,
|
||||
cwd=str(work_dir),
|
||||
)
|
||||
|
||||
except RuntimeError as e:
|
||||
# Handle execution errors
|
||||
return format_shell_result(
|
||||
command=command,
|
||||
stdout="",
|
||||
stderr=str(e),
|
||||
exit_code=1,
|
||||
cwd=str(work_dir),
|
||||
)
|
||||
|
||||
|
||||
class ShellTool(BaseScriptTool):
|
||||
@@ -206,18 +276,25 @@ class ShellTool(BaseScriptTool):
|
||||
"""Get the tool description."""
|
||||
return f"""Run shell commands using the best available shell (currently: {os.path.basename(self._best_shell)}).
|
||||
|
||||
Automatically selects:
|
||||
- Zsh if available (with .zshrc)
|
||||
- User's preferred shell ($SHELL)
|
||||
- Bash as fallback
|
||||
This tool returns JSON with these fields:
|
||||
- summary: Brief description of what happened
|
||||
- stdout_markdown: Output formatted as markdown code block
|
||||
- stderr_markdown: Errors formatted as markdown (if any)
|
||||
- status: "success", "error", or "backgrounded"
|
||||
- exit_code: Command exit code (0 = success)
|
||||
|
||||
Commands that run for more than 2 minutes will automatically continue in the background.
|
||||
DISPLAY INSTRUCTIONS: Show the summary as a bullet point, then render
|
||||
stdout_markdown and stderr_markdown directly as markdown. Never show raw JSON.
|
||||
|
||||
Automatically selects: Zsh > User's $SHELL > Bash
|
||||
|
||||
Commands that exceed timeout automatically continue in background.
|
||||
Use `ps --logs <id>` to view output, `ps --kill <id>` to stop.
|
||||
|
||||
Usage:
|
||||
shell "ls -la"
|
||||
shell "echo $SHELL" # Shows which shell is being used
|
||||
shell "git status && git diff"
|
||||
shell "npm run dev" --cwd ./frontend # Auto-backgrounds if needed"""
|
||||
shell "npm run dev" --cwd ./frontend"""
|
||||
|
||||
@override
|
||||
def get_interpreter(self) -> str:
|
||||
@@ -255,21 +332,62 @@ shell "npm run dev" --cwd ./frontend # Auto-backgrounds if needed"""
|
||||
timeout: Command timeout in seconds (ignored - auto-backgrounds after 2 minutes)
|
||||
|
||||
Returns:
|
||||
Command output or background status
|
||||
JSON with summary, stdout_markdown, stderr_markdown, status, exit_code
|
||||
"""
|
||||
# Prepare working directory
|
||||
work_dir = Path(cwd).resolve() if cwd else Path.cwd()
|
||||
|
||||
# Add shell info to output if verbose
|
||||
shell_name = os.path.basename(self._best_shell)
|
||||
|
||||
# Use execute_sync which has auto-backgrounding
|
||||
output = await self.execute_sync(command, cwd=work_dir, env=env, timeout=timeout)
|
||||
try:
|
||||
output = await self.execute_sync(command, cwd=work_dir, env=env, timeout=timeout)
|
||||
|
||||
if output:
|
||||
return output
|
||||
else:
|
||||
return f"Command completed successfully in {shell_name} (no output)"
|
||||
# Check if it was backgrounded
|
||||
if output and "automatically backgrounded" in output.lower():
|
||||
import re
|
||||
|
||||
pid_match = re.search(r"Process ID: (\S+)", output)
|
||||
process_id = pid_match.group(1) if pid_match else None
|
||||
|
||||
return format_shell_result(
|
||||
command=command,
|
||||
stdout=output,
|
||||
exit_code=0,
|
||||
cwd=str(work_dir),
|
||||
was_backgrounded=True,
|
||||
process_id=process_id,
|
||||
)
|
||||
|
||||
# Check for error in output
|
||||
if output and output.startswith("Command failed"):
|
||||
import re
|
||||
|
||||
exit_match = re.search(r"exit code (\d+)", output)
|
||||
exit_code = int(exit_match.group(1)) if exit_match else 1
|
||||
|
||||
return format_shell_result(
|
||||
command=command,
|
||||
stdout="",
|
||||
stderr=output,
|
||||
exit_code=exit_code,
|
||||
cwd=str(work_dir),
|
||||
)
|
||||
|
||||
# Success case
|
||||
return format_shell_result(
|
||||
command=command,
|
||||
stdout=output if output else "",
|
||||
exit_code=0,
|
||||
cwd=str(work_dir),
|
||||
)
|
||||
|
||||
except RuntimeError as e:
|
||||
return format_shell_result(
|
||||
command=command,
|
||||
stdout="",
|
||||
stderr=str(e),
|
||||
exit_code=1,
|
||||
cwd=str(work_dir),
|
||||
)
|
||||
|
||||
|
||||
# Create tool instances
|
||||
|
||||
@@ -318,13 +318,19 @@ class TestNoStubs:
|
||||
if "Adapter" in class_name:
|
||||
continue
|
||||
|
||||
# Check if class has run() or call() method
|
||||
# Check if class has run() or call() method (or execute as alias)
|
||||
has_run_or_call = False
|
||||
for item in node.body:
|
||||
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
if item.name in ("run", "call"):
|
||||
if item.name in ("run", "call", "execute"):
|
||||
has_run_or_call = True
|
||||
break
|
||||
# Also check for method aliases like "call = execute"
|
||||
if isinstance(item, ast.Assign):
|
||||
for target in item.targets:
|
||||
if isinstance(target, ast.Name) and target.id in ("run", "call"):
|
||||
has_run_or_call = True
|
||||
break
|
||||
|
||||
if not has_run_or_call:
|
||||
# If it inherits from any base class, the base likely provides call()
|
||||
|
||||
Generated
+1
-1
@@ -1271,7 +1271,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "hanzo-mcp"
|
||||
version = "0.10.6"
|
||||
version = "0.10.7"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
|
||||
Reference in New Issue
Block a user