feat: add new tool packages and install command

- Add hanzo-tools-code: code analysis and transformation tools
- Add hanzo-tools-net: network and HTTP tools
- Add hanzo-tools-plan: planning and task management tools
- Add hanzo-tools-test: testing tools
- Add hanzo-tools-vcs: version control tools
- Add install command for dependency management
- Improve CDP bridge server with better error handling
- Add ui_tool for computer tools
- Add unified tool loading and id_tool
- Add HIP documentation (HIP-0300, HIP-0301)
This commit is contained in:
2026-01-24 13:21:16 -08:00
parent 5636caf58c
commit 86c2cdcc0d
68 changed files with 14269 additions and 206 deletions
+53 -1
View File
@@ -1,6 +1,6 @@
.DEFAULT_GOAL := all
SHELL := /bin/bash
.PHONY: help all setup install-python venv deps test lint format build clean publish-all check check-forbidden check-functions test-no-stubs test-all security install-hooks
.PHONY: help all setup install install-local uninstall install-python venv deps test lint format build clean publish-all check check-forbidden check-functions test-no-stubs test-all security install-hooks
# Colors for output
CYAN := \033[0;36m
@@ -20,6 +20,58 @@ help: ## Show this help message
all: format lint test ## Run all checks
# ==================== INSTALLATION ====================
# Install CLI tools to ~/.local/bin/ (like Claude Code)
INSTALL_DIR := $(HOME)/.local/bin
install: ## Install CLI tools to ~/.local/bin via uv tool
@echo -e "$(CYAN)Installing Hanzo CLI tools to $(INSTALL_DIR)...$(NC)"
@if ! command -v uv &> /dev/null; then \
echo -e "$(RED)Error: uv is not installed. Run: curl -LsSf https://astral.sh/uv/install.sh | sh$(NC)"; \
exit 1; \
fi
@uv tool install hanzo --upgrade 2>/dev/null || uv tool install hanzo
@uv tool install hanzo-mcp --upgrade 2>/dev/null || uv tool install hanzo-mcp
@uv tool install hanzo-agents --upgrade 2>/dev/null || uv tool install hanzo-agents
@echo ""
@echo -e "$(GREEN)✓ Hanzo CLI tools installed!$(NC)"
@echo -e " Location: $(INSTALL_DIR)"
@echo ""
@echo -e " $(CYAN)hanzo --help$(NC) # CLI commands"
@echo -e " $(CYAN)hanzo-mcp$(NC) # MCP server"
@echo -e " $(CYAN)hanzo-agents$(NC) # Agents framework"
@echo ""
@if [[ ":$$PATH:" != *":$(INSTALL_DIR):"* ]]; then \
echo -e "$(YELLOW)Add to PATH:$(NC) export PATH=\"$(INSTALL_DIR):\$$PATH\""; \
fi
install-local: build ## Install from local source (development)
@echo -e "$(CYAN)Installing Hanzo from local source...$(NC)"
@uv tool install --force ./pkg/hanzo
@uv tool install --force ./pkg/hanzo-mcp
@uv tool install --force ./pkg/hanzo-agents
@echo -e "$(GREEN)✓ Installed from local source$(NC)"
uninstall: ## Remove all Hanzo CLI tools
@echo -e "$(CYAN)Uninstalling Hanzo CLI tools...$(NC)"
@uv tool uninstall hanzo 2>/dev/null || true
@uv tool uninstall hanzo-mcp 2>/dev/null || true
@uv tool uninstall hanzo-agents 2>/dev/null || true
@echo -e "$(GREEN)✓ Hanzo CLI tools uninstalled$(NC)"
doctor: ## Show installed Hanzo tools
@echo -e "$(CYAN)Hanzo CLI Tools Status$(NC)"
@echo ""
@echo -e " $(CYAN)uv tools:$(NC)"
@uv tool list 2>/dev/null | grep -E "^hanzo" | while read line; do \
name=$$(echo "$$line" | awk '{print $$1}'); \
ver=$$(echo "$$line" | awk '{print $$2}'); \
path=$$(command -v "$$name" 2>/dev/null || echo "~/.local/bin/$$name"); \
printf " $(GREEN)✓$(NC) %-16s %-10s %s\n" "$$name" "$$ver" "$$path"; \
done || echo -e " $(RED)(none installed)$(NC)"
@echo ""
setup: install-python venv deps ## Complete setup: install Python, create venv, install deps
install-python: ## Install Python using uv
+466
View File
@@ -0,0 +1,466 @@
# HIP-0300: Unified MCP Tools Architecture
**Status:** Draft
**Author:** Hanzo AI
**Created:** January 2025
**Updated:** January 2025
## Abstract
HIP-0300 defines a unified architecture for Model Context Protocol (MCP) tools, consolidating 52+ individual tools into ~16 orthogonal, composable operators. The design follows Unix philosophy: each tool does one thing well, with clear composition laws.
## Motivation
The original hanzo-mcp implementation grew organically to 52+ tools with significant overlap:
- Multiple search tools (grep, search, find, ast_search)
- Multiple file tools (read, write, edit, cat, head, tail)
- Multiple shell tools (bash, zsh, sh, shell, cmd)
This creates confusion for LLM agents and increases cognitive load. HIP-0300 restructures tools around orthogonal axes with a formal effect lattice.
## Design Philosophy
### Core Principles
1. **Orthogonal Axes**: Tools organized along independent dimensions
2. **Composability**: Small operators that combine predictably
3. **Effect Tracking**: Every operation has a declared effect level
4. **Transform vs Apply**: Pure transforms produce Patches; Apply commits them
5. **Minimal Surface Area**: 16 core operators cover all use cases
### The Three Lattices
Three lattices constrain all operations:
```
EFFECT LATTICE (purity)
├─ PURE (no side effects, referentially transparent)
├─ DETERMINISTIC_EFFECT (effects, but reproducible)
└─ NONDETERMINISTIC_EFFECT (network, time, randomness)
REPRESENTATION LATTICE (data granularity)
├─ Bytes (raw content)
├─ Lines (text with line numbers)
├─ AST (parsed structure)
├─ Patch (diff/change set)
└─ Symbols (semantic references)
SCOPE LATTICE (operational boundary)
├─ Span (byte/char range)
├─ Region (line range)
├─ File (single file)
├─ Tree (directory subtree)
└─ Repo (entire repository)
```
## Operator Surface
### Core Operators (HIP-0300)
| Tool | Axis | Actions | Effect |
|------|------|---------|--------|
| `fs` | Bytes + Paths | read, write, edit, search, list, stat, patch, tree, glob | DETERMINISTIC |
| `id` | Identity | hash, uri, ref, verify | PURE |
| `code` | Symbols + Structure | parse, serialize, symbols, definition, references, transform, summarize | PURE/DETERMINISTIC |
| `proc` | Execution | run, bg, signal, status, wait | NONDETERMINISTIC |
| `vcs` | History + Diffs | status, diff, commit, log, branch, stash | DETERMINISTIC |
| `test` | Validation | check, build, test, detect | NONDETERMINISTIC |
| `net` | Network | search, fetch, download, crawl, head | NONDETERMINISTIC |
| `plan` | Orchestration | intent, route, compose | PURE |
### Control Surfaces
| Tool | Surface | Actions | Effect |
|------|---------|---------|--------|
| `browser` | Web DOM | navigate, click, type, screenshot, evaluate, etc. | NONDETERMINISTIC |
| `computer` | OS Desktop | screenshot, click, type, key, scroll, etc. | NONDETERMINISTIC |
### Extended Operators
| Tool | Domain | Actions |
|------|--------|---------|
| `lsp` | Semantic Stream | diagnostics, code_actions, hover, completion |
| `memory` | Knowledge Persistence | read, write, search, create, recall |
| `todo` | Task Tracking | list, add, update, remove |
| `reasoning` | Cognition | think, critic |
| `agent` | Multi-Agent | run, list, status, config |
| `llm` | LLM Interface | chat, consensus |
## Verb Kernel
The 27-verb kernel with typed signatures:
### File Operations (fs)
```
read : Path → Bytes | Text | Lines
write : (Path, Content) → {ok, hash}
edit : (Path, Patch) → {ok, hash}
search : (Pattern, Scope) → [Match]
list : Path → [Entry]
stat : Path → {size, mtime, mode, hash}
patch : (Path, Patch, base_hash?) → {ok, new_hash}
tree : (Path, depth?) → TreeNode
glob : (Pattern, Path?) → [Path]
```
### Identity Operations (id)
```
hash : Content → {digest, algo, size}
uri : Path → {uri, path, exists}
ref : (Path, line?, col?) → {uri, range?, hash?}
verify : (Content, Digest) → {match, actual, expected}
```
### Code Operations (code)
```
parse : (Path | Text, lang?) → AST
serialize : AST → Text
symbols : (Path | AST, kind?) → [Symbol]
definition : (Path, Position) → [Location]
references : (Path, Position) → [Location]
transform : (Path | Text, kind, params) → Patch # PURE!
summarize : (Diff | Log | Report) → {summary, risks, next_actions}
```
### Process Operations (proc)
```
run : Command → {stdout, stderr, code}
bg : Command → ProcessID
signal : (ProcessID, Signal) → {ok}
status : ProcessID? → [ProcessStatus]
wait : (ProcessID, timeout?) → {stdout, stderr, code}
```
### Version Control (vcs)
```
status : () → {staged, unstaged, untracked}
diff : (ref1?, ref2?, paths?) → Diff
commit : (message, files?) → {sha, message}
log : (n?, since?, until?, path?) → [Commit]
branch : (name?, action?) → {current, branches}
stash : (action, message?) → {ok}
```
### Validation (test)
```
check : (Path?, tool?) → {diagnostics, pass} # Lint/typecheck
build : (Path?, tool?) → {success, artifacts} # Compilation
test : (selector?, tool?) → {passed, failed, summary} # Runtime
detect : (Path?) → {test_runner, build_tool, check_tool}
```
### Network (net)
```
search : (Query, engine?) → [{url, title, snippet}]
fetch : (URL, extract_text?) → {text, mime, status, hash}
download : (URL, dest?, assets?) → {path, size, mime}
crawl : (URL, dest, depth?, limit?) → {pages, count}
head : URL → {status, headers, size?, mime?}
```
### Orchestration (plan)
```
intent : NL → IntentIR
route : (IntentIR, Policy?) → Plan
compose : Plan → ExecGraph
```
## Effect Annotations
Every action declares its effect level:
```python
class Effect(Enum):
PURE = "pure" # No side effects
DETERMINISTIC = "deterministic" # Effects, reproducible
NONDETERMINISTIC = "nondeterministic" # Network/time/random
# Examples:
# fs.read → DETERMINISTIC_EFFECT (reads disk)
# code.parse → PURE (in-memory transform)
# code.transform → PURE (produces Patch value)
# fs.patch → DETERMINISTIC_EFFECT (writes disk)
# net.fetch → NONDETERMINISTIC_EFFECT (network I/O)
```
## Transform vs Apply
Critical separation of concerns:
```
Transform (PURE) Apply (EFFECT)
───────────────── ────────────────
code.transform fs.patch
→ Patch value → committed change
→ base_hash → requires base_hash precondition
→ preview-safe → point of no return
vcs.diff vcs.commit
→ Diff value → committed history
```
### Content-Addressable Edits
All mutations use content-addressable storage for safety:
```python
# Generate a transform
result = await code.call(ctx, action="transform",
path="/src/main.py",
kind="rename",
old_name="foo",
new_name="bar")
# Returns: {patch: [...], base_hash: "sha256:abc...", new_hash: "sha256:def..."}
# Apply with precondition
result = await fs.call(ctx, action="patch",
path="/src/main.py",
patch=patch,
base_hash="sha256:abc...") # Must match current
# Fails if file changed since transform was computed
```
## Intent Routing
Natural language maps to canonical operator chains:
### Intent IR Structure
```python
class IntentIR:
category: str # navigate, explain, modify, validate, debug, create
action: str # find, understand, rename, test, trace, add
target: str # extracted entity/path/concept
confidence: float
```
### Canonical Chains
| Intent Pattern | Canonical Chain |
|---------------|-----------------|
| "find X" | `fs.search(X)` |
| "what is X" | `fs.search(X) → code.summarize` |
| "rename X to Y" | `code.references(X) → code.transform(rename) → code.summarize → [policy] → fs.patch → test.run` |
| "fix bug in X" | `fs.read(X) → code.parse → code.transform(fix) → [policy] → fs.patch → test.run` |
| "add tests for X" | `code.symbols(X) → code.transform(add_tests) → fs.write → test.run` |
| "why does X fail" | `test.run(X) → vcs.log → code.summarize` |
| "refactor X" | `code.references(X) → code.transform → code.summarize → [policy] → fs.patch → test.run` |
### Policy Gates
High-risk operations require explicit approval:
- `fs.patch` (file modifications)
- `fs.write` (new file creation)
- `proc.run` with shell=True
- `vcs.commit` / `vcs.push`
## Validation Loops
Three distinct validation operations (Vim-inspired):
### CHECK (`:make` equivalent)
- Fast, incremental feedback
- Lint, typecheck, format check
- Tools: ruff, mypy, eslint, tsc, clippy
- Returns: `{diagnostics: [], pass: bool}`
### BUILD (`:!make` equivalent)
- Whole-project compilation
- Dependency resolution
- Tools: pip, npm, cargo, go build, make
- Returns: `{success: bool, artifacts: [], errors: []}`
### TEST (`:!make test` equivalent)
- Runtime behavior validation
- Isolated execution environment
- Tools: pytest, jest, go test, cargo test
- Returns: `{passed: int, failed: int, summary: str}`
### Auto-Detection
```python
# test.detect() returns:
{
"test_runner": {"name": "pytest", "cmd": ["pytest", "-v"]},
"build_tool": {"name": "pip", "cmd": ["pip", "install", "-e", "."]},
"check_tool": {"name": "ruff", "cmd": ["ruff", "check", "."]}
}
```
## Implementation
### BaseTool Pattern
```python
from hanzo_tools.core import BaseTool, ActionHandler, ToolError
class FsTool(BaseTool):
name: ClassVar[str] = "fs"
def __init__(self, cwd: str | None = None):
super().__init__()
self.cwd = cwd or os.getcwd()
self._register_actions()
def _register_actions(self):
@self.action("read", "Read file content")
async def read(ctx, path: str, encoding: str = "utf-8"):
"""Path → Content"""
# Implementation
return {"text": content, "hash": content_hash(content)}
@self.action("search", "Search for pattern")
async def search(ctx, pattern: str, path: str = ".", **opts):
"""(Pattern, Scope) → [Match]"""
# Implementation
return {"matches": matches, "count": len(matches)}
```
### Unified Response Envelope
All tools return a consistent envelope:
```python
{
"ok": True, # Success indicator
"data": {...}, # Action-specific result
"error": None, # Or {code, message, details}
"meta": { # Optional metadata
"duration_ms": 42,
"effect": "deterministic"
}
}
```
### Entry Points
Each package exports via entry points:
```toml
# pyproject.toml
[project.entry-points."hanzo.tools"]
filesystem = "hanzo_tools.filesystem:TOOLS"
code = "hanzo_tools.code:TOOLS"
plan = "hanzo_tools.plan:TOOLS"
test = "hanzo_tools.test:TOOLS"
net = "hanzo_tools.net:TOOLS"
```
## Package Structure
```
pkg/
├── hanzo-tools-core/ # BaseTool, IdTool, ToolRegistry
│ └── hanzo_tools/core/
├── hanzo-tools-filesystem/ # FsTool (read, write, edit, search, etc.)
│ └── hanzo_tools/filesystem/
├── hanzo-tools-code/ # CodeTool (parse, transform, summarize)
│ └── hanzo_tools/code/
├── hanzo-tools-shell/ # ProcTool (run, bg, signal, wait)
│ └── hanzo_tools/shell/
├── hanzo-tools-vcs/ # VcsTool (status, diff, commit, log)
│ └── hanzo_tools/vcs/
├── hanzo-tools-test/ # TestTool (check, build, test)
│ └── hanzo_tools/test/
├── hanzo-tools-net/ # NetTool (search, fetch, download, crawl)
│ └── hanzo_tools/net/
├── hanzo-tools-plan/ # PlanTool (intent, route, compose)
│ └── hanzo_tools/plan/
├── hanzo-tools-browser/ # BrowserTool (Playwright control)
│ └── hanzo_tools/browser/
├── hanzo-tools-computer/ # ComputerTool (OS desktop control)
│ └── hanzo_tools/computer/
└── hanzo-mcp/ # MCP server (discovers tools via entry points)
└── hanzo_mcp/
```
## Migration Path
### Phase 1: Implement Core Operators
- [x] `fs` - Filesystem operations (existing hanzo-tools-filesystem)
- [x] `id` - Identity operations (hash, uri, ref, verify)
- [x] `code` - Code operations (parse, transform, summarize)
- [x] `proc` - Process execution (existing hanzo-tools-shell)
- [x] `vcs` - Version control (existing hanzo-tools-vcs)
- [x] `test` - Validation loops (check, build, test)
- [x] `net` - Network operations (search, fetch, download, crawl)
- [x] `plan` - Orchestration (intent, route, compose)
### Phase 2: Control Surfaces
- [x] `browser` - Web DOM control (existing hanzo-tools-browser)
- [ ] `computer` - OS desktop control (needs update)
### Phase 3: Extended Operators
- [ ] `lsp` - Language server integration
- [ ] `dbg` - Debugger control (breakpoint, step, eval)
- [ ] `repl` - Interactive evaluation (send, recv, reset)
### Phase 4: Deprecation
- Deprecate individual tools (read_file, search_code, etc.)
- Map old names to new unified tools
- Remove after 6 months
## Composition Examples
### Safe Refactoring
```python
# 1. Find all references (PURE)
refs = await code.call(ctx, action="references", path="src/auth.py", position={"line": 42, "col": 10})
# 2. Generate patch (PURE)
patch = await code.call(ctx, action="transform", path="src/auth.py", kind="rename", old_name="authenticate", new_name="verify_user")
# 3. Summarize changes (PURE)
summary = await code.call(ctx, action="summarize", diff=patch["patch"])
# 4. [POLICY GATE] - User approves
# 5. Apply patch (EFFECT)
await fs.call(ctx, action="patch", path="src/auth.py", patch=patch["patch"], base_hash=patch["base_hash"])
# 6. Verify (EFFECT)
await test.call(ctx, action="run")
```
### Site Mirroring
```python
# Crawl site recursively
result = await net.call(ctx, action="crawl", url="https://docs.example.com", dest="./mirror", depth=3, limit=100)
# Returns: {pages: ["/mirror/index.html", ...], count: 47}
```
### Intelligent Search
```python
# Parse intent
intent = await plan.call(ctx, action="intent", nl="find where user authentication happens")
# Route to canonical chain
plan = await plan.call(ctx, action="route", intent_ir=intent)
# Execute chain
for step in plan["nodes"]:
if step.get("policy_gate"):
# Request approval
pass
result = await dispatch(step["tool"], step["action"], step["params"])
```
## Security Considerations
1. **Path Traversal**: All path operations validate against allowed directories
2. **Command Injection**: `proc.run` sanitizes inputs, requires explicit shell=True
3. **Network Access**: `net.*` operations respect proxy settings and rate limits
4. **File Modifications**: Require `base_hash` precondition to prevent race conditions
5. **Policy Gates**: High-risk operations require explicit approval
## References
- [Unix Philosophy](https://en.wikipedia.org/wiki/Unix_philosophy)
- [Model Context Protocol](https://github.com/anthropics/mcp)
- [Effect Systems](https://en.wikipedia.org/wiki/Effect_system)
- [Content-Addressable Storage](https://en.wikipedia.org/wiki/Content-addressable_storage)
## Changelog
- **2025-01-22**: Initial draft with operator lattice specification
- **2025-01-21**: Created CodeTool, PlanTool, TestTool, NetTool, IdTool packages
+420
View File
@@ -0,0 +1,420 @@
# HIP-0301: High-Performance Agent Communication Protocol
**Status:** Draft
**Author:** Hanzo AI
**Created:** January 2025
**Updated:** January 2025
## Abstract
HIP-0301 defines a high-performance agent-to-agent communication protocol using Cap'n Proto as the wire format, with MCP JSON-RPC as a fallback. This enables sub-millisecond tool invocation between agents while maintaining backward compatibility with existing MCP infrastructure.
## Motivation
Current MCP communication has inherent limitations:
1. **JSON Serialization Overhead**: Every tool call requires JSON encode/decode (~100-500μs per call)
2. **No Zero-Copy**: JSON requires full parsing before use; data copied multiple times
3. **Schema Flexibility = Runtime Errors**: JSON's flexibility means errors surface at runtime
4. **Consensus Latency**: Multi-agent consensus requires O(n²) messages; JSON overhead compounds
For AI agent swarms running metastable consensus, the overhead is unacceptable:
- 10 agents × 10 rounds × 100μs = 10ms just for serialization
- Real-world latencies 10-100x higher due to memory allocation
Cap'n Proto solves this with:
- **Zero-copy reads**: Wire format = memory format (no parsing)
- **Incremental reads**: Access fields without deserializing entire message
- **Type safety**: Schema-defined contracts, compile-time verification
- **RPC built-in**: Native async RPC with promise pipelining
## Design
### Transport Hierarchy
```
┌─────────────────────────────────────────────────────────────┐
│ Agent Bus Interface │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────┐ │
│ │ Cap'n Proto │ │ MCP JSON-RPC │ │ Unix IPC │ │
│ │ (preferred) │ │ (fallback) │ │ (local) │ │
│ └────────┬────────┘ └────────┬────────┘ └──────┬──────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ Unified Tool Invocation Layer ││
│ │ tool.call(action, params) -> Response ││
│ └─────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────┘
```
### AgentBus Trait
```rust
/// High-performance agent communication bus
#[async_trait]
pub trait AgentBus: Send + Sync {
/// Get bus capabilities
fn capabilities(&self) -> BusCapabilities;
/// Register local agent
async fn register(&self, agent_id: &str, handler: Arc<dyn AgentHandler>) -> Result<()>;
/// Unregister agent
async fn unregister(&self, agent_id: &str) -> Result<()>;
/// Call tool on remote agent (point-to-point)
async fn call(
&self,
from: &str,
to: &str,
tool: &str,
action: &str,
params: &[u8], // Pre-serialized (capnp or json)
) -> Result<Vec<u8>>;
/// Broadcast to all agents
async fn broadcast(
&self,
from: &str,
tool: &str,
action: &str,
params: &[u8],
) -> Result<Vec<(String, Vec<u8>)>>; // (agent_id, response)
/// Run consensus protocol
async fn consensus(
&self,
prompt: &str,
participants: &[&str],
config: ConsensusConfig,
) -> Result<ConsensusState>;
}
#[derive(Clone)]
pub struct BusCapabilities {
pub supports_capnp: bool,
pub supports_mcp: bool,
pub supports_unix_ipc: bool,
pub max_message_size: usize,
pub supports_streaming: bool,
}
pub struct ConsensusConfig {
pub rounds: u32,
pub k: u32, // Peer sample size
pub alpha: f64, // Confidence threshold
pub beta_1: f64, // Phase I threshold
pub beta_2: f64, // Phase II (finality) threshold
pub timeout_ms: u64,
}
```
### Cap'n Proto Schema
```capnp
@0xb5e7e8d3c9f2a1b4; # Unique file ID
# Core message types for agent communication
struct AgentId {
id @0 :Text;
nodeId @1 :Text; # For distributed deployment
capabilities @2 :List(Text);
}
struct ToolCall {
id @0 :UInt64; # Request ID for correlation
tool @1 :Text; # Tool name (fs, code, plan, etc.)
action @2 :Text; # Action within tool
params @3 :Data; # Serialized params (nested capnp or json)
effect @4 :Effect; # Declared effect level
traceId @5 :Text; # Distributed tracing
}
enum Effect {
pure @0;
deterministic @1;
nondeterministic @2;
}
struct ToolResponse {
id @0 :UInt64; # Matching request ID
ok @1 :Bool;
data @2 :Data; # Serialized result
error @3 :ToolError;
meta @4 :ResponseMeta;
}
struct ToolError {
code @0 :ErrorCode;
message @1 :Text;
details @2 :Data; # JSON or nested capnp
}
enum ErrorCode {
unknownAction @0;
invalidParams @1;
notFound @2;
conflict @3;
permissionDenied @4;
timeout @5;
internalError @6;
}
struct ResponseMeta {
tool @0 :Text;
version @1 :Text;
action @2 :Text;
effect @3 :Effect;
durationNs @4 :UInt64;
traceId @5 :Text;
}
# Consensus protocol messages
struct ConsensusVote {
round @0 :UInt32;
fromAgent @1 :Text;
vote @2 :Text; # Agent's response/vote
confidence @3 :Float64;
luminance @4 :Float64; # Response time weight
signature @5 :Data; # Optional cryptographic signature
}
struct ConsensusState {
round @0 :UInt32;
phase @1 :ConsensusPhase;
votes @2 :List(ConsensusVote);
winner @3 :Text;
synthesis @4 :Text;
finalized @5 :Bool;
confidence @6 :Float64;
}
enum ConsensusPhase {
sampling @0; # Phase I: k-peer sampling
finality @1; # Phase II: threshold aggregation
complete @2;
}
# Agent mesh management
struct AgentRegistration {
agent @0 :AgentId;
endpoint @1 :Text; # capnp://host:port or mcp://host:port
tools @2 :List(Text); # Available tools
publicKey @3 :Data; # For authenticated consensus
}
struct MeshTopology {
agents @0 :List(AgentRegistration);
gossipInterval @1 :UInt32; # ms between topology updates
}
# RPC Interface
interface AgentService {
# Core tool invocation
call @0 (request :ToolCall) -> (response :ToolResponse);
# Streaming for large responses
stream @1 (request :ToolCall) -> (stream :StreamHandle);
# Consensus participation
vote @2 (round :UInt32, prompt :Text, context :Data) -> (vote :ConsensusVote);
# Mesh management
register @3 (registration :AgentRegistration) -> (accepted :Bool);
topology @4 () -> (mesh :MeshTopology);
# Health
ping @5 () -> (pong :Bool, latencyNs :UInt64);
}
interface StreamHandle {
next @0 () -> (chunk :Data, done :Bool);
cancel @1 () -> ();
}
```
### Protocol Negotiation
When agents connect, they negotiate the best available transport:
```
Agent A Agent B
│ │
│──── HELLO (capabilities) ─────▶│
│ │
│◀─── HELLO_ACK (selected) ──────│
│ │
│ Transport: capnp (preferred) │
│ or: mcp-json (fallback) │
│ or: unix-ipc (local) │
│ │
│══════ Established ═════════════│
```
### Integration with HIP-0300 Tools
All HIP-0300 tools gain an `agent` action for consensus-based execution:
```rust
// fs tool with consensus
fs_tool.call("agent", json!({
"action": "read",
"path": "/etc/hosts",
"consensus": {
"participants": ["claude", "gpt4", "gemini"],
"require_agreement": true
}
}));
// plan tool routes through consensus automatically
plan_tool.call("intent", json!({
"nl": "refactor the authentication module",
"consensus": true // Uses metastable consensus for routing
}));
```
### Metastable Consensus Integration
The plan tool uses metastable consensus for LLM-based intent parsing:
```rust
impl PlanTool {
async fn intent_with_consensus(
&self,
nl: &str,
bus: &dyn AgentBus,
) -> Result<IntentIR> {
// Get available LLM agents
let agents = bus.list_agents_with_capability("llm").await?;
// Run consensus
let state = bus.consensus(
&format!("Parse this intent: {}", nl),
&agents,
ConsensusConfig {
rounds: 3,
k: 3,
alpha: 0.6,
beta_1: 0.5,
beta_2: 0.8,
timeout_ms: 30000,
},
).await?;
// Parse winner's response as IntentIR
serde_json::from_str(&state.synthesis)
.map_err(|e| ToolError::internal(format!("Invalid IR: {}", e)))
}
}
```
## Performance Characteristics
| Metric | JSON-RPC (MCP) | Cap'n Proto | Improvement |
|--------|----------------|-------------|-------------|
| Serialization | 100-500μs | 0μs (zero-copy) | ∞ |
| Deserialization | 50-200μs | 0μs (zero-copy) | ∞ |
| Message overhead | ~40% | ~5% | 8x smaller |
| Memory allocations | O(n) | O(1) | Constant |
| Type safety | Runtime | Compile-time | Earlier errors |
| Consensus (10 agents, 10 rounds) | ~50-100ms | ~1-5ms | 10-100x |
## Backward Compatibility
1. **MCP Fallback**: If Cap'n Proto unavailable, seamlessly falls back to JSON-RPC
2. **Schema Evolution**: Cap'n Proto supports adding fields without breaking
3. **Hybrid Mesh**: Agents using different transports can still communicate via bridge
4. **Tool API Unchanged**: HIP-0300 tool signatures remain identical
## Security Considerations
1. **Authentication**: Optional Ed25519 signatures on consensus votes
2. **Encryption**: TLS 1.3 for capnp-rpc, with post-quantum option (ML-KEM)
3. **Rate Limiting**: Per-agent rate limits on consensus participation
4. **Message Size**: Configurable max message size (default 16MB)
## Implementation Plan
### Phase 1: Schema & Core (Week 1-2)
- [ ] Cap'n Proto schema definitions
- [ ] Rust capnp code generation
- [ ] Python pycapnp bindings
- [ ] AgentBus trait implementation
### Phase 2: Transport (Week 3-4)
- [ ] Cap'n Proto RPC transport (Rust)
- [ ] Cap'n Proto RPC transport (Python)
- [ ] MCP JSON-RPC fallback
- [ ] Protocol negotiation
### Phase 3: Consensus Integration (Week 5-6)
- [ ] Integrate hanzo-metastable-consensus
- [ ] Update plan_tool for consensus routing
- [ ] Agent tool with consensus support
- [ ] Benchmark and optimize
### Phase 4: Production (Week 7-8)
- [ ] Documentation
- [ ] Migration guide
- [ ] Performance benchmarks
- [ ] Security audit
## References
- [Cap'n Proto](https://capnproto.org/) - Zero-copy serialization
- [HIP-0300](./HIP-0300.md) - Unified MCP Tools Architecture
- [Metastable Consensus](https://github.com/luxfi/consensus) - Hanzo consensus protocol
- [MCP Specification](https://modelcontextprotocol.io/) - Model Context Protocol
## Appendix A: Message Size Comparison
```
Tool Call: fs.read("/etc/hosts")
JSON-RPC (MCP):
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "fs",
"arguments": {
"action": "read",
"path": "/etc/hosts"
}
}
}
Total: 156 bytes
Cap'n Proto:
[8-byte header][tool: 2 bytes][action: 4 bytes][path: 11 bytes][padding: 1 byte]
Total: 26 bytes (6x smaller)
```
## Appendix B: Consensus Latency Analysis
For N agents over R rounds with message size M:
```
JSON-RPC: T = N × R × (serialize(M) + deserialize(M) + network(M))
≈ N × R × (200μs + 100μs + network)
Cap'n Proto: T = N × R × network(M') where M' ≈ M/6
≈ N × R × network_only
For N=10, R=10, typical network=1ms:
JSON-RPC: 10 × 10 × (300μs + 1ms) = 130ms
Cap'n Proto: 10 × 10 × 1ms = 100ms + 0μs serialization = 100ms
Actual improvement: ~30% in this scenario, but:
- Local agents (no network): 100% improvement (0μs vs 30ms)
- Large messages: Scales linearly better
- High-frequency consensus: Cumulative savings
```
+25
View File
@@ -0,0 +1,25 @@
# Hanzo Improvement Proposals (HIPs)
Design documents for major architectural decisions in the Hanzo ecosystem.
## Active HIPs
| HIP | Title | Status | Description |
|-----|-------|--------|-------------|
| [HIP-0300](HIP-0300.md) | Unified MCP Tools Architecture | Draft | Consolidates 52+ tools into ~16 orthogonal operators |
| [HIP-0301](HIP-0301.md) | High-Performance Agent Communication | Draft | Cap'n Proto for sub-ms agent-to-agent communication |
## HIP Process
1. **Draft**: Initial proposal with design rationale
2. **Review**: Community feedback and iteration
3. **Accepted**: Approved for implementation
4. **Final**: Fully implemented and documented
## Contributing
To propose a new HIP:
1. Create a new markdown file: `HIP-XXXX.md`
2. Follow the template structure (Abstract, Motivation, Design, Implementation)
3. Submit a PR for review
+155
View File
@@ -0,0 +1,155 @@
# Code Tool
Symbol and structure operations for source code (HIP-0300 operator).
## Installation
```bash
pip install hanzo-tools-code
```
## Overview
The `code` tool handles all code analysis and transformation operations:
| Action | Signature | Effect |
|--------|-----------|--------|
| `parse` | `(Path \| Text, lang?) → AST` | PURE |
| `serialize` | `AST → Text` | PURE |
| `symbols` | `(Path \| AST, kind?) → [Symbol]` | PURE |
| `definition` | `(Path, Position) → [Location]` | DETERMINISTIC |
| `references` | `(Path, Position) → [Location]` | DETERMINISTIC |
| `transform` | `(Path \| Text, kind, params) → Patch` | PURE |
| `summarize` | `(Diff \| Log \| Report) → Summary` | PURE |
## Actions
### parse
Parse source code into an Abstract Syntax Tree using tree-sitter.
```python
code(action="parse", path="/src/main.py")
# Returns: {tree: {...}, lang: "python", node_count: 142}
code(action="parse", text="def foo(): pass", lang="python")
# Returns: {tree: {...}, lang: "python", node_count: 5}
```
**Parameters:**
- `path` (str, optional): Path to source file
- `text` (str, optional): Source code text (provide one of path or text)
- `lang` (str, optional): Language hint (auto-detected from extension)
### serialize
Convert AST back to source code.
```python
code(action="serialize", tree=ast_tree, lang="python")
# Returns: {text: "def foo(): pass", size: 16}
```
### symbols
Extract symbols (functions, classes, variables) from code.
```python
code(action="symbols", path="/src/main.py")
# Returns: {symbols: [{name: "foo", kind: "function", line: 1}, ...]}
code(action="symbols", path="/src/main.py", kind="class")
# Returns: {symbols: [{name: "MyClass", kind: "class", line: 10}]}
```
**Parameters:**
- `path` (str, optional): Path to source file
- `text` (str, optional): Source code text
- `kind` (str, optional): Filter by symbol kind (function, class, variable, etc.)
### definition
Find the definition of a symbol at a position (requires LSP).
```python
code(action="definition", path="/src/main.py", line=42, col=10)
# Returns: {locations: [{uri: "file:///src/utils.py", line: 15, col: 0}]}
```
### references
Find all references to a symbol (requires LSP).
```python
code(action="references", path="/src/main.py", line=42, col=10)
# Returns: {locations: [{uri: "...", line: ..., col: ...}, ...], count: 7}
```
### transform
Apply a transformation and produce a Patch (PURE - no side effects).
```python
# Rename a symbol
code(action="transform", path="/src/main.py", kind="rename",
old_name="authenticate", new_name="verify_user")
# Returns: {patch: [...], base_hash: "sha256:abc...", new_hash: "sha256:def...", changes_count: 5}
# Extract a function
code(action="transform", path="/src/main.py", kind="extract",
start_line=10, end_line=20, new_name="helper_function")
# Returns: {patch: [...], base_hash: "...", new_hash: "..."}
```
**Transform Kinds:**
- `rename` - Rename a symbol (old_name, new_name)
- `extract` - Extract code to function (start_line, end_line, new_name)
- `inline` - Inline a function/variable
- `move` - Move symbol to another file
### summarize
Compress diff/log/report into actionable summary.
```python
code(action="summarize", diff=patch_content)
# Returns: {summary: "Renamed authenticate to verify_user in 5 locations",
# risks: ["Breaking change for external callers"],
# next_actions: ["Update API documentation", "Run integration tests"]}
code(action="summarize", log=[{"sha": "abc", "message": "..."}])
# Returns: {summary: "3 commits: added auth, fixed bug, updated tests", ...}
```
## Transform vs Apply
The `code.transform` action is PURE - it produces a Patch value without modifying files. To apply the patch:
```python
# 1. Generate transform (PURE)
result = code(action="transform", path="/src/main.py", kind="rename",
old_name="foo", new_name="bar")
# 2. Review the patch
print(result["patch"])
print(result["summary"])
# 3. Apply with precondition (EFFECT)
fs(action="patch", path="/src/main.py", patch=result["patch"],
base_hash=result["base_hash"]) # Fails if file changed
```
## Language Support
Tree-sitter languages supported:
- Python, JavaScript, TypeScript, Rust, Go, C, C++, Java
- JSON, YAML, TOML, Markdown, HTML, CSS
Languages auto-detected from file extensions.
## See Also
- [HIP-0300](../hip/HIP-0300.md) - Unified Tools Architecture
- [Filesystem Tool](fs.md) - For applying patches with `fs.patch`
- [LSP Tool](lsp.md) - For additional semantic operations
- [Refactor Tool](refactor.md) - High-level refactoring operations
+38 -38
View File
@@ -2,6 +2,8 @@
Complete documentation for all `hanzo-tools-*` packages.
> **Architecture**: Tools follow [HIP-0300](../hip/HIP-0300.md) - the Unified MCP Tools Architecture with orthogonal operators and effect tracking.
## Installation
```bash
@@ -17,52 +19,50 @@ pip install hanzo-tools-shell hanzo-tools-browser
## Tool Categories
### Core Infrastructure
### HIP-0300 Core Operators
Primary operators organized by orthogonal axes:
| Tool | Axis | Actions | Description |
|------|------|---------|-------------|
| [fs](fs.md) | Bytes + Paths | read, write, edit, search, patch, tree | Filesystem operations |
| [id](core.md) | Identity | hash, uri, ref, verify | Content-addressable identity |
| [code](code.md) | Symbols + Structure | parse, transform, summarize | Code analysis and transformation |
| [proc](shell.md) | Execution | run, bg, signal, wait | Process execution |
| [vcs](vcs.md) | History + Diffs | status, diff, commit, log | Version control |
| [test](test.md) | Validation | check, build, test | Validation loops |
| [net](net.md) | Network | search, fetch, download, crawl | Network operations |
| [plan](plan.md) | Orchestration | intent, route, compose | Intent routing |
### Control Surfaces
| Tool | Surface | Description |
|------|---------|-------------|
| [browser](browser.md) | Web DOM | Playwright automation (70+ actions) |
| [computer](computer.md) | OS Desktop | Mac automation via pyautogui |
### Extended Operators
| Tool | Domain | Description |
|------|--------|-------------|
| [lsp](lsp.md) | Semantic Stream | Language server protocol (diagnostics, code_actions) |
| [memory](memory.md) | Knowledge | Persistent memory and knowledge bases |
| [todo](todo.md) | Task Tracking | Task management |
| [reasoning](reasoning.md) | Cognition | Structured thinking (think, critic) |
| [agent](agent.md) | Multi-Agent | Agent orchestration (run, list, status) |
| [llm](llm-tools.md) | LLM Interface | Unified LLM interface (llm, consensus) |
### Infrastructure
| Package | Tools | Description |
|---------|-------|-------------|
| [Core](core.md) | Base classes | Foundation for tool development |
| [Core](core.md) | Base classes | BaseTool, IdTool, ToolRegistry |
| [Config](config.md) | 2 | Configuration and mode management |
### File & Code Operations
| Package | Tools | Description |
|---------|-------|-------------|
| [Filesystem](fs.md) | 7 | read, write, edit, tree, find, search, ast |
| [LSP](lsp.md) | 1 | Language server protocol (definition, references, hover) |
| [Refactor](refactor.md) | 1 | Code refactoring (rename, extract, inline) |
### Shell & Execution
| Package | Tools | Description |
|---------|-------|-------------|
| [Shell](shell.md) | 12 | cmd, ps, zsh, bash, fish, dash, npx, uvx, open, curl, jq, wget |
| [Computer](computer.md) | 1 | Mac automation via pyautogui |
### AI & Agents
| Package | Tools | Description |
|---------|-------|-------------|
| [Agent](agent.md) | 3 | Multi-agent orchestration (agent, iching, review) |
| [LLM](llm-tools.md) | 2 | Unified LLM interface (llm, consensus) |
| [Reasoning](reasoning.md) | 2 | Structured thinking (think, critic) |
### Data & Memory
| Package | Tools | Description |
|---------|-------|-------------|
| [Memory](memory.md) | 9 | Persistent memory and knowledge bases |
| [Database](database.md) | 8 | SQL and graph database operations |
| [Vector](vector.md) | 3 | Semantic search with embeddings |
### Specialized
| Package | Tools | Description |
|---------|-------|-------------|
| [Browser](browser.md) | 1 | Playwright automation (70+ actions) |
| [Refactor](refactor.md) | 1 | Code refactoring (rename, extract, inline) |
| [Jupyter](jupyter.md) | 1 | Notebook read/edit/execute |
| [Editor](editor.md) | 3 | Neovim integration |
| [Todo](todo.md) | 1 | Task management |
| [MCP](mcp-tools.md) | 4 | MCP server management |
## Quick Reference
+242
View File
@@ -0,0 +1,242 @@
# Net Tool
Network operations: search, fetch, download, crawl (HIP-0300 operator).
## Installation
```bash
pip install hanzo-tools-net
# With full HTML parsing support
pip install hanzo-tools-net[full]
```
## Overview
The `net` tool handles all network operations:
| Action | Signature | Effect |
|--------|-----------|--------|
| `search` | `(Query, engine?) → [{url, title, snippet}]` | NONDETERMINISTIC |
| `fetch` | `(URL, extract_text?) → {text, mime, status, hash}` | NONDETERMINISTIC |
| `download` | `(URL, dest?, assets?) → {path, size, mime}` | NONDETERMINISTIC |
| `crawl` | `(URL, dest, depth?, limit?) → {pages, count}` | NONDETERMINISTIC |
| `head` | `URL → {status, headers, size?, mime?}` | NONDETERMINISTIC |
## Actions
### search
Perform web search queries.
```python
net(action="search", query="python async best practices")
# Returns: {
# results: [
# {url: "https://...", title: "Async Python Guide", snippet: "..."},
# {url: "https://...", title: "Python Concurrency", snippet: "..."}
# ],
# query: "python async best practices",
# engine: "duckduckgo",
# count: 10
# }
net(action="search", query="rust memory safety", limit=5)
# Limit results
```
**Parameters:**
- `query` (str): Search query
- `engine` (str, optional): Search engine ("duckduckgo" default)
- `limit` (int, optional): Maximum results (default: 10)
### fetch
Retrieve content from a URL.
```python
net(action="fetch", url="https://example.com/api/data")
# Returns: {
# text: "{\"data\": [...]}",
# mime: "application/json",
# status: 200,
# hash: "sha256:abc123...",
# size: 1234,
# url: "https://example.com/api/data"
# }
net(action="fetch", url="https://example.com", extract_text=True)
# Returns: {
# text: "Example Domain. This domain is for use in...", # Cleaned text
# mime: "text/html",
# status: 200,
# ...
# }
```
**Parameters:**
- `url` (str): URL to fetch
- `extract_text` (bool, optional): Extract text from HTML (default: False)
- `headers` (dict, optional): Additional HTTP headers
### download
Save URL content to local file.
```python
net(action="download", url="https://example.com/report.pdf")
# Returns: {
# path: "/current/dir/report.pdf",
# size: 245678,
# mime: "application/pdf",
# url: "https://example.com/report.pdf"
# }
net(action="download", url="https://example.com", dest="./mirror/index.html", assets=True)
# Returns: {
# path: "./mirror/index.html",
# size: 5678,
# mime: "text/html",
# assets: ["./mirror/index_assets/style.css", "./mirror/index_assets/logo.png"],
# assets_count: 12
# }
```
**Parameters:**
- `url` (str): URL to download
- `dest` (str, optional): Destination path (auto-generated if not specified)
- `assets` (bool, optional): Download page assets (images, CSS, JS)
### crawl
Recursively mirror a website.
```python
net(action="crawl", url="https://docs.example.com", dest="./mirror", depth=2)
# Returns: {
# pages: [
# "./mirror/index.html",
# "./mirror/getting-started.html",
# "./mirror/api/index.html",
# "./mirror/api/reference.html"
# ],
# count: 47,
# dest: "./mirror",
# depth: 2
# }
```
**Parameters:**
- `url` (str): Starting URL
- `dest` (str): Destination directory
- `depth` (int, optional): Maximum crawl depth (default: 2)
- `same_host` (bool, optional): Only crawl same hostname (default: True)
- `limit` (int, optional): Maximum pages to download (default: 100)
### head
Get HTTP headers without downloading body.
```python
net(action="head", url="https://example.com/large-file.zip")
# Returns: {
# status: 200,
# headers: {
# "content-type": "application/zip",
# "content-length": "1234567890",
# "last-modified": "..."
# },
# size: 1234567890,
# mime: "application/zip",
# url: "https://example.com/large-file.zip"
# }
```
## Usage Examples
### Research Workflow
```python
# 1. Search for information
results = net(action="search", query="rust async runtime comparison 2024")
# 2. Fetch promising articles
for result in results["data"]["results"][:3]:
content = net(action="fetch", url=result["url"], extract_text=True)
print(f"--- {result['title']} ---")
print(content["data"]["text"][:500])
```
### Documentation Mirror
```python
# Mirror documentation for offline access
result = net(action="crawl",
url="https://docs.example.com",
dest="./docs-mirror",
depth=3,
limit=200)
print(f"Downloaded {result['data']['count']} pages")
```
### API Integration
```python
# Fetch JSON API
response = net(action="fetch",
url="https://api.example.com/v1/users",
headers={"Authorization": "Bearer token..."})
import json
data = json.loads(response["data"]["text"])
```
### Pre-flight Check
```python
# Check if file exists and get size before downloading
info = net(action="head", url="https://example.com/large-dataset.tar.gz")
if info["data"]["status"] == 200:
size_mb = info["data"]["size"] / (1024 * 1024)
print(f"File size: {size_mb:.1f} MB")
if size_mb < 100: # Only download if < 100MB
net(action="download", url=info["data"]["url"])
```
## Error Handling
Network operations can fail. Handle errors appropriately:
```python
result = net(action="fetch", url="https://example.com/404")
if not result["ok"]:
print(f"Error: {result['error']['message']}")
# Error: HTTP 404: Not Found
```
## Dependencies
- `httpx` - HTTP client (required)
- `beautifulsoup4` + `lxml` - HTML parsing (optional, for better text extraction)
Install full dependencies:
```bash
pip install hanzo-tools-net[full]
```
## Rate Limiting
The net tool respects standard rate limiting:
- Honors `Retry-After` headers
- Default timeout: 30 seconds
- Configurable via environment variables
## See Also
- [HIP-0300](../hip/HIP-0300.md) - Unified Tools Architecture
- [Browser Tool](browser.md) - For JavaScript-rendered content
- [Filesystem Tool](fs.md) - For local file operations
+171
View File
@@ -0,0 +1,171 @@
# Plan Tool
Orchestration and intent routing for agentic workflows (HIP-0300 operator).
## Installation
```bash
pip install hanzo-tools-plan
```
## Overview
The `plan` tool routes natural language intents to canonical operator chains:
| Action | Signature | Effect |
|--------|-----------|--------|
| `intent` | `NL → IntentIR` | PURE |
| `route` | `(IntentIR, Policy?) → Plan` | PURE |
| `compose` | `Plan → ExecGraph` | PURE |
## Actions
### intent
Parse natural language into structured intent representation.
```python
plan(action="intent", nl="find where user authentication happens")
# Returns: {
# category: "navigate",
# action: "find",
# target: "user authentication",
# confidence: 0.92
# }
plan(action="intent", nl="rename authenticate to verify_user")
# Returns: {
# category: "refactor",
# action: "rename",
# target: "authenticate",
# params: {new_name: "verify_user"},
# confidence: 0.95
# }
```
**Intent Categories:**
- `navigate` - Finding code/files (find, search, locate)
- `explain` - Understanding code (what, why, how)
- `modify` - Changing code (rename, extract, fix)
- `validate` - Testing/checking (test, verify, check)
- `debug` - Troubleshooting (trace, why fails)
- `create` - Adding new code (add, create, implement)
### route
Map IntentIR to a canonical operator chain.
```python
intent_ir = {"category": "refactor", "action": "rename", "target": "foo"}
plan(action="route", intent_ir=intent_ir)
# Returns: {
# nodes: [
# {tool: "code", action: "references", params: {symbol: "foo"}},
# {tool: "code", action: "transform", params: {kind: "rename"}},
# {tool: "code", action: "summarize"},
# {tool: "fs", action: "patch", policy_gate: True},
# {tool: "test", action: "run"}
# ],
# policy_gates: [3],
# estimated_steps: 5
# }
```
**Parameters:**
- `intent_ir` (dict, optional): Structured intent from `intent` action
- `nl` (str, optional): Natural language (will call intent internally)
- `policy` (dict, optional): Custom policy overrides
### compose
Compile Plan into an execution graph with dependencies.
```python
plan(action="compose", plan=plan_result)
# Returns: {
# graph: {
# nodes: [...],
# edges: [(0,1), (1,2), (2,3), (3,4)],
# entry: 0,
# exit: 4
# },
# execution_order: [0, 1, 2, 3, 4],
# parallelizable: [[0], [1], [2], [3, 4]], # Groups that can run in parallel
# estimated_complexity: "medium"
# }
```
## Canonical Chains
Common intent → operator chain mappings:
| Intent Pattern | Canonical Chain |
|---------------|-----------------|
| "find X" | `fs.search(X)` |
| "what is X" | `fs.search(X) → code.summarize` |
| "rename X to Y" | `code.references(X) → code.transform(rename) → [policy] → fs.patch → test.run` |
| "fix bug in X" | `fs.read(X) → code.parse → code.transform(fix) → [policy] → fs.patch → test.run` |
| "add tests for X" | `code.symbols(X) → code.transform(add_tests) → fs.write → test.run` |
| "why does X fail" | `test.run(X) → vcs.log → code.summarize` |
| "refactor X" | `code.references(X) → code.transform → [policy] → fs.patch → test.run` |
## Policy Gates
High-risk operations require explicit approval:
```python
# In the plan output, policy_gate: True indicates approval needed
{
"nodes": [
{"tool": "code", "action": "transform", "params": {...}},
{"tool": "fs", "action": "patch", "policy_gate": True} # <-- requires approval
]
}
```
Configurable policy rules:
- File modifications (`fs.patch`, `fs.write`)
- Process execution with shell (`proc.run` with shell=True)
- Version control commits (`vcs.commit`, `vcs.push`)
## Example Workflow
```python
# 1. Parse user intent
intent = plan(action="intent", nl="rename the authenticate function to verify_credentials")
# 2. Route to operator chain
workflow = plan(action="route", intent_ir=intent["data"])
# 3. Compile to execution graph
graph = plan(action="compose", plan=workflow["data"])
# 4. Execute with policy gates
for step in graph["data"]["execution_order"]:
node = graph["data"]["nodes"][step]
if node.get("policy_gate"):
# Request user approval
approved = await request_approval(node)
if not approved:
break
# Execute the step
result = await dispatch(node["tool"], node["action"], node["params"])
```
## Custom Routing
Override default routing with custom rules:
```python
plan(action="route", nl="add caching to API",
policy={"prefer_tools": ["code", "test"], "require_tests": True})
```
## See Also
- [HIP-0300](../hip/HIP-0300.md) - Unified Tools Architecture
- [Agent Tool](agent.md) - Multi-agent orchestration
- [Code Tool](code.md) - Symbol and structure operations
- [Test Tool](test.md) - Validation operations
+222
View File
@@ -0,0 +1,222 @@
# Test Tool
Validation operations: check, build, test (HIP-0300 operator).
## Installation
```bash
pip install hanzo-tools-test
```
## Overview
The `test` tool provides three distinct validation loops (Vim-inspired):
| Action | Purpose | Equivalent | Effect |
|--------|---------|------------|--------|
| `check` | Fast incremental feedback | `:make` | NONDETERMINISTIC |
| `build` | Whole-project compilation | `:!make` | NONDETERMINISTIC |
| `test` | Runtime behavior validation | `:!make test` | NONDETERMINISTIC |
| `detect` | Auto-detect project tools | - | DETERMINISTIC |
## Actions
### check
Fast, incremental linting and type checking.
```python
test(action="check")
# Returns: {
# diagnostics: [
# {file: "src/main.py", line: 42, severity: "error", message: "..."},
# {file: "src/utils.py", line: 10, severity: "warning", message: "..."}
# ],
# pass: False,
# tool: "ruff",
# duration_ms: 234
# }
test(action="check", path="src/auth.py")
# Check specific file
test(action="check", tool="mypy")
# Use specific tool
```
**Supported Check Tools:**
| Tool | Languages | Command |
|------|-----------|---------|
| `ruff` | Python | `ruff check .` |
| `mypy` | Python | `mypy .` |
| `pyright` | Python | `pyright .` |
| `eslint` | JavaScript/TypeScript | `eslint .` |
| `tsc` | TypeScript | `tsc --noEmit` |
| `clippy` | Rust | `cargo clippy` |
| `golangci-lint` | Go | `golangci-lint run` |
### build
Whole-project compilation and dependency resolution.
```python
test(action="build")
# Returns: {
# success: True,
# artifacts: ["dist/main.js", "dist/main.js.map"],
# duration_ms: 5432,
# tool: "npm"
# }
test(action="build", tool="cargo")
# Use specific build tool
```
**Supported Build Tools:**
| Tool | Languages | Command |
|------|-----------|---------|
| `pip` | Python | `pip install -e .` |
| `uv` | Python | `uv sync` |
| `npm` | Node.js | `npm run build` |
| `pnpm` | Node.js | `pnpm build` |
| `cargo` | Rust | `cargo build` |
| `go` | Go | `go build ./...` |
| `make` | Any | `make` |
| `cmake` | C/C++ | `cmake --build .` |
### test
Runtime behavior validation with test isolation.
```python
test(action="test")
# Returns: {
# passed: 42,
# failed: 2,
# skipped: 3,
# total: 47,
# failures: [
# {name: "test_auth_flow", file: "tests/test_auth.py", message: "..."}
# ],
# duration_ms: 8765,
# tool: "pytest"
# }
test(action="test", selector="tests/test_auth.py")
# Run specific tests
test(action="test", selector="test_login")
# Run tests matching pattern
test(action="test", tool="jest", verbose=True)
# Use specific tool with options
```
**Supported Test Runners:**
| Tool | Languages | Command |
|------|-----------|---------|
| `pytest` | Python | `pytest -v` |
| `unittest` | Python | `python -m unittest` |
| `jest` | JavaScript/TypeScript | `jest` |
| `vitest` | JavaScript/TypeScript | `vitest run` |
| `mocha` | JavaScript | `mocha` |
| `cargo_test` | Rust | `cargo test` |
| `go_test` | Go | `go test ./...` |
| `make_test` | Any | `make test` |
### detect
Auto-detect project tools based on config files.
```python
test(action="detect")
# Returns: {
# test_runner: {name: "pytest", cmd: ["pytest", "-v"], config: "pyproject.toml"},
# build_tool: {name: "uv", cmd: ["uv", "sync"], config: "pyproject.toml"},
# check_tool: {name: "ruff", cmd: ["ruff", "check", "."], config: "ruff.toml"},
# detected_from: ["pyproject.toml", "ruff.toml"]
# }
test(action="detect", path="/path/to/project")
# Detect in specific directory
```
**Detection Priority:**
1. Check for config files (pyproject.toml, package.json, Cargo.toml, etc.)
2. Look for lock files (uv.lock, package-lock.json, Cargo.lock)
3. Examine project structure (tests/, src/, etc.)
## Three Validation Loops
The three operations serve distinct purposes:
### CHECK (Fast Feedback)
- **When:** During editing, before commits
- **Speed:** Milliseconds to seconds
- **Scope:** Single file or incremental
- **Output:** Diagnostics with line numbers
- **Goal:** Catch obvious errors immediately
### BUILD (Compilation)
- **When:** Before running, in CI
- **Speed:** Seconds to minutes
- **Scope:** Entire project
- **Output:** Artifacts or errors
- **Goal:** Verify everything compiles
### TEST (Validation)
- **When:** Before deploy, in CI
- **Speed:** Seconds to minutes
- **Scope:** Test suite
- **Output:** Pass/fail with details
- **Goal:** Verify behavior is correct
## Example Workflow
```python
# Quick check during development
check_result = test(action="check")
if not check_result["data"]["pass"]:
print("Fix these issues first:")
for diag in check_result["data"]["diagnostics"]:
print(f" {diag['file']}:{diag['line']}: {diag['message']}")
# Full build before commit
build_result = test(action="build")
if not build_result["data"]["success"]:
print("Build failed!")
# Run tests before push
test_result = test(action="test")
if test_result["data"]["failed"] > 0:
print(f"{test_result['data']['failed']} tests failed")
for failure in test_result["data"]["failures"]:
print(f" {failure['name']}: {failure['message']}")
```
## CI Integration
```yaml
# GitHub Actions example
jobs:
validate:
steps:
- name: Check
run: |
python -c "from hanzo_tools.test import test_tool; print(test_tool.call(action='check'))"
- name: Build
run: |
python -c "from hanzo_tools.test import test_tool; print(test_tool.call(action='build'))"
- name: Test
run: |
python -c "from hanzo_tools.test import test_tool; print(test_tool.call(action='test'))"
```
## See Also
- [HIP-0300](../hip/HIP-0300.md) - Unified Tools Architecture
- [Code Tool](code.md) - For code analysis before testing
- [VCS Tool](vcs.md) - For running tests on changes
- [Shell Tool](shell.md) - For custom test commands
+256
View File
@@ -0,0 +1,256 @@
# VCS Tool
Version control operations: status, diff, commit, log (HIP-0300 operator).
## Installation
```bash
pip install hanzo-tools-vcs
```
## Overview
The `vcs` tool handles all version control operations:
| Action | Signature | Effect |
|--------|-----------|--------|
| `status` | `() → {staged, unstaged, untracked}` | DETERMINISTIC |
| `diff` | `(ref1?, ref2?, paths?) → Diff` | DETERMINISTIC |
| `commit` | `(message, files?) → {sha, message}` | DETERMINISTIC |
| `log` | `(n?, since?, path?) → [Commit]` | DETERMINISTIC |
| `branch` | `(name?, action?) → {current, branches}` | DETERMINISTIC |
| `stash` | `(action, message?) → {ok}` | DETERMINISTIC |
| `apply` | `(patch) → {ok, files_changed}` | DETERMINISTIC |
| `checkout` | `(ref, paths?) → {ok}` | DETERMINISTIC |
## Actions
### status
Get working tree status.
```python
vcs(action="status")
# Returns: {
# staged: [
# {path: "src/main.py", status: "modified"},
# {path: "src/utils.py", status: "added"}
# ],
# unstaged: [
# {path: "src/config.py", status: "modified"}
# ],
# untracked: ["temp.txt", "notes.md"],
# branch: "feature/auth",
# ahead: 2,
# behind: 0
# }
```
### diff
Show differences between commits, branches, or working tree.
```python
# Working tree diff (unstaged)
vcs(action="diff")
# Returns: {
# diff: "diff --git a/src/main.py b/src/main.py\n...",
# files: ["src/main.py"],
# additions: 15,
# deletions: 3
# }
# Staged diff
vcs(action="diff", staged=True)
# Between commits
vcs(action="diff", ref1="HEAD~3", ref2="HEAD")
# Specific files
vcs(action="diff", paths=["src/auth.py", "src/users.py"])
# Between branches
vcs(action="diff", ref1="main", ref2="feature/auth")
```
### commit
Create a commit.
```python
vcs(action="commit", message="Add user authentication")
# Returns: {
# sha: "abc123def456",
# message: "Add user authentication",
# files_changed: 3
# }
# Commit specific files
vcs(action="commit", message="Fix bug in auth", files=["src/auth.py"])
# Amend last commit
vcs(action="commit", message="Updated message", amend=True)
```
### log
View commit history.
```python
vcs(action="log")
# Returns: {
# commits: [
# {sha: "abc123", message: "Add auth", author: "...", date: "..."},
# {sha: "def456", message: "Fix bug", author: "...", date: "..."}
# ]
# }
# Limit results
vcs(action="log", n=10)
# Filter by date
vcs(action="log", since="2024-01-01", until="2024-01-31")
# Filter by path
vcs(action="log", path="src/auth.py")
# Show full diff with each commit
vcs(action="log", n=5, show_diff=True)
```
### branch
Branch operations.
```python
# List branches
vcs(action="branch")
# Returns: {
# current: "feature/auth",
# local: ["main", "feature/auth", "bugfix/login"],
# remote: ["origin/main", "origin/develop"]
# }
# Create branch
vcs(action="branch", name="feature/new-feature", create=True)
# Delete branch
vcs(action="branch", name="old-branch", delete=True)
```
### stash
Stash operations.
```python
# Save stash
vcs(action="stash", push=True, message="WIP: auth changes")
# Returns: {ok: True, stash: "stash@{0}"}
# List stashes
vcs(action="stash", list=True)
# Returns: {stashes: [{ref: "stash@{0}", message: "..."}]}
# Apply stash
vcs(action="stash", apply=True)
# Pop stash (apply and remove)
vcs(action="stash", pop=True)
```
### apply
Apply a patch (unified diff format).
```python
patch = """
diff --git a/src/main.py b/src/main.py
--- a/src/main.py
+++ b/src/main.py
@@ -1,3 +1,4 @@
+# New header comment
def main():
pass
"""
vcs(action="apply", patch=patch)
# Returns: {ok: True, files_changed: ["src/main.py"]}
```
### checkout
Switch branches or restore files.
```python
# Switch branch
vcs(action="checkout", ref="main")
# Returns: {ok: True, branch: "main"}
# Create and switch
vcs(action="checkout", ref="feature/new", create=True)
# Restore specific files
vcs(action="checkout", ref="HEAD", paths=["src/main.py"])
```
## Usage Examples
### Safe Commit Workflow
```python
# 1. Check status
status = vcs(action="status")
# 2. Review diff
if status["data"]["unstaged"]:
diff = vcs(action="diff")
print(diff["data"]["diff"])
# 3. Stage and commit
vcs(action="commit", message="Implement user authentication",
files=["src/auth.py", "tests/test_auth.py"])
```
### Code Review Workflow
```python
# 1. Get diff between branches
diff = vcs(action="diff", ref1="main", ref2="feature/auth")
# 2. Summarize with code tool
from hanzo_tools.code import code_tool
summary = code_tool(action="summarize", diff=diff["data"]["diff"])
print(f"Changes: +{diff['data']['additions']} -{diff['data']['deletions']}")
print(f"Summary: {summary['data']['summary']}")
```
### History Analysis
```python
# Get recent changes to a file
commits = vcs(action="log", path="src/api.py", n=10, show_diff=True)
for commit in commits["data"]["commits"]:
print(f"{commit['sha'][:7]} {commit['message']}")
```
## Integration with fs.patch
VCS diffs integrate with `fs.patch` for applying changes:
```python
# Generate diff
diff = vcs(action="diff", ref1="main", ref2="feature/fix")
# Apply to another location
fs(action="patch", path="/other/project/src/main.py",
patch=diff["data"]["diff"])
```
## See Also
- [HIP-0300](../hip/HIP-0300.md) - Unified Tools Architecture
- [Filesystem Tool](fs.md) - For `fs.patch` to apply diffs
- [Code Tool](code.md) - For `code.summarize` on diffs
- [Test Tool](test.md) - For validation after commits
+19 -5
View File
@@ -75,13 +75,19 @@ def _get_permission_manager():
def _get_session_storage():
"""Get SessionStorage class lazily."""
"""Get SessionStorage class lazily.
Returns None if session_storage module is not available.
"""
global _SessionStorage
if _SessionStorage is None:
from hanzo_tools.shell.session_storage import SessionStorage
_SessionStorage = SessionStorage
return _SessionStorage
try:
from hanzo_tools.shell.session_storage import SessionStorage
_SessionStorage = SessionStorage
except ImportError:
# session_storage module not available - return sentinel value
_SessionStorage = False
return _SessionStorage if _SessionStorage else None
def _get_register_all_tools():
@@ -282,6 +288,11 @@ class HanzoMCPServer:
def _background_cleanup(self) -> None:
"""Background thread for periodic session cleanup."""
SessionStorage = _get_session_storage()
if SessionStorage is None:
# No session storage available, just wait for shutdown
self._shutdown_event.wait()
return
while not self._shutdown_event.is_set():
try:
# Clean up expired sessions every 2 minutes
@@ -298,6 +309,9 @@ class HanzoMCPServer:
"""Clean up all active sessions."""
try:
SessionStorage = _get_session_storage()
if SessionStorage is None:
return
cleared_count = SessionStorage.clear_all_sessions()
if cleared_count > 0:
# Only log if not stdio transport
@@ -27,29 +27,35 @@ logger = logging.getLogger(__name__)
TOOLS_ENTRY_POINT_GROUP = "hanzo.tools"
# Package to tool prefix mapping (for enable/disable)
# HIP-0300 Operator Lattice: 16 tools organized by axis
PACKAGE_TOOL_PREFIXES: dict[str, list[str]] = {
"filesystem": ["read", "write", "edit", "tree", "ast", "search", "find"],
"shell": ["dag", "ps", "zsh", "shell", "npx", "uvx", "open", "curl", "jq", "wget"],
"browser": ["browser"],
"memory": ["memory"], # Unified memory tool
"todo": ["todo"],
# Core operators (HIP-0300)
"filesystem": ["fs"], # Bytes + Paths axis
"core": ["id"], # Identity axis (hash, uri, ref, verify)
"code": ["code"], # Symbols + Structure axis (parse, transform, summarize)
"shell": ["proc"], # Execution axis
"vcs": ["vcs"], # History + Diffs axis
"test": ["test"], # Validation axis (check, build, test)
"net": ["net"], # Network axis (search, fetch, download, crawl)
"plan": ["plan"], # Orchestration axis (intent, route, compose)
# Control surfaces
"browser": ["browser"], # Web content control (Playwright/extension)
"computer": ["computer"], # OS/desktop control (computer use)
# Extended operators
"lsp": ["lsp"], # Semantic stream (diagnostics, code_actions)
"memory": ["memory"], # Knowledge persistence
"todo": ["todo"], # Task tracking
"reasoning": ["think", "critic"],
"lsp": ["lsp"],
"refactor": ["refactor"],
"database": ["sql", "graph"], # Consolidated database tools
"agent": [
"agent",
"iching",
"review",
], # Consolidated agent tools (critic is from reasoning)
"database": ["sql", "graph"],
"agent": ["agent", "iching", "review"],
"jupyter": ["jupyter"],
"editor": ["neovim_edit", "neovim_command", "neovim_session"],
"llm": ["llm", "consensus"], # Removed llm_manage
"llm": ["llm", "consensus"],
"vector": ["index", "vector_index", "vector_search"],
"config": ["config", "mode"],
"mcp_tools": ["mcp"], # Consolidated MCP tool
"computer": ["computer", "screen"],
"api": ["api"], # Generic API tool for OpenAPI specs
"mcp_tools": ["mcp"],
"api": ["api"],
}
@@ -59,6 +59,44 @@ except ImportError:
logger = logging.getLogger(__name__)
async def _check_extension() -> bool:
"""Check if Hanzo browser extension is connected."""
try:
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.get(
"http://localhost:9224/status",
timeout=aiohttp.ClientTimeout(total=1)
) as resp:
if resp.status == 200:
data = await resp.json()
return data.get("connected", False)
except Exception:
pass
return False
async def _extension_command(action: str, **kwargs) -> Optional[dict]:
"""Send command to Hanzo browser extension."""
try:
import aiohttp
# Filter out None values
payload = {"action": action}
payload.update({k: v for k, v in kwargs.items() if v is not None})
async with aiohttp.ClientSession() as session:
async with session.post(
"http://localhost:9224",
json=payload,
timeout=aiohttp.ClientTimeout(total=30)
) as resp:
if resp.status == 200:
return await resp.json()
except Exception as e:
logger.debug(f"Extension command failed: {e}")
return None
# Device presets - user-friendly aliases + specific devices
DEVICES = {
# User-friendly aliases
@@ -682,11 +720,38 @@ CATEGORIES:
# Filter
level: Optional[str] = None,
) -> dict[str, Any]:
"""Execute browser action with full Playwright API support."""
pool = await BrowserPool.get_instance()
"""Execute browser action with full Playwright API support.
Automatically uses Hanzo browser extension if connected,
falling back to Playwright for headless automation.
"""
timeout = timeout or self.timeout
sel = selector or ref
# === TRY EXTENSION FIRST ===
extension_actions = {"navigate", "screenshot", "click", "fill", "evaluate", "tabs", "status"}
if action in extension_actions:
ext_result = await _extension_command(
action, url=url, selector=sel, text=text, value=value,
code=code, expression=code, full_page=full_page
)
if ext_result is not None and "error" not in ext_result:
# Normalize response
if "result" in ext_result:
ext_result = ext_result["result"]
return {"success": True, "source": "extension", **ext_result}
# === FALL BACK TO PLAYWRIGHT ===
if not PLAYWRIGHT_AVAILABLE:
return {
"error": "Browser extension not connected and Playwright not installed. "
"Either connect the Hanzo browser extension or install Playwright: "
"pip install playwright && playwright install chromium",
"action": action
}
pool = await BrowserPool.get_instance()
try:
# === Connection ===
if action == "connect":
@@ -2,16 +2,20 @@
CDP Bridge Server for Hanzo Browser Extension Integration.
This server acts as a bridge between:
1. hanzo-mcp's browser tool (Playwright)
1. hanzo-mcp's browser tool (Playwright)
2. The Hanzo browser extension (Chrome/Firefox)
The browser extension connects via WebSocket, and hanzo-mcp can send
commands through the extension to control browser tabs.
MULTI-CLIENT SUPPORT:
- Multiple browser extensions can connect simultaneously
- Each client has a unique client_id (uuid sent on registration)
- Target IDs are namespaced: "<client_id>:<tab_id>"
- Commands can specify client_id or target_id for routing
- Default client = most recently active
Usage:
# Start the bridge server (typically on port 9223)
python -m hanzo_tools.browser.cdp_bridge_server
# Or programmatically
from hanzo_tools.browser.cdp_bridge_server import CDPBridgeServer
server = CDPBridgeServer(port=9223)
@@ -26,6 +30,9 @@ import asyncio
import json
import logging
import os
import time
import uuid
from dataclasses import dataclass, field
from typing import Any, Callable, Optional
try:
@@ -40,9 +47,35 @@ except ImportError:
logger = logging.getLogger(__name__)
@dataclass
class ExtensionClient:
"""Represents a connected browser extension client."""
client_id: str
websocket: WebSocketServerProtocol
browser: str = "unknown"
profile: str = "default"
user_agent: str = ""
capabilities: list = field(default_factory=list)
connected_at: float = field(default_factory=time.time)
last_active: float = field(default_factory=time.time)
def to_dict(self) -> dict:
return {
"client_id": self.client_id,
"browser": self.browser,
"profile": self.profile,
"capabilities": self.capabilities,
"connected_at": self.connected_at,
"last_active": self.last_active,
}
class CDPBridgeServer:
"""WebSocket server that bridges hanzo-mcp and browser extension."""
"""WebSocket server that bridges hanzo-mcp and browser extensions.
Supports multiple browser extension clients simultaneously.
"""
def __init__(
self,
host: str = "localhost",
@@ -50,12 +83,34 @@ class CDPBridgeServer:
):
self.host = host
self.port = port
self.extension_client: Optional[WebSocketServerProtocol] = None
# Multi-client registry: client_id -> ExtensionClient
self.extension_clients: dict[str, ExtensionClient] = {}
# Reverse lookup: websocket -> client_id
self._ws_to_client_id: dict[WebSocketServerProtocol, str] = {}
self.mcp_clients: set[WebSocketServerProtocol] = set()
self.pending_requests: dict[int, asyncio.Future] = {}
self.request_id = 0
self._server = None
@property
def default_client_id(self) -> Optional[str]:
"""Get the default client (most recently active)."""
if not self.extension_clients:
return None
# Return the client with most recent last_active timestamp
return max(
self.extension_clients.keys(),
key=lambda cid: self.extension_clients[cid].last_active
)
@property
def default_client(self) -> Optional[ExtensionClient]:
"""Get the default ExtensionClient."""
cid = self.default_client_id
return self.extension_clients.get(cid) if cid else None
async def start(self) -> None:
"""Start the WebSocket server."""
if not WEBSOCKETS_AVAILABLE:
@@ -63,21 +118,21 @@ class CDPBridgeServer:
"websockets package required for CDP bridge. "
"Install with: pip install websockets"
)
self._server = await ws_serve(
self._handle_connection,
self.host,
self.port,
)
logger.info(f"CDP Bridge Server started on ws://{self.host}:{self.port}")
async def stop(self) -> None:
"""Stop the WebSocket server."""
if self._server:
self._server.close()
await self._server.wait_closed()
logger.info("CDP Bridge Server stopped")
async def _handle_connection(
self,
websocket: WebSocketServerProtocol,
@@ -85,115 +140,200 @@ class CDPBridgeServer:
) -> None:
"""Handle incoming WebSocket connections."""
logger.info(f"New connection from {websocket.remote_address} on {path}")
try:
# First message identifies the client type
message = await websocket.recv()
data = json.loads(message)
if data.get("type") == "register":
role = data.get("role")
if role == "cdp-provider":
# This is the browser extension
self.extension_client = websocket
logger.info("Browser extension registered as CDP provider")
# This is a browser extension
# Client can provide its own ID or we generate one
client_id = data.get("client_id") or str(uuid.uuid4())[:8]
client = ExtensionClient(
client_id=client_id,
websocket=websocket,
browser=data.get("browser", "unknown"),
profile=data.get("profile", "default"),
user_agent=data.get("userAgent", ""),
capabilities=data.get("capabilities", []),
)
self.extension_clients[client_id] = client
self._ws_to_client_id[websocket] = client_id
logger.info(
f"Browser extension registered: {client_id} "
f"({client.browser}/{client.profile})"
)
# Send back the assigned client_id
await websocket.send(json.dumps({
"type": "registered",
"client_id": client_id,
}))
# Notify MCP clients
for client in self.mcp_clients:
await client.send(json.dumps({
for mcp in self.mcp_clients:
await mcp.send(json.dumps({
"type": "provider_connected",
"capabilities": data.get("capabilities", [])
"client_id": client_id,
"browser": client.browser,
"profile": client.profile,
"capabilities": client.capabilities,
"total_clients": len(self.extension_clients),
}))
elif role == "mcp-client":
# This is hanzo-mcp or another MCP tool
self.mcp_clients.add(websocket)
logger.info("MCP client connected")
# Let client know if extension is connected
# Send status with all connected clients
await websocket.send(json.dumps({
"type": "status",
"extension_connected": self.extension_client is not None
"connected": len(self.extension_clients) > 0,
"clients": [c.to_dict() for c in self.extension_clients.values()],
"default_client_id": self.default_client_id,
}))
# Handle subsequent messages
async for message in websocket:
await self._route_message(websocket, message)
except websockets.exceptions.ConnectionClosed:
logger.info(f"Connection closed: {websocket.remote_address}")
finally:
# Clean up
if websocket == self.extension_client:
self.extension_client = None
if websocket in self._ws_to_client_id:
client_id = self._ws_to_client_id.pop(websocket)
self.extension_clients.pop(client_id, None)
logger.info(f"Extension client disconnected: {client_id}")
# Notify MCP clients
for client in self.mcp_clients:
for mcp in self.mcp_clients:
try:
await client.send(json.dumps({
"type": "provider_disconnected"
await mcp.send(json.dumps({
"type": "provider_disconnected",
"client_id": client_id,
"remaining_clients": len(self.extension_clients),
}))
except Exception:
pass
elif websocket in self.mcp_clients:
self.mcp_clients.discard(websocket)
def _resolve_client(
self,
client_id: Optional[str] = None,
target_id: Optional[str] = None,
) -> Optional[ExtensionClient]:
"""Resolve which client to route to.
Args:
client_id: Explicit client ID
target_id: Namespaced target like "clientid:tabid"
Returns:
ExtensionClient or None
"""
# Parse target_id if provided (format: "client_id:tab_id")
if target_id and ":" in target_id:
cid = target_id.split(":")[0]
if cid in self.extension_clients:
return self.extension_clients[cid]
# Use explicit client_id
if client_id and client_id in self.extension_clients:
return self.extension_clients[client_id]
# Fall back to default (most recently active)
return self.default_client
async def _route_message(
self,
sender: WebSocketServerProtocol,
message: str,
) -> None:
"""Route messages between extension and MCP clients."""
"""Route messages between extensions and MCP clients."""
data = json.loads(message)
if sender == self.extension_client:
# Check if message is from an extension
if sender in self._ws_to_client_id:
client_id = self._ws_to_client_id[sender]
# Update last_active
if client_id in self.extension_clients:
self.extension_clients[client_id].last_active = time.time()
# Message from extension (response or event)
if "id" in data and data["id"] in self.pending_requests:
# This is a response to a pending request
future = self.pending_requests.pop(data["id"])
# Add source client_id to response
data["_client_id"] = client_id
future.set_result(data)
elif data.get("type") == "event":
# Add source client_id to event
data["_client_id"] = client_id
# Broadcast event to all MCP clients
for client in self.mcp_clients:
for mcp in self.mcp_clients:
try:
await client.send(message)
await mcp.send(json.dumps(data))
except Exception:
pass
elif sender in self.mcp_clients:
# Message from MCP client (command)
if not self.extension_client:
# No extension connected
# Resolve target client
target_client = self._resolve_client(
client_id=data.get("client_id"),
target_id=data.get("target_id"),
)
if not target_client:
# No extension connected or specified client not found
await sender.send(json.dumps({
"id": data.get("id"),
"error": {
"code": -32000,
"message": "Browser extension not connected"
"message": "No browser extension connected"
if not self.extension_clients
else f"Client not found: {data.get('client_id') or data.get('target_id')}"
}
}))
return
# Forward to extension and wait for response
# Update target client's last_active
target_client.last_active = time.time()
# Forward to target extension and wait for response
request_id = data.get("id", self._next_request_id())
data["id"] = request_id
future: asyncio.Future = asyncio.get_event_loop().create_future()
self.pending_requests[request_id] = future
try:
await self.extension_client.send(json.dumps(data))
await target_client.websocket.send(json.dumps(data))
# Wait for response with timeout
response = await asyncio.wait_for(future, timeout=30.0)
# Include client_id in response
response["client_id"] = target_client.client_id
await sender.send(json.dumps(response))
except asyncio.TimeoutError:
self.pending_requests.pop(request_id, None)
await sender.send(json.dumps({
"id": request_id,
"error": {
"code": -32001,
"message": "Request timeout"
"message": f"Request timeout (client: {target_client.client_id})"
}
}))
except Exception as e:
@@ -205,7 +345,7 @@ class CDPBridgeServer:
"message": str(e)
}
}))
def _next_request_id(self) -> int:
"""Generate next request ID."""
self.request_id += 1
@@ -214,7 +354,7 @@ class CDPBridgeServer:
class CDPBridgeClient:
"""Client for connecting to CDP Bridge Server from hanzo-mcp."""
def __init__(
self,
host: str = "localhost",
@@ -226,146 +366,239 @@ class CDPBridgeClient:
self._request_id = 0
self._pending: dict[int, asyncio.Future] = {}
self._event_handlers: list[Callable] = []
self._clients: list[dict] = []
self._default_client_id: Optional[str] = None
@property
def clients(self) -> list[dict]:
"""List of connected browser extension clients."""
return self._clients
@property
def default_client_id(self) -> Optional[str]:
"""ID of the default (most recently active) client."""
return self._default_client_id
async def connect(self) -> bool:
"""Connect to the CDP bridge server."""
if not WEBSOCKETS_AVAILABLE:
logger.warning("websockets not available, CDP bridge disabled")
return False
try:
import websockets
uri = f"ws://{self.host}:{self.port}/cdp"
self._websocket = await websockets.connect(uri)
# Register as MCP client
await self._websocket.send(json.dumps({
"type": "register",
"role": "mcp-client"
}))
# Wait for status response
status_msg = await self._websocket.recv()
status = json.loads(status_msg)
if status.get("type") == "status":
self._clients = status.get("clients", [])
self._default_client_id = status.get("default_client_id")
# Start message handler
asyncio.create_task(self._message_loop())
logger.info(f"Connected to CDP bridge at {uri}")
return True
except Exception as e:
logger.warning(f"Failed to connect to CDP bridge: {e}")
return False
async def disconnect(self) -> None:
"""Disconnect from the bridge server."""
if self._websocket:
await self._websocket.close()
self._websocket = None
async def _message_loop(self) -> None:
"""Process incoming messages."""
if not self._websocket:
return
try:
async for message in self._websocket:
data = json.loads(message)
# Handle status updates
if data.get("type") == "provider_connected":
# New client connected
self._clients.append({
"client_id": data.get("client_id"),
"browser": data.get("browser"),
"profile": data.get("profile"),
"capabilities": data.get("capabilities"),
})
elif data.get("type") == "provider_disconnected":
# Client disconnected
cid = data.get("client_id")
self._clients = [c for c in self._clients if c.get("client_id") != cid]
# Handle responses
if "id" in data and data["id"] in self._pending:
future = self._pending.pop(data["id"])
if "error" in data:
future.set_exception(Exception(data["error"]["message"]))
else:
future.set_result(data.get("result"))
elif data.get("type") == "event":
for handler in self._event_handlers:
try:
handler(data)
except Exception:
pass
except Exception as e:
logger.error(f"Message loop error: {e}")
async def send(self, method: str, params: dict = None) -> Any:
"""Send a CDP command and wait for response."""
async def send(
self,
method: str,
params: dict = None,
client_id: str = None,
target_id: str = None,
) -> Any:
"""Send a CDP command and wait for response.
Args:
method: CDP method name
params: Method parameters
client_id: Optional specific client to target
target_id: Optional namespaced target (client_id:tab_id)
Returns:
Result from the extension
"""
if not self._websocket:
raise Exception("Not connected to CDP bridge")
self._request_id += 1
request_id = self._request_id
future: asyncio.Future = asyncio.get_event_loop().create_future()
self._pending[request_id] = future
await self._websocket.send(json.dumps({
payload = {
"id": request_id,
"method": method,
"params": params or {}
}))
}
# Add routing hints
if client_id:
payload["client_id"] = client_id
if target_id:
payload["target_id"] = target_id
await self._websocket.send(json.dumps(payload))
return await asyncio.wait_for(future, timeout=30.0)
def on_event(self, handler: Callable) -> None:
"""Register an event handler."""
self._event_handlers.append(handler)
# High-level commands
async def navigate(self, url: str, tab_id: int = None) -> None:
async def navigate(
self,
url: str,
tab_id: int = None,
client_id: str = None,
) -> None:
"""Navigate to a URL."""
await self.send("Page.navigate", {"url": url, "tabId": tab_id})
await self.send(
"Page.navigate",
{"url": url, "tabId": tab_id},
client_id=client_id,
)
async def screenshot(
self,
tab_id: int = None,
full_page: bool = False,
format: str = "png",
client_id: str = None,
) -> str:
"""Take a screenshot, returns base64 data."""
result = await self.send("hanzo.screenshot", {
"tabId": tab_id,
"fullPage": full_page,
"format": format
})
result = await self.send(
"hanzo.screenshot",
{"tabId": tab_id, "fullPage": full_page, "format": format},
client_id=client_id,
)
return result.get("data", "")
async def click(self, selector: str, tab_id: int = None) -> bool:
async def click(
self,
selector: str,
tab_id: int = None,
client_id: str = None,
) -> bool:
"""Click an element by selector."""
result = await self.send("hanzo.click", {
"selector": selector,
"tabId": tab_id
})
result = await self.send(
"hanzo.click",
{"selector": selector, "tabId": tab_id},
client_id=client_id,
)
return result.get("success", False)
async def fill(self, selector: str, value: str, tab_id: int = None) -> bool:
async def fill(
self,
selector: str,
value: str,
tab_id: int = None,
client_id: str = None,
) -> bool:
"""Fill an input element."""
result = await self.send("hanzo.fill", {
"selector": selector,
"value": value,
"tabId": tab_id
})
result = await self.send(
"hanzo.fill",
{"selector": selector, "value": value, "tabId": tab_id},
client_id=client_id,
)
return result.get("success", False)
async def evaluate(self, expression: str, tab_id: int = None) -> Any:
async def evaluate(
self,
expression: str,
tab_id: int = None,
client_id: str = None,
) -> Any:
"""Evaluate JavaScript in the page."""
return await self.send("Runtime.evaluate", {
"expression": expression,
"tabId": tab_id
})
return await self.send(
"Runtime.evaluate",
{"expression": expression, "tabId": tab_id},
client_id=client_id,
)
async def list_clients(self) -> list[dict]:
"""Get list of connected browser extension clients."""
# Refresh from server
result = await self.send("hanzo.listClients", {})
return result.get("clients", self._clients)
async def main():
"""Run the CDP bridge server."""
host = os.environ.get("HANZO_CDP_BRIDGE_HOST", "localhost")
port = int(os.environ.get("HANZO_CDP_BRIDGE_PORT", "9223"))
server = CDPBridgeServer(host=host, port=port)
await server.start()
print(f"CDP Bridge Server running on ws://{host}:{port}")
print("Waiting for browser extension to connect...")
print("Waiting for browser extension(s) to connect...")
print("Supports multiple browsers simultaneously")
print("Press Ctrl+C to stop")
try:
await asyncio.Future() # Run forever
except KeyboardInterrupt:
+4 -3
View File
@@ -4,17 +4,18 @@ build-backend = "setuptools.build_meta"
[project]
name = "hanzo-tools-browser"
version = "0.2.0"
description = "Browser automation tools using Playwright - async-first, shared browser pool"
version = "0.2.2"
description = "Browser automation tools with extension-first routing - uses Hanzo browser extension when available, Playwright as fallback"
readme = "README.md"
requires-python = ">=3.12"
license = { text = "MIT" }
authors = [{ name = "Hanzo Industries Inc", email = "[email protected]" }]
keywords = ["hanzo", "tools", "browser", "playwright", "mcp", "ai", "automation"]
keywords = ["hanzo", "tools", "browser", "playwright", "mcp", "ai", "automation", "extension"]
dependencies = [
"hanzo-tools>=0.3.0",
"mcp>=1.25.0",
"pydantic>=2.12.5",
"aiohttp>=3.9.0",
]
[project.optional-dependencies]
@@ -0,0 +1,3 @@
# Namespace package
from pkgutil import extend_path
__path__ = extend_path(__path__, __name__)
@@ -0,0 +1,59 @@
"""Code semantic tools for Hanzo AI (HIP-0300).
Tools:
- code: Unified code semantics tool (HIP-0300)
- parse: Parse source to AST (tree-sitter)
- serialize: AST back to text
- symbols: List symbols in file
- definition: Go to definition (LSP)
- references: Find all references (LSP)
- transform: Pure codemod → Patch
- summarize: Compress diff/log/report
Effect lattice position: PURE
All operations are safe to cache and parallelize.
Install:
pip install hanzo-tools-code
pip install hanzo-tools-code[tree-sitter] # For AST parsing
pip install hanzo-tools-code[lsp] # For LSP integration
Usage:
from hanzo_tools.code import register_tools, TOOLS
# Register with MCP server
register_tools(mcp_server)
# Or access the unified tool
from hanzo_tools.code import CodeTool
"""
from hanzo_tools.core import BaseTool, ToolRegistry
from .code_tool import CodeTool, code_tool
# Export list for tool discovery - HIP-0300 unified tool
TOOLS = [CodeTool]
__all__ = [
"CodeTool",
"code_tool",
"register_tools",
"TOOLS",
]
def register_tools(mcp_server, **kwargs) -> list[BaseTool]:
"""Register code tools with the MCP server.
Args:
mcp_server: The FastMCP server instance
**kwargs: Additional options (cwd, etc.)
Returns:
List of registered tool instances
"""
cwd = kwargs.get("cwd")
tool = CodeTool(cwd=cwd)
ToolRegistry.register_tool(mcp_server, tool)
return [tool]
@@ -0,0 +1,601 @@
"""Unified code semantics tool for HIP-0300 architecture.
This module provides a single unified 'code' tool that handles all semantic code operations:
- parse: Parse source to AST (tree-sitter)
- serialize: AST back to text
- symbols: List symbols in file/scope
- definition: Go to definition (LSP)
- references: Find all references (LSP)
- transform: Pure codemod → Patch (no side effects)
- summarize: Compress Diff/Log/Report to summary
Following Unix philosophy: one tool for the Symbols + Structure axis.
All operations are PURE (no side effects) except where noted.
Effect lattice position: PURE
Representation: Text → AST → Patch
Scope: File → Package → Repo
"""
import os
import json
import hashlib
import difflib
from typing import Any, ClassVar, Literal
from pathlib import Path
from dataclasses import dataclass, field
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import (
BaseTool,
ToolError,
InvalidParamsError,
NotFoundError,
Paging,
content_hash,
)
# Language detection by extension
LANG_MAP = {
".py": "python",
".js": "javascript",
".ts": "typescript",
".tsx": "tsx",
".jsx": "jsx",
".go": "go",
".rs": "rust",
".c": "c",
".cpp": "cpp",
".h": "c",
".hpp": "cpp",
".java": "java",
".rb": "ruby",
".php": "php",
".swift": "swift",
".kt": "kotlin",
".scala": "scala",
".cs": "c_sharp",
".lua": "lua",
".sh": "bash",
".bash": "bash",
".zsh": "bash",
".json": "json",
".yaml": "yaml",
".yml": "yaml",
".toml": "toml",
".md": "markdown",
".html": "html",
".css": "css",
".sql": "sql",
}
@dataclass
class TransformSpec:
"""Specification for code transformation."""
kind: Literal["rename", "extract", "inline", "move", "codemod", "generate"]
# For rename
old_name: str | None = None
new_name: str | None = None
scope: Literal["file", "package", "repo"] = "file"
# For codemod (pattern-based)
match_pattern: str | None = None
replace_template: str | None = None
# For generate
template: str | None = None
context: dict[str, Any] = field(default_factory=dict)
class CodeTool(BaseTool):
"""Unified code semantics tool (HIP-0300).
Handles all semantic code operations on a single axis:
- parse: Parse source to AST
- serialize: AST to text
- symbols: List symbols
- definition: Go to definition
- references: Find references
- transform: Pure codemod → Patch
- summarize: Compress to summary
All operations are PURE (safe to parallelize/cache).
"""
name: ClassVar[str] = "code"
VERSION: ClassVar[str] = "0.1.0"
def __init__(self, cwd: str | None = None):
super().__init__()
self.cwd = cwd or os.getcwd()
self._tree_sitter = None
self._lsp_clients: dict[str, Any] = {}
self._register_code_actions()
@property
def description(self) -> str:
return """Unified code semantics tool (HIP-0300).
Actions:
- parse: Parse source code to AST (tree-sitter)
- serialize: Convert AST back to text
- symbols: List symbols in file/scope
- definition: Go to symbol definition (LSP)
- references: Find all references to symbol (LSP)
- transform: Pure codemod producing Patch (no side effects)
- summarize: Compress Diff/Log/Report to summary
All operations are PURE - safe to cache and parallelize.
"""
def _detect_lang(self, path: str | None, text: str | None = None) -> str:
"""Detect language from path extension or content."""
if path:
ext = Path(path).suffix.lower()
if ext in LANG_MAP:
return LANG_MAP[ext]
# Fallback: try to detect from shebang or content
if text:
first_line = text.split("\n", 1)[0]
if first_line.startswith("#!"):
if "python" in first_line:
return "python"
elif "node" in first_line or "deno" in first_line:
return "javascript"
elif "bash" in first_line or "sh" in first_line:
return "bash"
return "unknown"
def _get_tree_sitter(self):
"""Lazy-load tree-sitter."""
if self._tree_sitter is None:
try:
import tree_sitter_languages
self._tree_sitter = tree_sitter_languages
except ImportError:
raise ToolError(
code="INTERNAL_ERROR",
message="tree-sitter-languages not installed. Run: pip install tree-sitter-languages",
)
return self._tree_sitter
def _parse_with_tree_sitter(self, text: str, lang: str) -> dict:
"""Parse text to AST using tree-sitter."""
ts = self._get_tree_sitter()
try:
parser = ts.get_parser(lang)
tree = parser.parse(text.encode())
def node_to_dict(node) -> dict:
result = {
"type": node.type,
"start": {"line": node.start_point[0], "col": node.start_point[1]},
"end": {"line": node.end_point[0], "col": node.end_point[1]},
}
if node.child_count > 0:
result["children"] = [node_to_dict(c) for c in node.children]
else:
result["text"] = node.text.decode() if node.text else ""
return result
return {
"root": node_to_dict(tree.root_node),
"lang": lang,
"errors": [
{
"line": n.start_point[0],
"col": n.start_point[1],
"message": "syntax error",
}
for n in tree.root_node.children
if n.type == "ERROR"
],
}
except Exception as e:
raise ToolError(
code="INTERNAL_ERROR",
message=f"Parse error: {e}",
details={"lang": lang},
)
def _extract_symbols(self, ast: dict, lang: str) -> list[dict]:
"""Extract symbols from AST."""
symbols = []
# Symbol node types by language
symbol_types = {
"python": ["function_definition", "class_definition", "assignment"],
"javascript": ["function_declaration", "class_declaration", "variable_declaration", "lexical_declaration"],
"typescript": ["function_declaration", "class_declaration", "interface_declaration", "type_alias_declaration"],
"go": ["function_declaration", "method_declaration", "type_declaration"],
"rust": ["function_item", "struct_item", "enum_item", "impl_item", "trait_item"],
}
types_to_find = symbol_types.get(lang, ["function_definition", "class_definition"])
def walk(node: dict, parent_name: str = ""):
node_type = node.get("type", "")
if node_type in types_to_find:
# Try to extract name from first identifier child
name = None
for child in node.get("children", []):
if child.get("type") == "identifier":
name = child.get("text", "")
break
elif child.get("type") == "name":
name = child.get("text", "")
break
if name:
symbols.append({
"name": name,
"kind": node_type,
"range": {
"start": node["start"],
"end": node["end"],
},
"parent": parent_name or None,
})
parent_name = name
for child in node.get("children", []):
walk(child, parent_name)
if "root" in ast:
walk(ast["root"])
return symbols
def _generate_patch(self, original: str, modified: str, path: str = "file") -> str:
"""Generate unified diff patch."""
original_lines = original.splitlines(keepends=True)
modified_lines = modified.splitlines(keepends=True)
diff = difflib.unified_diff(
original_lines,
modified_lines,
fromfile=f"a/{path}",
tofile=f"b/{path}",
)
return "".join(diff)
def _apply_rename(self, text: str, old_name: str, new_name: str) -> tuple[str, int]:
"""Apply simple rename transformation. Returns (new_text, count)."""
import re
# Word-boundary rename to avoid partial matches
pattern = rf"\b{re.escape(old_name)}\b"
new_text, count = re.subn(pattern, new_name, text)
return new_text, count
def _apply_codemod(self, text: str, match: str, replace: str) -> tuple[str, int]:
"""Apply pattern-based codemod. Returns (new_text, count)."""
import re
try:
new_text, count = re.subn(match, replace, text)
return new_text, count
except re.error as e:
raise InvalidParamsError(f"Invalid regex pattern: {e}", param="match_pattern")
def _register_code_actions(self):
"""Register all code actions."""
@self.action("parse", "Parse source code to AST")
async def parse(
ctx: MCPContext,
path: str | None = None,
text: str | None = None,
lang: str | None = None,
) -> dict:
"""Parse source to AST using tree-sitter.
Args:
path: File path to parse
text: Raw text to parse (alternative to path)
lang: Language (auto-detected if not specified)
Returns:
AST structure with lang and errors
Effect: PURE
Cache: hash(text)
"""
if path and not text:
full_path = Path(path) if Path(path).is_absolute() else Path(self.cwd) / path
if not full_path.exists():
raise NotFoundError(f"File not found: {path}", uri=str(full_path))
text = full_path.read_text()
if not text:
raise InvalidParamsError("Either path or text required", param="text")
detected_lang = lang or self._detect_lang(path, text)
ast = self._parse_with_tree_sitter(text, detected_lang)
return {
"ast": ast,
"lang": detected_lang,
"hash": content_hash(text),
"source_map": {"path": path} if path else None,
}
@self.action("serialize", "Convert AST back to text")
async def serialize(
ctx: MCPContext,
ast: dict,
lang: str | None = None,
) -> dict:
"""Serialize AST back to text.
Note: This is a simplified implementation that extracts
leaf node text. Full round-trip requires CST preservation.
Effect: PURE
"""
def extract_text(node: dict) -> str:
if "text" in node:
return node["text"]
children_text = []
for child in node.get("children", []):
children_text.append(extract_text(child))
return " ".join(children_text)
root = ast.get("root") or ast
text = extract_text(root)
return {
"text": text,
"lang": lang or ast.get("lang", "unknown"),
}
@self.action("symbols", "List symbols in file")
async def symbols(
ctx: MCPContext,
path: str | None = None,
text: str | None = None,
ast: dict | None = None,
lang: str | None = None,
) -> dict:
"""Extract symbols from source code.
Args:
path: File path
text: Raw text (alternative)
ast: Pre-parsed AST (alternative)
lang: Language hint
Returns:
List of symbols with name, kind, range
Effect: PURE
"""
if ast is None:
if path and not text:
full_path = Path(path) if Path(path).is_absolute() else Path(self.cwd) / path
if not full_path.exists():
raise NotFoundError(f"File not found: {path}")
text = full_path.read_text()
if not text:
raise InvalidParamsError("path, text, or ast required")
detected_lang = lang or self._detect_lang(path, text)
parsed = self._parse_with_tree_sitter(text, detected_lang)
ast = parsed
lang = detected_lang
symbols_list = self._extract_symbols(ast, lang or ast.get("lang", "unknown"))
return {
"symbols": symbols_list,
"count": len(symbols_list),
"path": path,
}
@self.action("definition", "Go to symbol definition")
async def definition(
ctx: MCPContext,
path: str,
line: int,
col: int,
) -> dict:
"""Find definition of symbol at position.
Uses LSP when available, falls back to AST search.
Effect: PURE
"""
# TODO: Integrate with LSP (gopls, pyright, etc.)
# For now, return a stub indicating LSP integration needed
return {
"path": path,
"position": {"line": line, "col": col},
"definition": None,
"note": "LSP integration required for full definition lookup",
}
@self.action("references", "Find all references to symbol")
async def references(
ctx: MCPContext,
path: str,
line: int,
col: int,
scope: str = "repo",
) -> dict:
"""Find all references to symbol at position.
Uses LSP when available, falls back to text search.
Effect: PURE
Scope: file | package | repo
"""
# TODO: Integrate with LSP
return {
"path": path,
"position": {"line": line, "col": col},
"references": [],
"scope": scope,
"note": "LSP integration required for full reference lookup",
}
@self.action("transform", "Generate patch from transformation spec")
async def transform(
ctx: MCPContext,
path: str | None = None,
text: str | None = None,
kind: str = "rename",
old_name: str | None = None,
new_name: str | None = None,
match_pattern: str | None = None,
replace_template: str | None = None,
scope: str = "file",
) -> dict:
"""Apply transformation and produce Patch (PURE - no side effects).
This is the key "pure middle layer" operator:
- Input: source + transform spec
- Output: Patch as value (not applied)
Kinds:
rename: Rename symbol (old_name → new_name)
codemod: Pattern-based replacement
Effect: PURE
"""
# Load text if path provided
if path and not text:
full_path = Path(path) if Path(path).is_absolute() else Path(self.cwd) / path
if not full_path.exists():
raise NotFoundError(f"File not found: {path}")
text = full_path.read_text()
if not text:
raise InvalidParamsError("path or text required")
original_hash = content_hash(text)
original = text
changes_count = 0
if kind == "rename":
if not old_name or not new_name:
raise InvalidParamsError("rename requires old_name and new_name")
text, changes_count = self._apply_rename(text, old_name, new_name)
elif kind == "codemod":
if not match_pattern or replace_template is None:
raise InvalidParamsError("codemod requires match_pattern and replace_template")
text, changes_count = self._apply_codemod(text, match_pattern, replace_template)
else:
raise InvalidParamsError(
f"Unknown transform kind: {kind}",
param="kind",
expected="rename | codemod",
)
# Generate patch
patch = self._generate_patch(original, text, path or "input")
return {
"patch": patch,
"changes_count": changes_count,
"base_hash": original_hash,
"new_hash": content_hash(text) if changes_count > 0 else original_hash,
"kind": kind,
"report": {
"files_affected": 1 if changes_count > 0 else 0,
"lines_changed": patch.count("\n") if patch else 0,
},
}
@self.action("summarize", "Compress diff/log/report to summary")
async def summarize(
ctx: MCPContext,
diff: str | None = None,
log: list[dict] | None = None,
report: dict | None = None,
text: str | None = None,
max_length: int = 500,
) -> dict:
"""Summarize code artifacts for review.
Takes one of: diff, log, report, or text
Produces: summary, risks, next_actions
Effect: PURE
"""
summary_parts = []
risks = []
next_actions = []
if diff:
# Analyze diff
lines = diff.split("\n")
adds = sum(1 for l in lines if l.startswith("+") and not l.startswith("+++"))
dels = sum(1 for l in lines if l.startswith("-") and not l.startswith("---"))
files = [l[6:] for l in lines if l.startswith("+++ b/")]
summary_parts.append(f"Patch: +{adds}/-{dels} lines across {len(files)} file(s)")
if adds + dels > 100:
risks.append("Large change - review carefully")
if any("test" in f.lower() for f in files):
next_actions.append("Run tests to verify")
else:
risks.append("No test files modified")
next_actions.append("Consider adding tests")
if log:
summary_parts.append(f"History: {len(log)} commit(s)")
if log:
latest = log[0] if isinstance(log[0], dict) else {"message": str(log[0])}
summary_parts.append(f"Latest: {latest.get('message', 'N/A')[:50]}")
if report:
if "pass" in report:
status = "PASS" if report["pass"] else "FAIL"
summary_parts.append(f"Status: {status}")
if not report["pass"]:
risks.append("Tests failing")
next_actions.append("Fix failing tests before merge")
if text:
# Simple text summary
word_count = len(text.split())
summary_parts.append(f"Text: {word_count} words")
if word_count > 1000:
summary_parts.append("(truncated for summary)")
return {
"summary": " | ".join(summary_parts) if summary_parts else "No content to summarize",
"risks": risks,
"next_actions": next_actions,
}
def register(self, mcp_server: FastMCP) -> None:
"""Register as 'code' 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)
# Singleton instance
code_tool = CodeTool
+76
View File
@@ -0,0 +1,76 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "hanzo-tools-code"
version = "0.1.0"
description = "Code semantic tools for Hanzo AI (HIP-0300)"
readme = "README.md"
license = "MIT"
requires-python = ">=3.10"
authors = [
{ name = "Hanzo AI Team", email = "[email protected]" },
]
keywords = [
"hanzo",
"mcp",
"tools",
"code",
"ast",
"lsp",
"tree-sitter",
]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
dependencies = [
"hanzo-tools-core>=0.1.0",
"mcp>=1.0.0",
]
[project.optional-dependencies]
tree-sitter = [
"tree-sitter>=0.21.0",
"tree-sitter-languages>=1.10.0",
]
lsp = [
"pygls>=1.0.0",
]
full = [
"hanzo-tools-code[tree-sitter,lsp]",
]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"ruff>=0.1.0",
]
[project.entry-points."hanzo.tools"]
code = "hanzo_tools.code:TOOLS"
[project.urls]
Homepage = "https://github.com/hanzoai/python-sdk"
Documentation = "https://docs.hanzo.ai"
Repository = "https://github.com/hanzoai/python-sdk"
[tool.hatch.build.targets.wheel]
packages = ["hanzo_tools"]
[tool.ruff]
line-length = 100
target-version = "py310"
[tool.ruff.lint]
select = ["E", "F", "I", "UP"]
ignore = ["E501"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
@@ -1,18 +1,21 @@
"""Computer control tools for Hanzo AI.
"""UI control tools for Hanzo AI (HIP-0300).
Tools:
- computer: Unified computer control with native API acceleration
- ui: Unified interface control (HIP-0300 compliant)
- Mouse: click, double_click, right_click, move, drag, scroll
- Touch: tap, swipe, pinch (mobile emulation)
- Keyboard: type, write, press, key_down, key_up, hotkey
- Screen capture: screenshot, screenshot_region, capture
- Screen recording: session, record, stop, analyze
- Window: focus_window, list_windows, get_active_window
- Screen info: get_screens, screen_size
Cross-platform support:
- macOS: Quartz/CoreGraphics (10-50x faster than pyautogui)
- Linux: xdotool/scrot for X11
- Windows: win32api via ctypes
- Fallback: pyautogui when native unavailable
- screen: Screen recording, capture, and Claude-optimized processing
- capture: Single screenshot
- record: Background recording
- session: ONE-SHOT record → analyze → compress → return for Claude
- Claude limits: 100 frames max, 32MB payload, 2000px max dimension
Install:
pip install hanzo-tools-computer
@@ -22,8 +25,8 @@ Usage:
# Register with MCP server
register_tools(mcp_server, permission_manager)
# Or access individual tools
from hanzo_tools.computer import ComputerTool, ScreenTool
# Or access individual tool
from hanzo_tools.computer import UiTool
Screen Limits (configurable via env vars):
HANZO_SCREEN_DURATION=30 # Default session duration (seconds)
@@ -34,20 +37,29 @@ Screen Limits (configurable via env vars):
from hanzo_tools.core import BaseTool, ToolRegistry, PermissionManager
# HIP-0300: Single unified 'ui' tool
from .ui_tool import UiTool, ui_tool
# Backward compatibility (deprecated)
from .computer_tool import ComputerTool
from .screen_tool import ScreenTool, ScreenConfig, screen_tool
# Internal utilities (used by screen_tool)
from .media_tool import MediaLimits, MediaResult, ActivitySegment, media_tool
# Export list for tool discovery - only 2 tools now
TOOLS = [ComputerTool, ScreenTool]
# Export list for tool discovery - single ui tool (HIP-0300)
TOOLS = [UiTool]
__all__ = [
# HIP-0300 unified tool
"UiTool",
"ui_tool",
# Backward compatibility (deprecated)
"ComputerTool",
"ScreenTool",
"ScreenConfig",
"screen_tool",
# Internal utilities
"MediaLimits",
"MediaResult",
"ActivitySegment",
@@ -631,10 +631,17 @@ Action = Literal[
# Mouse
"click", "double_click", "right_click", "middle_click",
"move", "move_relative", "drag", "drag_relative", "scroll",
# Touch (mobile emulation)
"tap", "swipe", "pinch",
# Keyboard
"type", "write", "press", "key_down", "key_up", "hotkey",
# Screen
"screenshot", "screenshot_region",
# Screen capture
"screenshot", "screenshot_region", "capture",
# Screen recording (consolidated from ScreenTool)
"session", # ONE-SHOT: record → analyze → compress → return for Claude
"record", # Start background recording
"stop", # Stop recording and get compressed frames
"analyze", # Analyze existing video file
# Image location
"locate", "locate_all", "locate_center",
"wait_for_image", "wait_while_image",
@@ -651,7 +658,7 @@ Action = Literal[
# Batch
"batch",
# Info
"info", "position",
"info", "position", "status",
]
@@ -0,0 +1,68 @@
"""Unified UI tool for HIP-0300 architecture.
This module provides a single unified 'ui' tool that consolidates:
- Computer control (click, type, screenshot, etc.)
- Screen recording (session, record, stop, analyze)
Following Unix philosophy: one tool for the Interface axis.
"""
from typing import Any, ClassVar
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import PermissionManager
from .computer_tool import ComputerTool
class UiTool(ComputerTool):
"""Unified UI tool (HIP-0300).
Consolidates computer + screen tools into single 'ui' tool.
Inherits all functionality from ComputerTool.
Actions:
- Mouse: click, double_click, right_click, middle_click, move, drag, scroll
- Touch: tap, swipe, pinch
- Keyboard: type, write, press, key_down, key_up, hotkey
- Screen capture: screenshot, screenshot_region, capture
- Screen recording: session, record, stop, analyze
- Image location: locate, locate_all, locate_center, wait_for_image
- Window: get_active_window, list_windows, focus_window
- Screen info: get_screens, screen_size, current_screen
- Region: define_region, region_screenshot, region_locate
- Batch: batch
"""
# Override name to 'ui' for HIP-0300
name: ClassVar[str] = "ui"
@property
def description(self) -> str:
return """Unified interface control tool (HIP-0300).
Actions:
- click, double_click, right_click, middle_click, move, drag, scroll
- tap, swipe, pinch (touch/mobile)
- type, write, press, key_down, key_up, hotkey (keyboard)
- screenshot, screenshot_region, capture
- session, record, stop, analyze (screen recording)
- focus_window, list_windows, get_active_window
- get_screens, screen_size
Cross-platform: macOS (Quartz), Linux (xdotool), Windows (win32api).
"""
def register(self, mcp_server: FastMCP) -> None:
"""Register as 'ui' tool with MCP server."""
# Delegate to parent registration but with 'ui' name
super().register(mcp_server)
# Backward compatibility aliases
ComputerToolAlias = UiTool
# For imports: from hanzo_tools.computer import ui_tool
ui_tool = UiTool
@@ -13,7 +13,7 @@ warnings.warn(
# Re-export everything from hanzo-tools
from hanzo_tools.core.base import (
BaseTool,
BaseTool as BaseToolABC, # Low-level ABC
ToolRegistry,
FileSystemTool,
)
@@ -21,12 +21,45 @@ from hanzo_tools.core.types import MCPResourceDocument
from hanzo_tools.core.context import ToolContext, create_tool_context
from hanzo_tools.core.decorators import auto_timeout
from hanzo_tools.core.permissions import PermissionManager
from hanzo_tools.core.unified import (
BaseTool, # HIP-0300 unified tool - use this
ActionHandler,
ToolError,
ConflictError,
NotFoundError,
InvalidParamsError,
Paging,
Range,
content_hash,
file_uri,
ErrorCode,
)
from hanzo_tools.core.id_tool import IdTool, id_tool
__all__ = [
# HIP-0300 Base class - use this for new tools
"BaseTool",
# Low-level ABC (for FileSystemTool etc)
"BaseToolABC",
"FileSystemTool",
"ToolRegistry",
# HIP-0300 helpers
"ActionHandler",
"ToolError",
"ConflictError",
"NotFoundError",
"InvalidParamsError",
"Paging",
"Range",
"ErrorCode",
"content_hash",
"file_uri",
# Identity tool
"IdTool",
"id_tool",
# Types
"MCPResourceDocument",
# Context
"PermissionManager",
"ToolContext",
"create_tool_context",
@@ -0,0 +1,295 @@
"""Identity tool for HIP-0300 architecture.
This module provides the 'id' tool for identity and addressing primitives:
- hash: Content → Digest
- uri: Path → file:// URI
- ref: Location → Reference
- verify: (Content, Digest) → Bool
Identity is fundamental to composability:
- Cache keys
- base_hash preconditions
- Deduplication
- Content-addressable storage
Effect lattice position: PURE
All operations are deterministic and side-effect free.
"""
import os
import json
import hashlib
import aiofiles
from typing import Any, ClassVar
from pathlib import Path
from urllib.parse import quote
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core.unified import BaseTool
from hanzo_tools.core.unified import (
ToolError,
NotFoundError,
InvalidParamsError,
)
# Supported hash algorithms
HASH_ALGORITHMS = ["sha256", "sha512", "sha1", "md5", "blake2b", "blake2s"]
class IdTool(BaseTool):
"""Identity and addressing tool (HIP-0300).
Handles identity primitives:
- hash: Content → Digest
- uri: Path → file:// URI
- ref: Location → Reference
- verify: Check content integrity
All operations are PURE (no side effects).
"""
name: ClassVar[str] = "id"
VERSION: ClassVar[str] = "0.1.0"
def __init__(self, cwd: str | None = None):
super().__init__()
self.cwd = cwd or os.getcwd()
self._register_id_actions()
@property
def description(self) -> str:
return """Identity and addressing tool (HIP-0300).
Actions:
- hash: Compute content hash (Content → Digest)
- uri: Convert path to file:// URI
- ref: Create location reference (path + optional line/col)
- verify: Check content matches expected hash
Identity is fundamental to composability:
- Cache keys for memoization
- base_hash preconditions for safe edits
- Content-addressable storage
- Deduplication
All operations are PURE.
"""
def _compute_hash(self, content: bytes, algo: str = "sha256") -> str:
"""Compute hash of content."""
if algo not in HASH_ALGORITHMS:
raise InvalidParamsError(
f"Unknown algorithm: {algo}",
param="algo",
expected=", ".join(HASH_ALGORITHMS),
)
h = hashlib.new(algo)
h.update(content)
return f"{algo}:{h.hexdigest()}"
def _path_to_uri(self, path: str) -> str:
"""Convert path to file:// URI."""
abs_path = Path(path).resolve()
# Properly encode the path for URI
encoded = quote(str(abs_path), safe="/:")
return f"file://{encoded}"
def _register_id_actions(self):
"""Register all identity actions."""
@self.action("hash", "Compute content hash")
async def hash_content(
ctx: MCPContext,
path: str | None = None,
text: str | None = None,
algo: str = "sha256",
) -> dict:
"""Compute hash of file or text content.
Args:
path: File path to hash
text: Inline text to hash (alternative to path)
algo: Hash algorithm (sha256, sha512, sha1, md5, blake2b, blake2s)
Returns:
{digest, algo, size}
Effect: PURE
"""
if path:
full_path = Path(path) if Path(path).is_absolute() else Path(self.cwd) / path
if not full_path.exists():
raise NotFoundError(f"File not found: {path}", uri=str(full_path))
async with aiofiles.open(full_path, "rb") as f:
content = await f.read()
elif text is not None:
content = text.encode("utf-8")
else:
raise InvalidParamsError("Either path or text required")
digest = self._compute_hash(content, algo)
return {
"digest": digest,
"algo": algo,
"size": len(content),
}
@self.action("uri", "Convert path to file:// URI")
async def to_uri(
ctx: MCPContext,
path: str,
) -> dict:
"""Convert filesystem path to file:// URI.
Args:
path: Filesystem path
Returns:
{uri, path}
Effect: PURE
"""
full_path = Path(path) if Path(path).is_absolute() else Path(self.cwd) / path
uri = self._path_to_uri(str(full_path))
return {
"uri": uri,
"path": str(full_path),
"exists": full_path.exists(),
}
@self.action("ref", "Create location reference")
async def create_ref(
ctx: MCPContext,
path: str,
line: int | None = None,
col: int | None = None,
end_line: int | None = None,
end_col: int | None = None,
) -> dict:
"""Create a location reference (URI + optional range).
Args:
path: File path
line: Start line (0-based)
col: Start column (0-based)
end_line: End line (optional)
end_col: End column (optional)
Returns:
{uri, range?, hash?}
Effect: PURE
"""
full_path = Path(path) if Path(path).is_absolute() else Path(self.cwd) / path
uri = self._path_to_uri(str(full_path))
result = {
"uri": uri,
"path": str(full_path),
}
# Add range if line specified
if line is not None:
result["range"] = {
"start": {"line": line, "col": col or 0},
}
if end_line is not None:
result["range"]["end"] = {
"line": end_line,
"col": end_col or 0,
}
# Add hash if file exists
if full_path.exists():
async with aiofiles.open(full_path, "rb") as f:
content = await f.read()
result["hash"] = self._compute_hash(content)
return result
@self.action("verify", "Verify content matches expected hash")
async def verify_hash(
ctx: MCPContext,
path: str | None = None,
text: str | None = None,
expected: str = "",
) -> dict:
"""Verify content matches expected hash.
Args:
path: File path to verify
text: Inline text to verify (alternative)
expected: Expected hash (format: "algo:hexdigest")
Returns:
{match, actual, expected}
Effect: PURE
"""
if not expected:
raise InvalidParamsError("expected hash required")
# Parse expected hash format
if ":" in expected:
algo, _ = expected.split(":", 1)
else:
algo = "sha256"
expected = f"sha256:{expected}"
# Get content
if path:
full_path = Path(path) if Path(path).is_absolute() else Path(self.cwd) / path
if not full_path.exists():
raise NotFoundError(f"File not found: {path}")
async with aiofiles.open(full_path, "rb") as f:
content = await f.read()
elif text is not None:
content = text.encode("utf-8")
else:
raise InvalidParamsError("Either path or text required")
actual = self._compute_hash(content, algo)
return {
"match": actual == expected,
"actual": actual,
"expected": expected,
}
@self.action("algorithms", "List supported hash algorithms")
async def list_algorithms(ctx: MCPContext) -> dict:
"""List supported hash algorithms.
Returns:
{algorithms, default}
Effect: PURE
"""
return {
"algorithms": HASH_ALGORITHMS,
"default": "sha256",
}
def register(self, mcp_server: FastMCP) -> None:
"""Register as 'id' 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)
# Singleton
id_tool = IdTool
@@ -0,0 +1,423 @@
"""Base tool class for HIP-0300 architecture.
Provides the foundation for orthogonal, composable tools following Unix philosophy:
- Permissive input, strict output
- Action routing with alias resolution
- Unified response envelope with ok/data/error/meta
- Built-in help and schema actions
- Typed error codes with Unix exit code semantics
- Paging/cursor support for large results
- Composable via stable identifiers (uri, hash, range, ref)
Design principles:
- Input parsing = permissive (accept many spellings/forms)
- Output + semantics = strict (one canonical shape)
- Aliases never appear in output; only canonical action names
Reference: HIP-0300 Unified MCP Tools Architecture
"""
import inspect
import json
import hashlib
from abc import abstractmethod
from dataclasses import dataclass, field
from typing import Any, Callable, ClassVar, Literal, TypeVar, get_type_hints
from collections.abc import Awaitable
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from .base import BaseTool as _BaseToolABC
# Error codes for structured error handling
ErrorCode = Literal[
"UNKNOWN_ACTION", # Action not found
"INVALID_PARAMS", # Invalid parameters
"NOT_FOUND", # Resource not found
"CONFLICT", # Precondition failed (e.g., base_hash mismatch)
"PERMISSION_DENIED", # Access denied
"TIMEOUT", # Operation timed out
"INTERNAL_ERROR", # Unexpected error
]
@dataclass
class Paging:
"""Pagination info for large results."""
cursor: str | None = None
more: bool = False
total: int | None = None
@dataclass
class ToolError(Exception):
"""Structured error for tool operations."""
code: ErrorCode
message: str
details: dict[str, Any] = field(default_factory=dict)
class ConflictError(ToolError):
"""Precondition failed (e.g., base_hash mismatch)."""
def __init__(self, message: str, expected: str | None = None, actual: str | None = None):
details = {}
if expected:
details["expected"] = expected
if actual:
details["actual"] = actual
super().__init__(code="CONFLICT", message=message, details=details)
class NotFoundError(ToolError):
"""Resource not found."""
def __init__(self, message: str, uri: str | None = None):
details = {"uri": uri} if uri else {}
super().__init__(code="NOT_FOUND", message=message, details=details)
class InvalidParamsError(ToolError):
"""Invalid parameters."""
def __init__(self, message: str, param: str | None = None, expected: str | None = None):
details = {}
if param:
details["param"] = param
if expected:
details["expected"] = expected
super().__init__(code="INVALID_PARAMS", message=message, details=details)
@dataclass
class ActionHandler:
"""Metadata for a registered action handler."""
name: str
handler: Callable[..., Awaitable[Any]]
description: str
schema: dict[str, Any] | None = None
examples: list[str] = field(default_factory=list)
class BaseTool(_BaseToolABC):
"""Base class for HIP-0300 unified tools with action routing.
Design principles:
- Permissive input, strict output
- Action routing with alias resolution
- Unified response envelope: {ok, data, error, meta}
- Aliases never appear in output; only canonical action names
Usage:
class FsTool(BaseTool):
name = "fs"
description = "Filesystem operations"
def __init__(self):
super().__init__()
self._register_actions()
def _register_actions(self):
@self.action("read", "Read file contents")
async def read(ctx, uri: str, range: dict | None = None) -> dict:
content = await read_file(uri)
return {"text": content, "hash": hash_content(content)}
@self.action("apply_patch", "Edit file with precondition")
async def apply_patch(ctx, uri: str, patch: str, base_hash: str) -> dict:
if get_hash(uri) != base_hash:
raise ConflictError("base_hash mismatch", expected=base_hash)
# Apply patch...
return {"uri": uri, "hash": new_hash}
"""
# Subclasses must define these
name: ClassVar[str]
# Version for meta envelope
VERSION: ClassVar[str] = "0.12.0"
def __init__(self):
self._handlers: dict[str, ActionHandler] = {}
self._register_builtin_actions()
def _register_builtin_actions(self):
"""Register built-in help and schema actions."""
@self.action("help", "List available actions with descriptions")
async def help_action(ctx: MCPContext) -> dict:
actions = {}
for name, handler in self._handlers.items():
if name not in ("help", "schema", "status"):
actions[name] = {
"description": handler.description,
"examples": handler.examples,
}
return {"actions": actions, "tool": self.name}
@self.action("schema", "Get JSON Schema for action parameters")
async def schema_action(ctx: MCPContext, action_name: str | None = None) -> dict:
if action_name:
handler = self._handlers.get(action_name)
if not handler:
raise ToolError(
code="UNKNOWN_ACTION",
message=f"Unknown action: {action_name}",
details={"available": list(self._handlers.keys())}
)
return {"action": action_name, "schema": handler.schema or {}}
schemas = {}
for name, handler in self._handlers.items():
if name not in ("help", "schema", "status"):
schemas[name] = handler.schema or {}
return {"schemas": schemas}
@self.action("status", "Get tool status and version")
async def status_action(ctx: MCPContext) -> dict:
return {
"tool": self.name,
"version": self.VERSION,
"enabled": True,
"actions": list(self._handlers.keys()),
}
def action(
self,
name: str,
description: str = "",
schema: dict[str, Any] | None = None,
examples: list[str] | None = None,
) -> Callable:
"""Decorator to register an action handler.
Args:
name: Action name (e.g., "read", "apply_patch")
description: Human-readable description
schema: Optional JSON Schema for parameters
examples: Optional list of example usages
Returns:
Decorator function
"""
def decorator(fn: Callable[..., Awaitable[Any]]) -> Callable[..., Awaitable[Any]]:
# Auto-generate schema from type hints if not provided
auto_schema = schema
if auto_schema is None:
auto_schema = self._generate_schema(fn)
self._handlers[name] = ActionHandler(
name=name,
handler=fn,
description=description or fn.__doc__ or "",
schema=auto_schema,
examples=examples or [],
)
return fn
return decorator
def _generate_schema(self, fn: Callable) -> dict[str, Any]:
"""Generate JSON Schema from function signature and type hints."""
try:
hints = get_type_hints(fn)
except Exception:
hints = {}
sig = inspect.signature(fn)
properties = {}
required = []
for param_name, param in sig.parameters.items():
if param_name in ("self", "ctx"):
continue
param_schema: dict[str, Any] = {}
hint = hints.get(param_name)
if hint is str:
param_schema["type"] = "string"
elif hint is int:
param_schema["type"] = "integer"
elif hint is float:
param_schema["type"] = "number"
elif hint is bool:
param_schema["type"] = "boolean"
elif hint is dict or (hasattr(hint, "__origin__") and hint.__origin__ is dict):
param_schema["type"] = "object"
elif hint is list or (hasattr(hint, "__origin__") and hint.__origin__ is list):
param_schema["type"] = "array"
else:
param_schema["type"] = "string" # Default to string
properties[param_name] = param_schema
if param.default is inspect.Parameter.empty:
required.append(param_name)
return {
"type": "object",
"properties": properties,
"required": required,
}
def _envelope(
self,
data: Any,
action: str | None = None,
paging: Paging | None = None,
backend: str | None = None,
trace_id: str | None = None,
) -> dict[str, Any]:
"""Wrap result in unified response envelope."""
meta: dict[str, Any] = {
"tool": self.name,
"version": self.VERSION,
}
if action:
meta["action"] = action
if backend:
meta["backend"] = backend
if trace_id:
meta["trace_id"] = trace_id
if paging:
meta["paging"] = {
"cursor": paging.cursor,
"more": paging.more,
}
if paging.total is not None:
meta["paging"]["total"] = paging.total
else:
meta["paging"] = {"cursor": None, "more": False}
return {
"ok": True,
"data": data,
"error": None,
"meta": meta,
}
def _error(
self,
code: ErrorCode,
message: str,
**details: Any,
) -> dict[str, Any]:
"""Create error response envelope."""
error_dict: dict[str, Any] = {
"code": code,
"message": message,
}
if details:
error_dict.update(details)
return {
"ok": False,
"data": None,
"error": error_dict,
"meta": {"tool": self.name, "version": self.VERSION},
}
@property
@abstractmethod
def description(self) -> str:
"""Tool description for MCP registration."""
pass
async def call(self, ctx: MCPContext, action: str = "help", **kwargs: Any) -> dict[str, Any]:
"""Execute tool with action routing.
Args:
ctx: MCP context
action: Action to execute (default: "help")
**kwargs: Action parameters
Returns:
Unified response envelope
"""
if action not in self._handlers:
return self._error(
"UNKNOWN_ACTION",
f"Unknown action: {action}",
available=list(self._handlers.keys()),
)
handler = self._handlers[action]
try:
result = await handler.handler(ctx, **kwargs)
return self._envelope(result, action=action)
except ToolError as e:
return self._error(e.code, e.message, **e.details)
except Exception as e:
return self._error("INTERNAL_ERROR", str(e))
def register(self, mcp_server: FastMCP) -> None:
"""Register this tool with the MCP server.
Creates a single MCP tool with action parameter for routing.
"""
tool_name = self.name
tool_description = f"{self.description}\n\nActions: {', '.join(self._handlers.keys())}"
@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)
# Utility functions for composability
def content_hash(content: str | bytes, algorithm: str = "sha256") -> str:
"""Generate content hash for file identity."""
if isinstance(content, str):
content = content.encode("utf-8")
h = hashlib.new(algorithm)
h.update(content)
return f"{algorithm}:{h.hexdigest()}"
def file_uri(path: str) -> str:
"""Convert path to file:// URI."""
from pathlib import Path
abs_path = Path(path).resolve()
return f"file://{abs_path}"
@dataclass
class Range:
"""Text range for composability.
0-based line and column numbers.
"""
start_line: int
start_col: int = 0
end_line: int | None = None
end_col: int | None = None
def to_dict(self) -> dict[str, Any]:
"""Convert to dict format for serialization."""
result = {
"start": {"line": self.start_line, "col": self.start_col},
}
if self.end_line is not None:
result["end"] = {
"line": self.end_line,
"col": self.end_col or 0,
}
return result
@classmethod
def from_dict(cls, d: dict[str, Any]) -> "Range":
"""Create Range from dict."""
start = d.get("start", {})
end = d.get("end")
return cls(
start_line=start.get("line", 0),
start_col=start.get("col", 0),
end_line=end.get("line") if end else None,
end_col=end.get("col") if end else None,
)
+29 -15
View File
@@ -1,15 +1,18 @@
"""Filesystem tools for Hanzo AI.
"""Filesystem tools for Hanzo AI (HIP-0300).
Tools:
- read: Read file contents
- write: Write/create files
- edit: Edit files with find/replace
- multi_edit: Multiple edits in one operation
- tree: Directory tree view
- find: Find files by pattern
- search: Search file contents
- ast: AST-based code search
- rules: Read project rules/config
- fs: Unified filesystem tool (HIP-0300)
- read: Read file contents (returns hash)
- write: Create new files
- stat: File metadata
- list: Directory listing
- apply_patch: Edit with base_hash precondition
- search_text: Text search
- mkdir: Create directory
- rm: Remove (requires confirm=true)
IMPORTANT: apply_patch is the ONLY way to edit existing files.
It requires base_hash from a previous read to prevent stale edits.
Install:
pip install hanzo-tools-fs
@@ -20,10 +23,14 @@ Usage:
# Register with MCP server
register_tools(mcp_server, permission_manager)
# Or access individual tools
from hanzo_tools.fs import ReadTool, WriteTool
# Or access the unified tool
from hanzo_tools.fs import FsTool
"""
# HIP-0300: Unified fs tool
from hanzo_tools.fs.fs_tool import FsTool, fs_tool
# Legacy tools (backward compatibility)
from hanzo_tools.fs.ast import ASTTool
from hanzo_tools.fs.edit import EditTool
from hanzo_tools.fs.find import FindTool
@@ -46,8 +53,11 @@ READ_ONLY_TOOLS = [
ASTTool,
]
# Export list for tool discovery
TOOLS = [
# Export list for tool discovery - HIP-0300 unified tool
TOOLS = [FsTool]
# Legacy tools list (for backward compatibility)
LEGACY_TOOLS = [
ReadTool,
WriteTool,
EditTool,
@@ -58,7 +68,10 @@ TOOLS = [
]
__all__ = [
# Tool classes
# HIP-0300 unified tool
"FsTool",
"fs_tool",
# Legacy tool classes
"ReadTool",
"WriteTool",
"EditTool",
@@ -74,6 +87,7 @@ __all__ = [
"register_tools",
"get_read_only_filesystem_tools",
"TOOLS",
"LEGACY_TOOLS",
"READ_ONLY_TOOLS",
]
@@ -0,0 +1,549 @@
"""Unified filesystem tool for HIP-0300 architecture.
This module provides a single unified 'fs' tool that handles all filesystem operations:
- read: Read file contents (returns hash for composability)
- write: Create new files only
- stat: File metadata including hash
- list: Directory listing with depth/pattern
- apply_patch: Edit files with base_hash precondition (the ONLY mutation for existing files)
- search_text: Ripgrep-style text search
- diff: Content diff between hashes
Following Unix philosophy: one tool for the Bytes + Paths axis.
"""
import os
import json
import fnmatch
import hashlib
import aiofiles
from typing import Any, Optional, ClassVar
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 (
BaseTool,
PermissionManager,
ConflictError,
NotFoundError,
InvalidParamsError,
Paging,
content_hash,
file_uri,
)
class FsTool(BaseTool):
"""Unified filesystem tool (HIP-0300).
Handles all filesystem operations on a single axis:
- read: Read file contents
- write: Create new files
- stat: File metadata
- list: Directory listing
- apply_patch: Edit with precondition
- search_text: Text search
- mkdir: Create directory
- rm: Remove (guarded)
CRITICAL: apply_patch is the ONLY way to edit existing files.
It requires base_hash to prevent stale edits.
"""
name: ClassVar[str] = "fs"
VERSION: ClassVar[str] = "0.12.0"
def __init__(self, permission_manager: Optional[PermissionManager] = None):
super().__init__()
if permission_manager is None:
permission_manager = PermissionManager()
self.permission_manager = permission_manager
self._register_fs_actions()
@property
def description(self) -> str:
return """Unified filesystem tool (HIP-0300).
Actions:
- read: Read file contents (returns hash)
- write: Create new files only
- stat: File metadata including hash
- list: Directory listing
- apply_patch: Edit with base_hash precondition
- search_text: Text search
- mkdir: Create directory
- rm: Remove (requires confirm=true)
IMPORTANT: apply_patch is the ONLY way to edit existing files.
"""
def _validate_path(self, path: str) -> Path:
"""Validate and return Path object."""
if not path:
raise InvalidParamsError("Path is required", param="uri")
if not os.path.isabs(path):
raise InvalidParamsError("Path must be absolute", param="uri", expected="absolute path")
return Path(path)
def _check_permission(self, path: str) -> None:
"""Check if path is allowed."""
if not self.permission_manager.is_path_allowed(path):
raise InvalidParamsError(f"Access denied: {path}", param="uri")
def _compute_hash(self, content: str | bytes) -> str:
"""Compute content hash."""
if isinstance(content, str):
content = content.encode("utf-8")
h = hashlib.sha256(content)
return f"sha256:{h.hexdigest()}"
def _register_fs_actions(self):
"""Register all filesystem actions."""
@self.action("read", "Read file contents")
async def read(
ctx: MCPContext,
uri: str,
offset: int = 0,
limit: int = 2000,
encoding: str = "utf-8",
) -> dict:
"""Read file contents.
Returns text content and hash for composability.
"""
# Handle file:// URIs
path_str = uri.replace("file://", "") if uri.startswith("file://") else uri
path = self._validate_path(path_str)
self._check_permission(str(path))
if not path.exists():
raise NotFoundError(f"File not found: {path}", uri=uri)
if not path.is_file():
raise InvalidParamsError(f"Not a file: {path}", param="uri")
try:
async with aiofiles.open(path, "r", encoding=encoding, errors="replace") as f:
content = await f.read()
# Compute hash of full content
file_hash = self._compute_hash(content)
# Apply offset/limit by lines
lines = content.splitlines(keepends=True)
total_lines = len(lines)
selected = lines[offset:offset + limit]
# Format with line numbers
output_lines = []
for i, line in enumerate(selected, start=offset + 1):
line = line.rstrip("\n\r")
if len(line) > 2000:
line = line[:2000] + "..."
output_lines.append(f"{i:6}│{line}")
text = "\n".join(output_lines)
return {
"uri": file_uri(str(path)),
"text": text,
"hash": file_hash,
"total_lines": total_lines,
"offset": offset,
"limit": limit,
}
except UnicodeDecodeError as e:
raise InvalidParamsError(f"Encoding error: {e}", param="encoding")
@self.action("write", "Create new file")
async def write(
ctx: MCPContext,
uri: str,
content: str,
encoding: str = "utf-8",
) -> dict:
"""Create a new file. Fails if file already exists.
For editing existing files, use apply_patch instead.
"""
path_str = uri.replace("file://", "") if uri.startswith("file://") else uri
path = self._validate_path(path_str)
self._check_permission(str(path))
if path.exists():
raise ConflictError(
f"File already exists: {path}. Use apply_patch to edit.",
expected="non-existent",
actual="exists",
)
# Create parent directories if needed
path.parent.mkdir(parents=True, exist_ok=True)
try:
async with aiofiles.open(path, "w", encoding=encoding) as f:
await f.write(content)
file_hash = self._compute_hash(content)
return {
"uri": file_uri(str(path)),
"hash": file_hash,
"size": len(content.encode(encoding)),
}
except Exception as e:
raise InvalidParamsError(f"Write failed: {e}")
@self.action("stat", "Get file metadata")
async def stat(ctx: MCPContext, uri: str) -> dict:
"""Get file metadata including hash."""
path_str = uri.replace("file://", "") if uri.startswith("file://") else uri
path = self._validate_path(path_str)
self._check_permission(str(path))
if not path.exists():
raise NotFoundError(f"File not found: {path}", uri=uri)
stat_info = path.stat()
# Compute hash for files
file_hash = None
if path.is_file():
async with aiofiles.open(path, "rb") as f:
file_hash = self._compute_hash(await f.read())
return {
"uri": file_uri(str(path)),
"size": stat_info.st_size,
"hash": file_hash,
"mtime": datetime.fromtimestamp(stat_info.st_mtime).isoformat(),
"is_file": path.is_file(),
"is_dir": path.is_dir(),
}
@self.action("list", "List directory contents")
async def list_dir(
ctx: MCPContext,
uri: str,
depth: int = 1,
pattern: str | None = None,
cursor: str | None = None,
limit: int = 100,
) -> dict:
"""List directory contents with optional depth and pattern."""
path_str = uri.replace("file://", "") if uri.startswith("file://") else uri
path = self._validate_path(path_str)
self._check_permission(str(path))
if not path.exists():
raise NotFoundError(f"Directory not found: {path}", uri=uri)
if not path.is_dir():
raise InvalidParamsError(f"Not a directory: {path}", param="uri")
entries = []
start_index = int(cursor) if cursor else 0
count = 0
total = 0
def should_include(p: Path) -> bool:
if pattern:
return fnmatch.fnmatch(p.name, pattern)
return True
def walk(dir_path: Path, current_depth: int):
nonlocal count, total
if current_depth > depth:
return
try:
for entry in sorted(dir_path.iterdir()):
if not should_include(entry):
continue
total += 1
if total <= start_index:
continue
if count >= limit:
continue
rel_path = entry.relative_to(path)
entries.append({
"name": str(rel_path),
"uri": file_uri(str(entry)),
"is_dir": entry.is_dir(),
"size": entry.stat().st_size if entry.is_file() else None,
})
count += 1
if entry.is_dir() and current_depth < depth:
walk(entry, current_depth + 1)
except PermissionError:
pass
walk(path, 1)
has_more = total > start_index + count
next_cursor = str(start_index + count) if has_more else None
return {
"uri": file_uri(str(path)),
"entries": entries,
"paging": {
"cursor": next_cursor,
"more": has_more,
"total": total,
}
}
@self.action("apply_patch", "Edit file with precondition")
async def apply_patch(
ctx: MCPContext,
uri: str,
old_text: str,
new_text: str,
base_hash: str,
) -> dict:
"""Edit existing file with base_hash precondition.
This is the ONLY way to edit existing files.
The base_hash must match the current file hash to prevent stale edits.
Args:
uri: File path
old_text: Text to find and replace
new_text: Replacement text
base_hash: Expected hash from previous read (prevents race conditions)
Returns:
New file URI and hash
Raises:
ConflictError: If base_hash doesn't match current file
"""
path_str = uri.replace("file://", "") if uri.startswith("file://") else uri
path = self._validate_path(path_str)
self._check_permission(str(path))
if not path.exists():
raise NotFoundError(f"File not found: {path}", uri=uri)
if not path.is_file():
raise InvalidParamsError(f"Not a file: {path}", param="uri")
# Read current content and verify hash
async with aiofiles.open(path, "r", encoding="utf-8") as f:
content = await f.read()
current_hash = self._compute_hash(content)
if current_hash != base_hash:
raise ConflictError(
"File has changed since last read (base_hash mismatch)",
expected=base_hash,
actual=current_hash,
)
# Check old_text exists and is unique
count = content.count(old_text)
if count == 0:
raise NotFoundError(
f"old_text not found in file",
uri=uri,
)
if count > 1:
raise InvalidParamsError(
f"old_text found {count} times. Make it more specific.",
param="old_text",
expected="unique match",
)
# Apply the patch
new_content = content.replace(old_text, new_text, 1)
async with aiofiles.open(path, "w", encoding="utf-8") as f:
await f.write(new_content)
new_hash = self._compute_hash(new_content)
return {
"uri": file_uri(str(path)),
"hash": new_hash,
"previous_hash": current_hash,
}
@self.action("search_text", "Search file contents")
async def search_text(
ctx: MCPContext,
pattern: str,
uri: str | None = None,
glob: str | None = None,
limit: int = 50,
cursor: str | None = None,
) -> dict:
"""Search for text pattern in files.
Uses ripgrep-style matching with regex support.
"""
import re
import subprocess
# Default to current directory
search_path = "."
if uri:
path_str = uri.replace("file://", "") if uri.startswith("file://") else uri
path = self._validate_path(path_str)
self._check_permission(str(path))
search_path = str(path)
matches = []
start_index = int(cursor) if cursor else 0
# Try ripgrep first (fastest)
try:
cmd = ["rg", "--json", "-n", "--max-count", str(limit * 2)]
if glob:
cmd.extend(["--glob", glob])
cmd.extend([pattern, search_path])
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=30,
)
for line in result.stdout.splitlines():
if len(matches) >= limit:
break
try:
data = json.loads(line)
if data.get("type") == "match":
match_data = data["data"]
matches.append({
"uri": file_uri(match_data["path"]["text"]),
"line": match_data["line_number"],
"text": match_data["lines"]["text"].strip(),
})
except json.JSONDecodeError:
continue
except (subprocess.TimeoutExpired, FileNotFoundError):
# Fallback to Python regex search
try:
regex = re.compile(pattern)
except re.error as e:
raise InvalidParamsError(f"Invalid regex: {e}", param="pattern")
async def search_file(file_path: Path):
try:
async with aiofiles.open(file_path, "r", encoding="utf-8", errors="replace") as f:
line_num = 0
async for line in f:
line_num += 1
if regex.search(line):
matches.append({
"uri": file_uri(str(file_path)),
"line": line_num,
"text": line.strip()[:200],
})
if len(matches) >= limit:
return
except (PermissionError, IsADirectoryError):
pass
search_dir = Path(search_path)
if search_dir.is_file():
await search_file(search_dir)
else:
for file_path in search_dir.rglob(glob or "*"):
if file_path.is_file():
await search_file(file_path)
if len(matches) >= limit:
break
has_more = len(matches) >= limit
next_cursor = str(start_index + len(matches)) if has_more else None
return {
"pattern": pattern,
"matches": matches,
"paging": {
"cursor": next_cursor,
"more": has_more,
}
}
@self.action("mkdir", "Create directory")
async def mkdir(ctx: MCPContext, uri: str) -> dict:
"""Create directory and parent directories."""
path_str = uri.replace("file://", "") if uri.startswith("file://") else uri
path = self._validate_path(path_str)
self._check_permission(str(path))
if path.exists():
if path.is_dir():
return {"uri": file_uri(str(path)), "created": False}
raise ConflictError(f"Path exists and is not a directory: {path}")
path.mkdir(parents=True, exist_ok=True)
return {"uri": file_uri(str(path)), "created": True}
@self.action("rm", "Remove file or directory")
async def rm(ctx: MCPContext, uri: str, confirm: bool = False) -> dict:
"""Remove file or directory.
REQUIRES confirm=true as safety measure.
"""
if not confirm:
raise InvalidParamsError(
"rm requires confirm=true for safety",
param="confirm",
expected="true",
)
path_str = uri.replace("file://", "") if uri.startswith("file://") else uri
path = self._validate_path(path_str)
self._check_permission(str(path))
if not path.exists():
raise NotFoundError(f"Path not found: {path}", uri=uri)
if path.is_file():
path.unlink()
else:
import shutil
shutil.rmtree(path)
return {"uri": file_uri(str(path)), "removed": True}
def register(self, mcp_server: FastMCP) -> None:
"""Register as 'fs' 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
fs_tool = FsTool
@@ -0,0 +1,3 @@
# Namespace package
from pkgutil import extend_path
__path__ = extend_path(__path__, __name__)
@@ -0,0 +1,56 @@
"""Network tools for Hanzo AI (HIP-0300).
Tools:
- net: Unified network tool (HIP-0300)
- search: Web search (Query → [URL, title, snippet])
- fetch: Retrieve URL content (URL → {text, mime})
- download: Save URL with assets (URL → Path)
- crawl: Recursive site mirror (URL, depth → [Path])
- head: Get headers only
Effect lattice position: NONDETERMINISTIC_EFFECT
All operations involve network I/O.
Install:
pip install hanzo-tools-net
pip install hanzo-tools-net[full] # With beautifulsoup4
Usage:
from hanzo_tools.net import register_tools, TOOLS
# Register with MCP server
register_tools(mcp_server)
# Or access the unified tool
from hanzo_tools.net import NetTool
"""
from hanzo_tools.core import BaseTool, ToolRegistry
from .net_tool import NetTool, net_tool
# Export list for tool discovery - HIP-0300 unified tool
TOOLS = [NetTool]
__all__ = [
"NetTool",
"net_tool",
"register_tools",
"TOOLS",
]
def register_tools(mcp_server, **kwargs) -> list[BaseTool]:
"""Register net tools with the MCP server.
Args:
mcp_server: The FastMCP server instance
**kwargs: Additional options (cwd, etc.)
Returns:
List of registered tool instances
"""
cwd = kwargs.get("cwd")
tool = NetTool(cwd=cwd)
ToolRegistry.register_tool(mcp_server, tool)
return [tool]
@@ -0,0 +1,469 @@
"""Network tool for HIP-0300 architecture.
This module provides the 'net' tool for network operations:
- search: Query → [URL, title, snippet]
- fetch: URL → {text, mime, status}
- download: URL → Path (with assets)
- crawl: URL + depth → [Path] (recursive mirror)
Effect lattice position: NONDETERMINISTIC_EFFECT
Network operations are inherently non-deterministic.
"""
import os
import json
import asyncio
import re
from typing import Any, ClassVar
from pathlib import Path
from urllib.parse import urlparse, urljoin
import mimetypes
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import (
BaseTool,
ToolError,
InvalidParamsError,
NotFoundError,
content_hash,
)
class NetTool(BaseTool):
"""Network operations tool (HIP-0300).
Handles all network operations:
- search: Web search query
- fetch: Single URL retrieval
- download: Save page with assets
- crawl: Recursive site mirror
Effect: NONDETERMINISTIC_EFFECT
"""
name: ClassVar[str] = "net"
VERSION: ClassVar[str] = "0.1.0"
def __init__(self, cwd: str | None = None):
super().__init__()
self.cwd = cwd or os.getcwd()
self._client = None
self._register_net_actions()
@property
def description(self) -> str:
return """Network operations tool (HIP-0300).
Actions:
- search: Web search (Query → [URL, title, snippet])
- fetch: Retrieve URL content (URL → {text, mime, status})
- download: Save page with assets (URL → Path)
- crawl: Recursive site mirror (URL, depth → [Path])
Effect: NONDETERMINISTIC_EFFECT (network I/O)
"""
async def _get_client(self):
"""Get or create HTTP client."""
if self._client is None:
try:
import httpx
self._client = httpx.AsyncClient(
follow_redirects=True,
timeout=30.0,
headers={
"User-Agent": "Mozilla/5.0 (compatible; HanzoBot/1.0)"
}
)
except ImportError:
raise ToolError(
code="INTERNAL_ERROR",
message="httpx not installed. Run: pip install httpx",
)
return self._client
def _extract_text(self, html: str) -> str:
"""Extract text from HTML."""
try:
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "lxml")
# Remove script and style elements
for script in soup(["script", "style"]):
script.decompose()
return soup.get_text(separator="\n", strip=True)
except ImportError:
# Fallback: simple regex
text = re.sub(r"<script[^>]*>.*?</script>", "", html, flags=re.DOTALL | re.IGNORECASE)
text = re.sub(r"<style[^>]*>.*?</style>", "", text, flags=re.DOTALL | re.IGNORECASE)
text = re.sub(r"<[^>]+>", " ", text)
text = re.sub(r"\s+", " ", text)
return text.strip()
def _extract_links(self, html: str, base_url: str) -> list[str]:
"""Extract links from HTML."""
try:
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "lxml")
links = []
for a in soup.find_all("a", href=True):
href = a["href"]
full_url = urljoin(base_url, href)
links.append(full_url)
return links
except ImportError:
# Fallback: regex
pattern = r'href=["\']([^"\']+)["\']'
matches = re.findall(pattern, html)
return [urljoin(base_url, m) for m in matches]
def _register_net_actions(self):
"""Register all network actions."""
@self.action("search", "Web search query")
async def search(
ctx: MCPContext,
query: str,
engine: str = "duckduckgo",
limit: int = 10,
) -> dict:
"""Perform web search.
Args:
query: Search query
engine: Search engine (duckduckgo, google)
limit: Max results
Returns:
{results: [{url, title, snippet}]}
Effect: NONDETERMINISTIC_EFFECT
"""
client = await self._get_client()
if engine == "duckduckgo":
# DuckDuckGo HTML search (no API key needed)
url = f"https://html.duckduckgo.com/html/?q={query}"
response = await client.get(url)
results = []
try:
from bs4 import BeautifulSoup
soup = BeautifulSoup(response.text, "lxml")
for result in soup.select(".result")[:limit]:
title_el = result.select_one(".result__title")
snippet_el = result.select_one(".result__snippet")
link_el = result.select_one(".result__url")
if title_el and link_el:
results.append({
"title": title_el.get_text(strip=True),
"url": link_el.get("href", ""),
"snippet": snippet_el.get_text(strip=True) if snippet_el else "",
})
except ImportError:
# Fallback without beautifulsoup
results = [{
"note": "Install beautifulsoup4 for better parsing",
"raw_length": len(response.text),
}]
else:
raise InvalidParamsError(
f"Unknown engine: {engine}",
param="engine",
expected="duckduckgo",
)
return {
"results": results,
"query": query,
"engine": engine,
"count": len(results),
}
@self.action("fetch", "Retrieve URL content")
async def fetch(
ctx: MCPContext,
url: str,
extract_text: bool = False,
headers: dict | None = None,
) -> dict:
"""Fetch content from URL.
Args:
url: URL to fetch
extract_text: Extract text from HTML
headers: Additional headers
Returns:
{text, mime, status, headers, hash}
Effect: NONDETERMINISTIC_EFFECT
"""
client = await self._get_client()
try:
response = await client.get(url, headers=headers)
except Exception as e:
raise ToolError(
code="INTERNAL_ERROR",
message=f"Network error: {e}",
details={"url": url},
)
content_type = response.headers.get("content-type", "")
mime = content_type.split(";")[0].strip()
# Determine if text or binary
is_text = mime.startswith("text/") or mime in [
"application/json",
"application/xml",
"application/javascript",
]
if is_text:
text = response.text
if extract_text and "html" in mime:
text = self._extract_text(text)
return {
"text": text,
"mime": mime,
"status": response.status_code,
"hash": content_hash(text),
"size": len(response.content),
"url": str(response.url),
}
else:
# Binary content
return {
"binary": True,
"mime": mime,
"status": response.status_code,
"hash": content_hash(response.content),
"size": len(response.content),
"url": str(response.url),
}
@self.action("download", "Save URL to file")
async def download(
ctx: MCPContext,
url: str,
dest: str | None = None,
assets: bool = False,
) -> dict:
"""Download URL to local file.
Args:
url: URL to download
dest: Destination path (auto-generated if not specified)
assets: Download page assets (images, css, js)
Returns:
{path, size, mime}
Effect: NONDETERMINISTIC_EFFECT
"""
client = await self._get_client()
try:
response = await client.get(url)
except Exception as e:
raise ToolError(
code="INTERNAL_ERROR",
message=f"Download failed: {e}",
)
# Generate destination path
if not dest:
parsed = urlparse(url)
filename = Path(parsed.path).name or "index.html"
dest = str(Path(self.cwd) / filename)
dest_path = Path(dest)
dest_path.parent.mkdir(parents=True, exist_ok=True)
# Save content
dest_path.write_bytes(response.content)
result = {
"path": str(dest_path),
"size": len(response.content),
"mime": response.headers.get("content-type", "").split(";")[0],
"url": url,
}
# Download assets if requested
if assets and "html" in result["mime"]:
downloaded_assets = []
html = response.text
links = []
# Extract asset URLs
try:
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "lxml")
for tag in soup.find_all(["img", "link", "script"]):
src = tag.get("src") or tag.get("href")
if src and not src.startswith("data:"):
links.append(urljoin(url, src))
except ImportError:
pass
# Download assets
assets_dir = dest_path.parent / f"{dest_path.stem}_assets"
assets_dir.mkdir(exist_ok=True)
for asset_url in links[:50]: # Limit to 50 assets
try:
asset_resp = await client.get(asset_url)
asset_name = Path(urlparse(asset_url).path).name
asset_path = assets_dir / asset_name
asset_path.write_bytes(asset_resp.content)
downloaded_assets.append(str(asset_path))
except Exception:
pass
result["assets"] = downloaded_assets
result["assets_count"] = len(downloaded_assets)
return result
@self.action("crawl", "Recursive site mirror")
async def crawl(
ctx: MCPContext,
url: str,
dest: str,
depth: int = 2,
same_host: bool = True,
limit: int = 100,
) -> dict:
"""Crawl and mirror a website.
Args:
url: Starting URL
dest: Destination directory
depth: Maximum crawl depth
same_host: Only crawl same hostname
limit: Maximum pages to download
Returns:
{pages: [Path], count}
Effect: NONDETERMINISTIC_EFFECT
"""
client = await self._get_client()
parsed_start = urlparse(url)
start_host = parsed_start.netloc
dest_path = Path(dest)
dest_path.mkdir(parents=True, exist_ok=True)
visited = set()
pages = []
queue = [(url, 0)] # (url, depth)
while queue and len(pages) < limit:
current_url, current_depth = queue.pop(0)
if current_url in visited:
continue
if current_depth > depth:
continue
parsed = urlparse(current_url)
if same_host and parsed.netloc != start_host:
continue
visited.add(current_url)
try:
response = await client.get(current_url)
except Exception:
continue
# Generate filename
path_parts = parsed.path.strip("/").split("/")
if not path_parts or not path_parts[-1]:
path_parts.append("index.html")
elif "." not in path_parts[-1]:
path_parts[-1] += ".html"
file_path = dest_path / "/".join(path_parts)
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_bytes(response.content)
pages.append(str(file_path))
# Extract links for further crawling
if "html" in response.headers.get("content-type", ""):
links = self._extract_links(response.text, current_url)
for link in links:
if link not in visited:
queue.append((link, current_depth + 1))
return {
"pages": pages,
"count": len(pages),
"dest": str(dest_path),
"depth": depth,
}
@self.action("head", "Get URL headers only")
async def head(
ctx: MCPContext,
url: str,
) -> dict:
"""Get HTTP headers without downloading body.
Args:
url: URL to check
Returns:
{status, headers, size?, mime?}
Effect: NONDETERMINISTIC_EFFECT
"""
client = await self._get_client()
try:
response = await client.head(url)
except Exception as e:
raise ToolError(
code="INTERNAL_ERROR",
message=f"Request failed: {e}",
)
headers = dict(response.headers)
result = {
"status": response.status_code,
"headers": headers,
"url": str(response.url),
}
if "content-length" in headers:
result["size"] = int(headers["content-length"])
if "content-type" in headers:
result["mime"] = headers["content-type"].split(";")[0].strip()
return result
def register(self, mcp_server: FastMCP) -> None:
"""Register as 'net' 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)
# Singleton
net_tool = NetTool
+71
View File
@@ -0,0 +1,71 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "hanzo-tools-net"
version = "0.1.0"
description = "Network tools for Hanzo AI (HIP-0300)"
readme = "README.md"
license = "MIT"
requires-python = ">=3.10"
authors = [
{ name = "Hanzo AI Team", email = "[email protected]" },
]
keywords = [
"hanzo",
"mcp",
"tools",
"network",
"fetch",
"crawl",
"search",
]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
dependencies = [
"hanzo-tools-core>=0.1.0",
"mcp>=1.0.0",
"httpx>=0.25.0",
]
[project.optional-dependencies]
full = [
"beautifulsoup4>=4.12.0",
"lxml>=5.0.0",
]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"ruff>=0.1.0",
]
[project.entry-points."hanzo.tools"]
net = "hanzo_tools.net:TOOLS"
[project.urls]
Homepage = "https://github.com/hanzoai/python-sdk"
Documentation = "https://docs.hanzo.ai"
Repository = "https://github.com/hanzoai/python-sdk"
[tool.hatch.build.targets.wheel]
packages = ["hanzo_tools"]
[tool.ruff]
line-length = 100
target-version = "py310"
[tool.ruff.lint]
select = ["E", "F", "I", "UP"]
ignore = ["E501"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
@@ -0,0 +1,3 @@
# Namespace package
from pkgutil import extend_path
__path__ = extend_path(__path__, __name__)
@@ -0,0 +1,56 @@
"""Plan orchestration tools for Hanzo AI (HIP-0300).
Tools:
- plan: Unified orchestration tool (HIP-0300)
- intent: Parse NL → IntentIR
- route: IntentIR → Plan (canonical chain)
- compose: Plan → ExecGraph
- chains: List available canonical chains
This is the "permissive input → strict output" layer.
Turns natural language into canonical operator chains.
Effect lattice position: PURE
All operations are safe to cache and parallelize.
Install:
pip install hanzo-tools-plan
Usage:
from hanzo_tools.plan import register_tools, TOOLS
# Register with MCP server
register_tools(mcp_server)
# Or access the unified tool
from hanzo_tools.plan import PlanTool
"""
from hanzo_tools.core import BaseTool, ToolRegistry
from .plan_tool import PlanTool, plan_tool
# Export list for tool discovery - HIP-0300 unified tool
TOOLS = [PlanTool]
__all__ = [
"PlanTool",
"plan_tool",
"register_tools",
"TOOLS",
]
def register_tools(mcp_server, **kwargs) -> list[BaseTool]:
"""Register plan tools with the MCP server.
Args:
mcp_server: The FastMCP server instance
**kwargs: Additional options
Returns:
List of registered tool instances
"""
tool = PlanTool()
ToolRegistry.register_tool(mcp_server, tool)
return [tool]
@@ -0,0 +1,503 @@
"""Unified plan orchestration tool for HIP-0300 architecture.
This module provides a single unified 'plan' tool that handles orchestration:
- intent: Parse natural language → IntentIR
- route: Map IntentIR → Plan (canonical operator chain)
- compose: Plan → ExecGraph (typed DAG for execution)
- execute: Run ExecGraph with policy gates (optional, returns audit log)
Following Unix philosophy: one tool for the Orchestration axis.
Turns "permissive input" into "strict canonical chains."
Effect lattice:
- intent, route, compose: NONDETERMINISTIC (LLM-based NL understanding)
- execute: NONDETERMINISTIC (audit-friendly)
"""
import os
import json
import hashlib
import re
from typing import Any, ClassVar, Literal
from dataclasses import dataclass, field
from enum import Enum
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import (
BaseTool,
ToolError,
InvalidParamsError,
content_hash,
)
class EffectClass(str, Enum):
"""Effect lattice for operators."""
PURE = "pure"
DETERMINISTIC = "deterministic"
NONDETERMINISTIC = "nondeterministic"
class Scope(str, Enum):
"""Scope lattice for operators."""
SPAN = "span"
FILE = "file"
PACKAGE = "package"
REPO = "repo"
WORKSPACE = "workspace"
@dataclass
class IntentIR:
"""Intermediate representation for parsed intent."""
category: str # navigate, edit, refactor, test, deploy, etc.
action: str # find, read, rename, run, etc.
target: str | None = None
params: dict[str, Any] = field(default_factory=dict)
confidence: float = 1.0
raw: str = ""
@dataclass
class PlanNode:
"""Single node in execution plan."""
id: str
tool: str
action: str
params: dict[str, Any] = field(default_factory=dict)
depends_on: list[str] = field(default_factory=list)
effect: EffectClass = EffectClass.PURE
scope: Scope = Scope.FILE
cache_key: str | None = None
@dataclass
class Plan:
"""Execution plan as typed DAG."""
nodes: list[PlanNode] = field(default_factory=list)
policy_gates: list[str] = field(default_factory=list) # Nodes requiring approval
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass
class ExecGraph:
"""Compiled execution graph ready for execution."""
plan: Plan
execution_order: list[str] # Topologically sorted node IDs
parallelizable: list[list[str]] # Groups that can run in parallel
audit_log: list[dict] = field(default_factory=list)
# Intent routing patterns
INTENT_PATTERNS = [
# Navigation / Understanding
(r"(where|find|locate)\s+(is\s+)?(.+)", "navigate", "find"),
(r"(who|what)\s+(calls|uses)\s+(.+)", "navigate", "references"),
(r"(what|explain|describe)\s+(does\s+)?(.+)\s+(do|mean)", "navigate", "explain"),
(r"(go\s+to|jump\s+to)\s+(definition\s+of\s+)?(.+)", "navigate", "definition"),
(r"(show|list)\s+(symbols|functions|classes)", "navigate", "symbols"),
# Edit / Transform
(r"(edit|change|modify|update)\s+(.+)", "edit", "transform"),
(r"(rename|refactor)\s+(.+)\s+(to|as)\s+(.+)", "refactor", "rename"),
(r"(add|create|insert)\s+(.+)", "edit", "create"),
(r"(remove|delete)\s+(.+)", "edit", "delete"),
(r"(fix|repair|correct)\s+(.+)", "edit", "fix"),
# Test / Validate
(r"(run|execute)\s+(tests?|specs?)", "test", "run"),
(r"(check|verify|validate)\s+(.+)", "test", "validate"),
(r"(lint|typecheck|format)\s+(.+)?", "test", "lint"),
(r"(why|debug)\s+(is|are|did)\s+(.+)\s+(failing|broken|wrong)", "test", "debug"),
# VCS
(r"(commit|save)\s+(changes?)?", "vcs", "commit"),
(r"(diff|compare|show\s+changes)", "vcs", "diff"),
(r"(log|history|blame)\s*(.+)?", "vcs", "log"),
# General
(r"(help|how\s+do\s+i)", "meta", "help"),
]
# Canonical chains for intents
CANONICAL_CHAINS = {
("navigate", "find"): [
{"tool": "fs", "action": "search"},
],
("navigate", "references"): [
{"tool": "code", "action": "references"},
{"tool": "code", "action": "summarize"},
],
("navigate", "definition"): [
{"tool": "code", "action": "definition"},
{"tool": "fs", "action": "read"},
],
("navigate", "symbols"): [
{"tool": "code", "action": "symbols"},
],
("navigate", "explain"): [
{"tool": "fs", "action": "read"},
{"tool": "code", "action": "parse"},
{"tool": "code", "action": "summarize"},
],
("edit", "transform"): [
{"tool": "fs", "action": "read"},
{"tool": "code", "action": "transform"},
{"tool": "code", "action": "summarize"}, # Review patch
{"tool": "fs", "action": "patch", "policy_gate": True},
{"tool": "test", "action": "run"},
],
("refactor", "rename"): [
{"tool": "code", "action": "references"},
{"tool": "code", "action": "transform"},
{"tool": "code", "action": "summarize"},
{"tool": "fs", "action": "patch", "policy_gate": True},
{"tool": "test", "action": "run"},
],
("test", "run"): [
{"tool": "test", "action": "run"},
{"tool": "code", "action": "summarize"},
],
("test", "debug"): [
{"tool": "test", "action": "run"},
{"tool": "code", "action": "summarize"},
{"tool": "code", "action": "references"},
],
("vcs", "commit"): [
{"tool": "vcs", "action": "diff", "params": {"staged": True}},
{"tool": "code", "action": "summarize"},
{"tool": "vcs", "action": "commit", "policy_gate": True},
],
("vcs", "diff"): [
{"tool": "vcs", "action": "diff"},
{"tool": "code", "action": "summarize"},
],
}
class PlanTool(BaseTool):
"""Unified plan orchestration tool (HIP-0300).
Handles all orchestration operations:
- intent: Parse NL → IntentIR
- route: IntentIR → Plan
- compose: Plan → ExecGraph
- execute: Run with audit (optional)
This is the "permissive input → strict output" layer.
"""
name: ClassVar[str] = "plan"
VERSION: ClassVar[str] = "0.1.0"
def __init__(self):
super().__init__()
self._register_plan_actions()
@property
def description(self) -> str:
return """Unified plan orchestration tool (HIP-0300).
Actions:
- intent: Parse natural language → IntentIR
- route: Map IntentIR → Plan (canonical operator chain)
- compose: Plan → ExecGraph (execution-ready DAG)
Turns permissive natural language input into strict canonical operator chains.
"""
def _parse_intent(self, nl: str) -> IntentIR:
"""Parse natural language to IntentIR."""
nl_lower = nl.lower().strip()
for pattern, category, action in INTENT_PATTERNS:
match = re.search(pattern, nl_lower, re.IGNORECASE)
if match:
# Extract target from capture groups
groups = match.groups()
target = groups[-1] if groups else None
return IntentIR(
category=category,
action=action,
target=target,
confidence=0.8,
raw=nl,
)
# Fallback: ambiguous
return IntentIR(
category="unknown",
action="unknown",
target=None,
confidence=0.3,
raw=nl,
)
def _build_plan(self, intent: IntentIR, policy: dict | None = None) -> Plan:
"""Build execution plan from intent."""
chain_key = (intent.category, intent.action)
chain = CANONICAL_CHAINS.get(chain_key, [])
if not chain:
# Unknown intent - return empty plan
return Plan(
metadata={
"intent": intent.category,
"action": intent.action,
"warning": "No canonical chain found",
}
)
nodes = []
policy_gates = []
prev_id = None
for i, step in enumerate(chain):
node_id = f"step_{i}"
node = PlanNode(
id=node_id,
tool=step["tool"],
action=step["action"],
params=step.get("params", {}),
depends_on=[prev_id] if prev_id else [],
effect=EffectClass.DETERMINISTIC if step.get("policy_gate") else EffectClass.PURE,
)
# Add target from intent
if intent.target and i == 0:
if "path" not in node.params:
node.params["target"] = intent.target
nodes.append(node)
if step.get("policy_gate"):
policy_gates.append(node_id)
prev_id = node_id
return Plan(
nodes=nodes,
policy_gates=policy_gates,
metadata={
"intent": intent.category,
"action": intent.action,
"target": intent.target,
}
)
def _compile_graph(self, plan: Plan) -> ExecGraph:
"""Compile plan into execution graph."""
# Topological sort
node_map = {n.id: n for n in plan.nodes}
in_degree = {n.id: len(n.depends_on) for n in plan.nodes}
execution_order = []
parallelizable = []
# Kahn's algorithm
ready = [nid for nid, deg in in_degree.items() if deg == 0]
while ready:
# All ready nodes can run in parallel
if len(ready) > 1:
parallelizable.append(ready.copy())
execution_order.extend(ready)
next_ready = []
for nid in ready:
for node in plan.nodes:
if nid in node.depends_on:
in_degree[node.id] -= 1
if in_degree[node.id] == 0:
next_ready.append(node.id)
ready = next_ready
return ExecGraph(
plan=plan,
execution_order=execution_order,
parallelizable=parallelizable,
)
def _register_plan_actions(self):
"""Register all plan actions."""
@self.action("intent", "Parse natural language to IntentIR")
async def intent(
ctx: MCPContext,
nl: str,
) -> dict:
"""Parse natural language input to structured IntentIR.
Args:
nl: Natural language input
Returns:
IntentIR with category, action, target, confidence
Effect: NONDETERMINISTIC (LLM-based, regex fallback)
Cache: hash(nl)
"""
parsed = self._parse_intent(nl)
return {
"intent_ir": {
"category": parsed.category,
"action": parsed.action,
"target": parsed.target,
"params": parsed.params,
"confidence": parsed.confidence,
},
"raw": nl,
"hash": content_hash(nl),
}
@self.action("route", "Map IntentIR to Plan")
async def route(
ctx: MCPContext,
intent_ir: dict | None = None,
nl: str | None = None,
policy: dict | None = None,
) -> dict:
"""Route intent to canonical operator chain.
Args:
intent_ir: Parsed intent (from plan.intent)
nl: Raw NL (will parse if intent_ir not provided)
policy: Policy constraints
Returns:
Plan with nodes, policy_gates, metadata
Effect: NONDETERMINISTIC
"""
if intent_ir:
intent = IntentIR(
category=intent_ir.get("category", "unknown"),
action=intent_ir.get("action", "unknown"),
target=intent_ir.get("target"),
params=intent_ir.get("params", {}),
confidence=intent_ir.get("confidence", 1.0),
)
elif nl:
intent = self._parse_intent(nl)
else:
raise InvalidParamsError("intent_ir or nl required")
plan = self._build_plan(intent, policy)
return {
"plan": {
"nodes": [
{
"id": n.id,
"tool": n.tool,
"action": n.action,
"params": n.params,
"depends_on": n.depends_on,
"effect": n.effect.value,
}
for n in plan.nodes
],
"policy_gates": plan.policy_gates,
"metadata": plan.metadata,
},
"chain_length": len(plan.nodes),
"requires_approval": len(plan.policy_gates) > 0,
}
@self.action("compose", "Compile Plan to ExecGraph")
async def compose(
ctx: MCPContext,
plan: dict,
) -> dict:
"""Compile plan into execution-ready graph.
Args:
plan: Plan dict (from plan.route)
Returns:
ExecGraph with execution_order, parallelizable groups
Effect: PURE
"""
# Reconstruct Plan
nodes = [
PlanNode(
id=n["id"],
tool=n["tool"],
action=n["action"],
params=n.get("params", {}),
depends_on=n.get("depends_on", []),
effect=EffectClass(n.get("effect", "pure")),
)
for n in plan.get("nodes", [])
]
plan_obj = Plan(
nodes=nodes,
policy_gates=plan.get("policy_gates", []),
metadata=plan.get("metadata", {}),
)
graph = self._compile_graph(plan_obj)
return {
"exec_graph": {
"execution_order": graph.execution_order,
"parallelizable": graph.parallelizable,
"policy_gates": plan_obj.policy_gates,
"total_steps": len(graph.execution_order),
},
"plan": plan,
"ready_for_execution": True,
}
@self.action("chains", "List available canonical chains")
async def chains(
ctx: MCPContext,
category: str | None = None,
) -> dict:
"""List available canonical operator chains.
Args:
category: Filter by category (optional)
Returns:
Dictionary of available chains
"""
result = {}
for (cat, action), chain in CANONICAL_CHAINS.items():
if category and cat != category:
continue
key = f"{cat}.{action}"
result[key] = {
"steps": len(chain),
"tools": [s["tool"] for s in chain],
"has_policy_gate": any(s.get("policy_gate") for s in chain),
}
return {
"chains": result,
"total": len(result),
}
def register(self, mcp_server: FastMCP) -> None:
"""Register as 'plan' 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)
# Singleton
plan_tool = PlanTool
+66
View File
@@ -0,0 +1,66 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "hanzo-tools-plan"
version = "0.1.0"
description = "Plan orchestration tools for Hanzo AI (HIP-0300)"
readme = "README.md"
license = "MIT"
requires-python = ">=3.10"
authors = [
{ name = "Hanzo AI Team", email = "[email protected]" },
]
keywords = [
"hanzo",
"mcp",
"tools",
"plan",
"orchestration",
"dag",
"intent",
]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
dependencies = [
"hanzo-tools-core>=0.1.0",
"mcp>=1.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"ruff>=0.1.0",
]
[project.entry-points."hanzo.tools"]
plan = "hanzo_tools.plan:TOOLS"
[project.urls]
Homepage = "https://github.com/hanzoai/python-sdk"
Documentation = "https://docs.hanzo.ai"
Repository = "https://github.com/hanzoai/python-sdk"
[tool.hatch.build.targets.wheel]
packages = ["hanzo_tools"]
[tool.ruff]
line-length = 100
target-version = "py310"
[tool.ruff.lint]
select = ["E", "F", "I", "UP"]
ignore = ["E501"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
@@ -0,0 +1,3 @@
"""Hanzo Tools namespace package."""
__path__ = __import__("pkgutil").extend_path(__path__, __name__)
@@ -0,0 +1,333 @@
"""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 configure_loop, using_uvloop
# 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.base_process import AUTO_BACKGROUND_TIMEOUT
from hanzo_tools.shell.jq_tool import JqTool
from hanzo_tools.shell.ps_tool import PsTool, ps_tool
# HIP-0300: Unified proc tool
from hanzo_tools.shell.proc_tool import ProcTool, proc_tool
# Core tools
from hanzo_tools.shell.cmd_tool import CmdTool, CmdResult, CmdNode, NodeStatus, create_cmd_tool, cmd_tool
from hanzo_tools.shell.npx_tool import NpxTool, npx_tool
from hanzo_tools.shell.truncate import truncate_lines, estimate_tokens, truncate_response
from hanzo_tools.shell.uvx_tool import UvxTool, uvx_tool
from hanzo_tools.shell.shell_tools import (
ZshTool, BashTool, FishTool, DashTool, KshTool, TcshTool, CshTool, ShellTool,
zsh_tool, bash_tool, fish_tool, dash_tool, ksh_tool, tcsh_tool, csh_tool, shell_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
from hanzo_tools.shell.shellflow import parse as parse_shellflow, compile as compile_shellflow
from hanzo_tools.shell.wget_tool import WgetTool
# Base classes
from hanzo_tools.shell.base_process import (
BaseBinaryTool,
BaseScriptTool,
ProcessManager,
BaseProcessTool,
AutoBackgroundExecutor,
ShellExecutor,
get_shell_executor,
)
# Session management
from hanzo_tools.shell.session_storage import (
SessionStorage,
SessionInfo,
cleanup_expired_sessions,
clear_all_sessions,
)
# Backwards compatibility - DagTool is now CmdTool
from hanzo_tools.shell.dag_tool import DagTool, DagResult, DagNode, create_dag_tool
# Shell detection
from hanzo_tools.shell.shell_detect import (
ShellInfo,
detect_shells,
get_active_shell,
get_cached_active_shell,
clear_shell_cache,
get_shell_tool_class,
resolve_shell_path,
SUPPORTED_SHELLS,
)
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)
@@ -0,0 +1,664 @@
"""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
@@ -0,0 +1,514 @@
"""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()
@@ -0,0 +1,189 @@
"""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,
)
@@ -0,0 +1,485 @@
"""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)
@@ -0,0 +1,167 @@
"""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,
)
@@ -0,0 +1,90 @@
"""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()
@@ -0,0 +1,116 @@
"""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()
@@ -0,0 +1,367 @@
"""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, Optional, ClassVar, Dict
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 (
BaseTool,
ToolError,
InvalidParamsError,
NotFoundError,
Paging,
)
from hanzo_tools.shell.base_process import (
ProcessManager,
get_shell_executor,
AUTO_BACKGROUND_TIMEOUT,
)
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
@@ -0,0 +1,212 @@
"""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 hanzo_async import read_file, path_exists
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
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()
@@ -0,0 +1,281 @@
"""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 signal
import time
import threading
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, Optional, ClassVar
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",
]
@@ -0,0 +1,401 @@
"""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 platform
import shlex
import shutil
import subprocess
from dataclasses import dataclass
from typing import Optional, Tuple, Dict, List
# 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 (
ZshTool, BashTool, FishTool, DashTool, KshTool, TcshTool, CshTool
)
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)')}")
@@ -0,0 +1,682 @@
"""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()
@@ -0,0 +1,497 @@
"""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
@@ -0,0 +1,98 @@
"""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)
@@ -0,0 +1,90 @@
"""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()
@@ -0,0 +1,232 @@
"""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,
)
@@ -54,6 +54,9 @@ from hanzo_tools.shell.base_process import AUTO_BACKGROUND_TIMEOUT
from hanzo_tools.shell.jq_tool import JqTool
from hanzo_tools.shell.ps_tool import PsTool, ps_tool
# HIP-0300: Unified proc tool
from hanzo_tools.shell.proc_tool import ProcTool, proc_tool
# Core tools
from hanzo_tools.shell.cmd_tool import CmdTool, CmdResult, CmdNode, NodeStatus, create_cmd_tool, cmd_tool
from hanzo_tools.shell.npx_tool import NpxTool, npx_tool
@@ -83,6 +86,14 @@ from hanzo_tools.shell.base_process import (
get_shell_executor,
)
# Session management
from hanzo_tools.shell.session_storage import (
SessionStorage,
SessionInfo,
cleanup_expired_sessions,
clear_all_sessions,
)
# Backwards compatibility - DagTool is now CmdTool
from hanzo_tools.shell.dag_tool import DagTool, DagResult, DagNode, create_dag_tool
@@ -141,6 +152,9 @@ TOOLS = _get_detected_shell_tools()
__all__ = [
# uvloop status
"_using_uvloop",
# HIP-0300: Unified proc tool
"ProcTool",
"proc_tool",
# Base classes
"ProcessManager",
"AutoBackgroundExecutor",
@@ -149,6 +163,11 @@ __all__ = [
"BaseProcessTool",
"BaseBinaryTool",
"BaseScriptTool",
# Session management
"SessionStorage",
"SessionInfo",
"cleanup_expired_sessions",
"clear_all_sessions",
# Utilities
"truncate_response",
"truncate_lines",
@@ -162,7 +181,7 @@ __all__ = [
"get_shell_tool_class",
"resolve_shell_path",
"SUPPORTED_SHELLS",
# Core tools
# Legacy tools
"CmdTool",
"CmdResult",
"CmdNode",
@@ -0,0 +1,367 @@
"""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, Optional, ClassVar, Dict
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 (
BaseTool,
ToolError,
InvalidParamsError,
NotFoundError,
Paging,
)
from hanzo_tools.shell.base_process import (
ProcessManager,
get_shell_executor,
AUTO_BACKGROUND_TIMEOUT,
)
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
@@ -0,0 +1,281 @@
"""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 signal
import time
import threading
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, Optional, ClassVar
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",
]
@@ -0,0 +1,3 @@
# Namespace package
from pkgutil import extend_path
__path__ = extend_path(__path__, __name__)
@@ -0,0 +1,54 @@
"""Test and validation tools for Hanzo AI (HIP-0300).
Tools:
- test: Unified test/validation tool (HIP-0300)
- run: Execute test/lint/typecheck
- detect: Auto-detect available tools
Kinds: test | lint | typecheck
Effect lattice position: NONDETERMINISTIC_EFFECT
Wraps process execution with structured Report output.
Install:
pip install hanzo-tools-test
Usage:
from hanzo_tools.test import register_tools, TOOLS
# Register with MCP server
register_tools(mcp_server)
# Or access the unified tool
from hanzo_tools.test import TestTool
"""
from hanzo_tools.core import BaseTool, ToolRegistry
from .test_tool import TestTool, test_tool
# Export list for tool discovery - HIP-0300 unified tool
TOOLS = [TestTool]
__all__ = [
"TestTool",
"test_tool",
"register_tools",
"TOOLS",
]
def register_tools(mcp_server, **kwargs) -> list[BaseTool]:
"""Register test tools with the MCP server.
Args:
mcp_server: The FastMCP server instance
**kwargs: Additional options (cwd, etc.)
Returns:
List of registered tool instances
"""
cwd = kwargs.get("cwd")
tool = TestTool(cwd=cwd)
ToolRegistry.register_tool(mcp_server, tool)
return [tool]
@@ -0,0 +1,541 @@
"""Unified validation tool for HIP-0300 architecture.
This module provides a single unified 'test' tool for three distinct feedback loops:
1. CHECK (fast, incremental, per-file)
- Static properties: syntax, types, lint rules
- Substrate: LSP / linters
- Output: Diagnostics + quickfix edits
- Composition: Check → Fix → Apply → Check
2. BUILD (slower, whole-project)
- Validates: linkability, packaging, compile graph
- Substrate: build system
- Output: logs + artifacts
- Composition: Build → ParseErrors → Patch → Build
3. TEST (executes code, validates behavior)
- Validates: runtime behavior, specs, invariants
- Substrate: test runner
- Output: pass/fail + traces
- Composition: Test → Locate(failure) → Patch → Test
Effect lattice position: NONDETERMINISTIC_EFFECT
Representation: Report (Diagnostics | BuildResult | TestResult)
Scope: Buffer → File → Package → Repo
"""
import os
import json
import asyncio
import re
from typing import Any, ClassVar, Literal
from pathlib import Path
from dataclasses import dataclass, field
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import (
BaseTool,
ToolError,
InvalidParamsError,
Paging,
content_hash,
)
# Test runner detection and commands
TEST_RUNNERS = {
# Python
"pytest": {
"detect": ["pytest.ini", "pyproject.toml", "setup.py"],
"cmd": ["pytest", "-v", "--tb=short"],
"parse": "pytest",
},
"unittest": {
"detect": ["test*.py"],
"cmd": ["python", "-m", "unittest", "discover", "-v"],
"parse": "unittest",
},
# JavaScript/TypeScript
"jest": {
"detect": ["jest.config.js", "jest.config.ts", "package.json"],
"cmd": ["npx", "jest", "--json"],
"parse": "jest",
},
"vitest": {
"detect": ["vitest.config.ts", "vitest.config.js"],
"cmd": ["npx", "vitest", "run", "--reporter=json"],
"parse": "vitest",
},
"mocha": {
"detect": [".mocharc.js", ".mocharc.json"],
"cmd": ["npx", "mocha", "--reporter", "json"],
"parse": "mocha",
},
# Go
"go_test": {
"detect": ["go.mod"],
"cmd": ["go", "test", "-v", "-json", "./..."],
"parse": "go_test",
},
# Rust
"cargo_test": {
"detect": ["Cargo.toml"],
"cmd": ["cargo", "test", "--", "--format=json", "-Z", "unstable-options"],
"parse": "cargo_test",
},
}
# Build tools
BUILD_TOOLS = {
# Python
"pip": {
"detect": ["pyproject.toml", "setup.py"],
"cmd": ["pip", "install", "-e", "."],
"parse": "pip",
},
"poetry": {
"detect": ["poetry.lock"],
"cmd": ["poetry", "build"],
"parse": "poetry",
},
# JavaScript/TypeScript
"npm": {
"detect": ["package.json"],
"cmd": ["npm", "run", "build"],
"parse": "npm",
},
"pnpm": {
"detect": ["pnpm-lock.yaml"],
"cmd": ["pnpm", "build"],
"parse": "pnpm",
},
# Go
"go_build": {
"detect": ["go.mod"],
"cmd": ["go", "build", "./..."],
"parse": "go_build",
},
# Rust
"cargo_build": {
"detect": ["Cargo.toml"],
"cmd": ["cargo", "build"],
"parse": "cargo_build",
},
# Make
"make": {
"detect": ["Makefile"],
"cmd": ["make"],
"parse": "make",
},
}
# Check tools (fast, incremental linting/typechecking)
CHECK_TOOLS = {
# Python
"ruff": {
"detect": ["ruff.toml", "pyproject.toml"],
"cmd": ["ruff", "check", "--output-format=json"],
"parse": "ruff",
},
"mypy": {
"detect": ["mypy.ini", "pyproject.toml"],
"cmd": ["mypy", "--output=json"],
"parse": "mypy",
},
"pylint": {
"detect": [".pylintrc", "pyproject.toml"],
"cmd": ["pylint", "--output-format=json"],
"parse": "pylint",
},
# JavaScript/TypeScript
"eslint": {
"detect": [".eslintrc", ".eslintrc.js", ".eslintrc.json"],
"cmd": ["npx", "eslint", "--format=json"],
"parse": "eslint",
},
"tsc": {
"detect": ["tsconfig.json"],
"cmd": ["npx", "tsc", "--noEmit"],
"parse": "tsc",
},
# Go
"golangci-lint": {
"detect": [".golangci.yml", ".golangci.yaml"],
"cmd": ["golangci-lint", "run", "--out-format=json"],
"parse": "golangci",
},
# Rust
"cargo_clippy": {
"detect": ["Cargo.toml"],
"cmd": ["cargo", "clippy", "--message-format=json"],
"parse": "clippy",
},
}
@dataclass
class TestResult:
"""Single test result."""
name: str
status: Literal["pass", "fail", "skip", "error"]
duration_ms: float = 0
message: str | None = None
location: dict | None = None # {file, line}
@dataclass
class Report:
"""Structured test/lint report."""
kind: str # test, lint, typecheck
tool: str
passed: bool
total: int = 0
passed_count: int = 0
failed_count: int = 0
skipped_count: int = 0
error_count: int = 0
duration_ms: float = 0
results: list[TestResult] = field(default_factory=list)
raw_output: str = ""
exit_code: int = 0
class TestTool(BaseTool):
"""Unified test/validation tool (HIP-0300).
Handles all assurance operations:
- run: Execute test/lint/typecheck
- report: Format results
Wraps proc.run with structured Report output.
Effect: NONDETERMINISTIC_EFFECT
"""
name: ClassVar[str] = "test"
VERSION: ClassVar[str] = "0.1.0"
def __init__(self, cwd: str | None = None):
super().__init__()
self.cwd = cwd or os.getcwd()
self._register_test_actions()
@property
def description(self) -> str:
return """Unified validation tool (HIP-0300).
Three feedback loops:
- check: Fast, incremental (lint/typecheck) → Diagnostics
- build: Whole-project compilation → BuildResult
- test: Runtime behavior validation → TestResult
Actions:
- run: Execute check/build/test with structured Report output
- detect: Auto-detect available tools for each loop
Compositions:
- Check loop: check → fix → apply → check
- Build loop: build → locate(errors) → patch → build
- Test loop: test → locate(failure) → patch → test
Effect: NONDETERMINISTIC_EFFECT
"""
def _detect_runner(self, cwd: str, kind: str) -> tuple[str, list[str]] | None:
"""Auto-detect appropriate runner for the loop type.
Args:
cwd: Working directory
kind: check | build | test
"""
if kind == "test":
tools = TEST_RUNNERS
elif kind == "build":
tools = BUILD_TOOLS
else: # check (lint/typecheck)
tools = CHECK_TOOLS
for name, config in tools.items():
for detect_file in config["detect"]:
if "*" in detect_file:
# Glob pattern
if list(Path(cwd).glob(detect_file)):
return name, config["cmd"]
else:
if (Path(cwd) / detect_file).exists():
return name, config["cmd"]
return None
def _parse_pytest_output(self, output: str, exit_code: int) -> Report:
"""Parse pytest output."""
results = []
total = passed = failed = skipped = errors = 0
# Parse summary line
summary_match = re.search(
r"(\d+) passed.*?(\d+) failed.*?(\d+) error|"
r"(\d+) passed.*?(\d+) skipped|"
r"(\d+) passed",
output,
)
# Parse individual test results
for match in re.finditer(
r"(PASSED|FAILED|SKIPPED|ERROR)\s+(\S+)::",
output,
):
status_map = {"PASSED": "pass", "FAILED": "fail", "SKIPPED": "skip", "ERROR": "error"}
status = status_map.get(match.group(1), "error")
name = match.group(2)
results.append(TestResult(name=name, status=status))
total += 1
if status == "pass":
passed += 1
elif status == "fail":
failed += 1
elif status == "skip":
skipped += 1
else:
errors += 1
return Report(
kind="test",
tool="pytest",
passed=exit_code == 0,
total=total,
passed_count=passed,
failed_count=failed,
skipped_count=skipped,
error_count=errors,
results=results,
raw_output=output,
exit_code=exit_code,
)
def _parse_generic_output(
self, output: str, exit_code: int, kind: str, tool: str
) -> Report:
"""Generic output parser."""
# Try JSON parsing first
try:
data = json.loads(output)
# Handle various JSON formats
if isinstance(data, dict):
if "testResults" in data: # Jest
results = []
for suite in data.get("testResults", []):
for test in suite.get("assertionResults", []):
results.append(TestResult(
name=test.get("fullName", ""),
status="pass" if test.get("status") == "passed" else "fail",
))
return Report(
kind=kind,
tool=tool,
passed=data.get("success", exit_code == 0),
total=len(results),
passed_count=sum(1 for r in results if r.status == "pass"),
failed_count=sum(1 for r in results if r.status == "fail"),
results=results,
raw_output=output,
exit_code=exit_code,
)
except json.JSONDecodeError:
pass
# Fallback: line-based parsing
lines = output.strip().split("\n")
error_lines = [l for l in lines if "error" in l.lower() or "fail" in l.lower()]
return Report(
kind=kind,
tool=tool,
passed=exit_code == 0 and len(error_lines) == 0,
total=len(lines),
failed_count=len(error_lines),
raw_output=output,
exit_code=exit_code,
)
def _register_test_actions(self):
"""Register all test actions."""
@self.action("run", "Execute check/build/test loop")
async def run(
ctx: MCPContext,
kind: str = "test",
selector: str | None = None,
cwd: str | None = None,
tool: str | None = None,
timeout: int = 300,
) -> dict:
"""Run validation loop and return structured Report.
Args:
kind: check | build | test
- check: Fast incremental (lint/typecheck) → Diagnostics
- build: Whole-project compilation → BuildResult
- test: Runtime behavior validation → TestResult
selector: File/target selector (e.g., "test_foo.py", "src/")
cwd: Working directory
tool: Specific tool to use (auto-detect if not specified)
timeout: Timeout in seconds
Returns:
Report with pass/fail, counts, results
Effect: NONDETERMINISTIC_EFFECT
"""
work_dir = cwd or self.cwd
# Detect or use specified tool
if tool:
if kind == "test":
tools = TEST_RUNNERS
elif kind == "build":
tools = BUILD_TOOLS
else:
tools = CHECK_TOOLS
if tool not in tools:
raise InvalidParamsError(f"Unknown tool: {tool}")
cmd = tools[tool]["cmd"].copy()
tool_name = tool
else:
detected = self._detect_runner(work_dir, kind)
if not detected:
raise ToolError(
code="NOT_FOUND",
message=f"No {kind} runner detected in {work_dir}",
)
tool_name, cmd = detected
cmd = cmd.copy()
# Add selector if provided
if selector:
cmd.append(selector)
# Run command
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
cwd=work_dir,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(
proc.communicate(),
timeout=timeout,
)
output = stdout.decode("utf-8", errors="replace")
if stderr:
output += "\n" + stderr.decode("utf-8", errors="replace")
exit_code = proc.returncode or 0
except asyncio.TimeoutError:
raise ToolError(code="TIMEOUT", message=f"{kind} timed out after {timeout}s")
except FileNotFoundError as e:
raise ToolError(code="NOT_FOUND", message=f"Tool not found: {e}")
# Parse output
if tool_name == "pytest":
report = self._parse_pytest_output(output, exit_code)
else:
report = self._parse_generic_output(output, exit_code, kind, tool_name)
return {
"report": {
"kind": report.kind,
"tool": report.tool,
"passed": report.passed,
"total": report.total,
"passed_count": report.passed_count,
"failed_count": report.failed_count,
"skipped_count": report.skipped_count,
"error_count": report.error_count,
"duration_ms": report.duration_ms,
"results": [
{
"name": r.name,
"status": r.status,
"message": r.message,
}
for r in report.results[:50] # Limit results
],
},
"pass": report.passed,
"exit_code": exit_code,
"raw_ref": content_hash(output), # Reference to full output
}
@self.action("detect", "Detect available test/lint tools")
async def detect(
ctx: MCPContext,
cwd: str | None = None,
) -> dict:
"""Detect available test runners and lint tools.
Returns:
Dict of detected tools by category
"""
work_dir = cwd or self.cwd
detected = {
"check": [], # Fast incremental (lint/typecheck)
"build": [], # Whole-project compilation
"test": [], # Runtime behavior validation
}
# Detect test runners
for name, config in TEST_RUNNERS.items():
for detect_file in config["detect"]:
if "*" in detect_file:
if list(Path(work_dir).glob(detect_file)):
detected["test"].append(name)
break
else:
if (Path(work_dir) / detect_file).exists():
detected["test"].append(name)
break
# Detect build tools
for name, config in BUILD_TOOLS.items():
for detect_file in config["detect"]:
if (Path(work_dir) / detect_file).exists():
detected["build"].append(name)
break
# Detect check tools (lint/typecheck)
for name, config in CHECK_TOOLS.items():
for detect_file in config["detect"]:
if (Path(work_dir) / detect_file).exists():
detected["check"].append(name)
break
return {
"detected": detected,
"cwd": work_dir,
}
def register(self, mcp_server: FastMCP) -> None:
"""Register as 'test' 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)
# Singleton
test_tool = TestTool
+66
View File
@@ -0,0 +1,66 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "hanzo-tools-test"
version = "0.1.0"
description = "Test and validation tools for Hanzo AI (HIP-0300)"
readme = "README.md"
license = "MIT"
requires-python = ">=3.10"
authors = [
{ name = "Hanzo AI Team", email = "[email protected]" },
]
keywords = [
"hanzo",
"mcp",
"tools",
"test",
"validation",
"lint",
"typecheck",
]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
dependencies = [
"hanzo-tools-core>=0.1.0",
"mcp>=1.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"ruff>=0.1.0",
]
[project.entry-points."hanzo.tools"]
test = "hanzo_tools.test:TOOLS"
[project.urls]
Homepage = "https://github.com/hanzoai/python-sdk"
Documentation = "https://docs.hanzo.ai"
Repository = "https://github.com/hanzoai/python-sdk"
[tool.hatch.build.targets.wheel]
packages = ["hanzo_tools"]
[tool.ruff]
line-length = 100
target-version = "py310"
[tool.ruff.lint]
select = ["E", "F", "I", "UP"]
ignore = ["E501"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
@@ -0,0 +1,3 @@
# Namespace package
from pkgutil import extend_path
__path__ = extend_path(__path__, __name__)
@@ -0,0 +1,56 @@
"""Version control tools for Hanzo AI (HIP-0300).
Tools:
- vcs: Unified version control tool (HIP-0300)
- status: Working tree status
- diff: Show differences (unified patch format)
- apply: Apply patch
- commit: Create commit
- branch: Branch operations (list, create, delete)
- checkout: Switch branches
- log: Commit history
Outputs diffs in unified patch format for use with fs.apply_patch.
Install:
pip install hanzo-tools-vcs
Usage:
from hanzo_tools.vcs import register_tools, TOOLS
# Register with MCP server
register_tools(mcp_server)
# Or access the unified tool
from hanzo_tools.vcs import VcsTool
"""
from hanzo_tools.core import BaseTool, ToolRegistry
from .vcs_tool import VcsTool, vcs_tool
# Export list for tool discovery - HIP-0300 unified tool
TOOLS = [VcsTool]
__all__ = [
"VcsTool",
"vcs_tool",
"register_tools",
"TOOLS",
]
def register_tools(mcp_server, **kwargs) -> list[BaseTool]:
"""Register vcs tools with the MCP server.
Args:
mcp_server: The FastMCP server instance
**kwargs: Additional options (cwd, etc.)
Returns:
List of registered tool instances
"""
cwd = kwargs.get("cwd")
tool = VcsTool(cwd=cwd)
ToolRegistry.register_tool(mcp_server, tool)
return [tool]
@@ -0,0 +1,460 @@
"""Unified version control tool for HIP-0300 architecture.
This module provides a single unified 'vcs' tool that handles all version control operations:
- status: Working tree status
- diff: Show differences
- apply: Apply patch
- commit: Create commit
- branch: Branch operations
- checkout: Switch branches
- log: Commit history
Following Unix philosophy: one tool for the Diffs + History axis.
Outputs diffs in unified patch format; integrates with fs.apply_patch.
"""
import os
import json
import asyncio
import subprocess
from typing import Any, Optional, ClassVar
from pathlib import Path
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import (
BaseTool,
ToolError,
InvalidParamsError,
NotFoundError,
Paging,
file_uri,
)
class VcsTool(BaseTool):
"""Unified version control tool (HIP-0300).
Handles all VCS operations on a single axis:
- status: Working tree status
- diff: Show differences
- apply: Apply patch
- commit: Create commit
- branch: Branch operations
- checkout: Switch branches
- log: Commit history
Outputs diffs in unified patch format.
"""
name: ClassVar[str] = "vcs"
VERSION: ClassVar[str] = "0.12.0"
def __init__(self, cwd: Optional[str] = None):
super().__init__()
self.cwd = cwd or os.getcwd()
self._register_vcs_actions()
@property
def description(self) -> str:
return """Unified version control tool (HIP-0300).
Actions:
- status: Working tree status
- diff: Show differences (unified patch format)
- apply: Apply patch
- commit: Create commit
- branch: Branch operations (list, create, delete)
- checkout: Switch branches
- log: Commit history
Outputs diffs in unified patch format for use with fs.apply_patch.
"""
async def _run_git(
self,
*args: str,
cwd: Optional[str] = None,
check: bool = True,
) -> tuple[str, str, int]:
"""Run git command and return stdout, stderr, returncode."""
cmd = ["git", *args]
work_dir = cwd or self.cwd
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
cwd=work_dir,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30)
stdout_str = stdout.decode("utf-8", errors="replace")
stderr_str = stderr.decode("utf-8", errors="replace")
if check and proc.returncode != 0:
raise ToolError(
code="INTERNAL_ERROR",
message=f"git {args[0]} failed: {stderr_str}",
details={"returncode": proc.returncode},
)
return stdout_str, stderr_str, proc.returncode
except asyncio.TimeoutError:
raise ToolError(
code="TIMEOUT",
message=f"git {args[0]} timed out",
)
except FileNotFoundError:
raise ToolError(
code="NOT_FOUND",
message="git not found in PATH",
)
def _register_vcs_actions(self):
"""Register all VCS actions."""
@self.action("status", "Get working tree status")
async def status(
ctx: MCPContext,
cwd: str | None = None,
) -> dict:
"""Get working tree status.
Returns lists of staged, unstaged, and untracked files.
"""
work_dir = cwd or self.cwd
# Get status in porcelain format for parsing
stdout, _, _ = await self._run_git(
"status", "--porcelain=v2", "--branch",
cwd=work_dir,
)
branch = None
staged = []
unstaged = []
untracked = []
for line in stdout.splitlines():
if line.startswith("# branch.head"):
branch = line.split()[-1]
elif line.startswith("1 ") or line.startswith("2 "):
# Changed entry
parts = line.split()
xy = parts[1] # XY status
path = parts[-1]
if xy[0] != ".":
staged.append({"path": path, "status": xy[0]})
if xy[1] != ".":
unstaged.append({"path": path, "status": xy[1]})
elif line.startswith("? "):
# Untracked
path = line[2:]
untracked.append({"path": path})
return {
"branch": branch,
"staged": staged,
"unstaged": unstaged,
"untracked": untracked,
"clean": len(staged) == 0 and len(unstaged) == 0 and len(untracked) == 0,
}
@self.action("diff", "Show differences")
async def diff(
ctx: MCPContext,
ref: str | None = None,
staged: bool = False,
path: str | None = None,
cwd: str | None = None,
) -> dict:
"""Show diff in unified patch format.
Args:
ref: Commit or ref to compare against
staged: Show staged changes only
path: Limit to specific path
cwd: Working directory
Returns:
Unified diff output
"""
work_dir = cwd or self.cwd
args = ["diff", "--no-color"]
if staged:
args.append("--cached")
if ref:
args.append(ref)
if path:
args.extend(["--", path])
stdout, _, _ = await self._run_git(*args, cwd=work_dir)
return {
"diff": stdout,
"format": "unified",
"ref": ref,
"staged": staged,
}
@self.action("apply", "Apply patch")
async def apply(
ctx: MCPContext,
patch: str,
cwd: str | None = None,
check: bool = False,
) -> dict:
"""Apply a unified diff patch.
Args:
patch: Unified diff patch content
cwd: Working directory
check: Only check if patch applies cleanly
Returns:
Success status
"""
work_dir = cwd or self.cwd
args = ["apply"]
if check:
args.append("--check")
args.append("-") # Read from stdin
try:
proc = await asyncio.create_subprocess_exec(
"git", *args,
cwd=work_dir,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(
proc.communicate(patch.encode()),
timeout=30,
)
if proc.returncode != 0:
raise ToolError(
code="CONFLICT",
message=f"Patch failed: {stderr.decode()}",
)
return {
"applied": not check,
"clean": True,
}
except asyncio.TimeoutError:
raise ToolError(code="TIMEOUT", message="git apply timed out")
@self.action("commit", "Create commit")
async def commit(
ctx: MCPContext,
message: str,
files: list[str] | None = None,
all: bool = False,
cwd: str | None = None,
) -> dict:
"""Create a commit.
Args:
message: Commit message
files: Specific files to commit
all: Stage all modified files
cwd: Working directory
Returns:
Commit hash and info
"""
work_dir = cwd or self.cwd
# Stage files if specified
if files:
await self._run_git("add", *files, cwd=work_dir)
elif all:
await self._run_git("add", "-A", cwd=work_dir)
# Create commit
args = ["commit", "-m", message]
stdout, _, _ = await self._run_git(*args, cwd=work_dir)
# Get commit hash
hash_stdout, _, _ = await self._run_git(
"rev-parse", "HEAD",
cwd=work_dir,
)
return {
"hash": hash_stdout.strip(),
"message": message,
"output": stdout,
}
@self.action("branch", "Branch operations")
async def branch(
ctx: MCPContext,
op: str = "list", # list, create, delete
name: str | None = None,
cwd: str | None = None,
) -> dict:
"""Branch operations.
Args:
op: Operation - list, create, delete
name: Branch name (for create/delete)
cwd: Working directory
Returns:
Operation result
"""
work_dir = cwd or self.cwd
if op == "list":
stdout, _, _ = await self._run_git(
"branch", "-a", "--format=%(refname:short)",
cwd=work_dir,
)
branches = [b.strip() for b in stdout.splitlines() if b.strip()]
# Get current branch
current, _, _ = await self._run_git(
"branch", "--show-current",
cwd=work_dir,
)
return {
"branches": branches,
"current": current.strip(),
}
elif op == "create":
if not name:
raise InvalidParamsError("Branch name required", param="name")
await self._run_git("branch", name, cwd=work_dir)
return {"created": name}
elif op == "delete":
if not name:
raise InvalidParamsError("Branch name required", param="name")
await self._run_git("branch", "-d", name, cwd=work_dir)
return {"deleted": name}
else:
raise InvalidParamsError(
f"Unknown operation: {op}",
param="op",
expected="list, create, or delete",
)
@self.action("checkout", "Switch branches")
async def checkout(
ctx: MCPContext,
ref: str,
create: bool = False,
cwd: str | None = None,
) -> dict:
"""Switch to a branch or commit.
Args:
ref: Branch name or commit
create: Create branch if it doesn't exist
cwd: Working directory
Returns:
Checkout result
"""
work_dir = cwd or self.cwd
args = ["checkout"]
if create:
args.append("-b")
args.append(ref)
stdout, _, _ = await self._run_git(*args, cwd=work_dir)
return {
"ref": ref,
"created": create,
}
@self.action("log", "Commit history")
async def log(
ctx: MCPContext,
limit: int = 10,
path: str | None = None,
ref: str | None = None,
cwd: str | None = None,
) -> dict:
"""Get commit history.
Args:
limit: Number of commits
path: Filter by path
ref: Starting ref
cwd: Working directory
Returns:
List of commits
"""
work_dir = cwd or self.cwd
args = [
"log",
f"-{limit}",
"--format=format:%H|%an|%ae|%at|%s",
]
if ref:
args.append(ref)
if path:
args.extend(["--", path])
stdout, _, _ = await self._run_git(*args, cwd=work_dir)
commits = []
for line in stdout.splitlines():
if not line:
continue
parts = line.split("|", 4)
if len(parts) >= 5:
commits.append({
"hash": parts[0],
"author": parts[1],
"email": parts[2],
"timestamp": int(parts[3]),
"message": parts[4],
})
return {
"commits": commits,
"total": len(commits),
}
def register(self, mcp_server: FastMCP) -> None:
"""Register as 'vcs' 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
vcs_tool = VcsTool
+65
View File
@@ -0,0 +1,65 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "hanzo-tools-vcs"
version = "0.1.0"
description = "Unified version control tool for Hanzo AI (HIP-0300)"
readme = "README.md"
license = "MIT"
requires-python = ">=3.10"
authors = [
{ name = "Hanzo AI Team", email = "[email protected]" },
]
keywords = [
"hanzo",
"mcp",
"tools",
"vcs",
"git",
"version-control",
]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
dependencies = [
"hanzo-tools-core>=0.1.0",
"mcp>=1.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"ruff>=0.1.0",
]
[project.entry-points."hanzo.tools"]
vcs = "hanzo_tools.vcs:TOOLS"
[project.urls]
Homepage = "https://github.com/hanzoai/python-sdk"
Documentation = "https://docs.hanzo.ai"
Repository = "https://github.com/hanzoai/python-sdk"
[tool.hatch.build.targets.wheel]
packages = ["hanzo_tools"]
[tool.ruff]
line-length = 100
target-version = "py310"
[tool.ruff.lint]
select = ["E", "F", "I", "UP"]
ignore = ["E501"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
+1
View File
@@ -23,6 +23,7 @@ dependencies = [
"fastmcp>=2.14.1",
"pydantic>=2.12.5",
"typing-extensions>=4.13.0",
"aiofiles>=24.1.0",
]
[project.optional-dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "hanzo"
version = "0.3.47"
version = "0.3.48"
description = "Hanzo AI - Complete AI Infrastructure Platform with CLI, Router, MCP, and Agent Runtime"
authors = [
{name = "Hanzo AI", email = "[email protected]"},
+233 -1
View File
@@ -55,7 +55,7 @@ from .interactive.repl import HanzoREPL
from .interactive.enhanced_repl import EnhancedHanzoREPL
# Version
__version__ = "0.3.46"
__version__ = "0.3.48"
@click.group(invoke_without_command=True)
@@ -624,6 +624,238 @@ def dashboard(ctx):
run_dashboard()
@cli.command()
@click.option("--json", "json_output", is_flag=True, help="JSON output format")
@click.pass_context
def doctor(ctx, json_output: bool):
"""Show installed Hanzo tools, versions, and system info.
Checks all Hanzo CLI tools installed via uv, cargo, npm, or homebrew.
"""
import shutil
import platform
from rich.table import Table
from rich.panel import Panel
console = ctx.obj.get("console", Console())
if json_output:
import json as json_module
result = {"tools": [], "system": {}}
# System info
console.print(Panel.fit(
f"[bold cyan]Hanzo Doctor[/bold cyan]\n"
f"[dim]System: {platform.system()} {platform.machine()}[/dim]",
border_style="cyan"
))
console.print()
# Check uv tools
tools_found = []
console.print("[bold]Python Tools (uv):[/bold]")
try:
result_uv = subprocess.run(
["uv", "tool", "list"],
capture_output=True, text=True, timeout=10
)
if result_uv.returncode == 0:
table = Table(show_header=True, header_style="bold")
table.add_column("Package", style="cyan")
table.add_column("Version", style="green")
table.add_column("Path", style="dim")
for line in result_uv.stdout.strip().split('\n'):
if line.startswith('hanzo'):
parts = line.split()
if len(parts) >= 2:
name, version = parts[0], parts[1]
path = shutil.which(name) or f"~/.local/bin/{name}"
table.add_row(name, version, path)
tools_found.append({"name": name, "version": version, "path": path, "source": "uv"})
if tools_found:
console.print(table)
else:
console.print(" [dim](none installed)[/dim]")
else:
console.print(" [dim](uv not available)[/dim]")
except (subprocess.TimeoutExpired, FileNotFoundError):
console.print(" [dim](uv not installed)[/dim]")
console.print()
# Check for other AI CLI tools
console.print("[bold]AI CLI Tools:[/bold]")
ai_tools = [
("claude", "Claude Code"),
("gemini", "Gemini CLI"),
("codex", "OpenAI Codex"),
("cursor", "Cursor"),
("aider", "Aider"),
]
ai_table = Table(show_header=True, header_style="bold")
ai_table.add_column("Tool", style="cyan")
ai_table.add_column("Version", style="green")
ai_table.add_column("Path", style="dim")
ai_found = False
for cmd, name in ai_tools:
path = shutil.which(cmd)
if path:
ai_found = True
try:
ver_result = subprocess.run(
[cmd, "--version"],
capture_output=True, text=True, timeout=5
)
version = ver_result.stdout.strip().split('\n')[0] if ver_result.returncode == 0 else "?"
except:
version = "?"
ai_table.add_row(name, version, path)
if ai_found:
console.print(ai_table)
else:
console.print(" [dim](none detected)[/dim]")
console.print()
# Check API keys
console.print("[bold]API Keys:[/bold]")
api_keys = [
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
"GOOGLE_API_KEY",
"GEMINI_API_KEY",
"XAI_API_KEY",
"GROQ_API_KEY",
"GITHUB_TOKEN",
"HF_TOKEN",
]
keys_found = []
for key in api_keys:
if os.environ.get(key):
keys_found.append(key)
console.print(f" [green]✓[/green] {key}")
if not keys_found:
console.print(" [dim](none set)[/dim]")
console.print()
console.print("[bold green]✓[/bold green] Doctor check complete")
if json_output:
result["tools"] = tools_found
result["system"] = {
"platform": platform.system(),
"arch": platform.machine(),
"python": platform.python_version(),
}
print(json_module.dumps(result, indent=2))
@cli.command()
@click.option("--all", "upgrade_all", is_flag=True, help="Upgrade all Hanzo tools")
@click.option("--force", "-f", is_flag=True, help="Force reinstall")
@click.argument("packages", nargs=-1)
@click.pass_context
def update(ctx, upgrade_all: bool, force: bool, packages: tuple):
"""Update Hanzo CLI tools to latest versions.
\b
Examples:
hanzo update # Update hanzo and hanzo-mcp
hanzo update --all # Update all Hanzo tools
hanzo update hanzo-mcp # Update specific package
hanzo update --force # Force reinstall
"""
console = ctx.obj.get("console", Console())
# Default packages to update
default_packages = ["hanzo", "hanzo-mcp"]
all_packages = ["hanzo", "hanzo-mcp", "hanzo-agents", "hanzo-memory", "hanzo-network"]
if packages:
to_update = list(packages)
elif upgrade_all:
to_update = all_packages
else:
to_update = default_packages
console.print("[bold cyan]Updating Hanzo CLI tools...[/bold cyan]")
console.print()
# Check if uv is available
import shutil
if not shutil.which("uv"):
console.print("[red]Error:[/red] uv is not installed")
console.print("Install with: curl -LsSf https://astral.sh/uv/install.sh | sh")
return
success = []
failed = []
for pkg in to_update:
console.print(f" Updating [cyan]{pkg}[/cyan]...", end=" ")
try:
cmd = ["uv", "tool", "upgrade" if not force else "install", pkg]
if force:
cmd.append("--force")
result = subprocess.run(
cmd,
capture_output=True, text=True, timeout=120
)
if result.returncode == 0:
# Get new version
ver_result = subprocess.run(
["uv", "tool", "list"],
capture_output=True, text=True, timeout=10
)
version = "?"
for line in ver_result.stdout.split('\n'):
if line.startswith(pkg + ' '):
version = line.split()[1] if len(line.split()) > 1 else "?"
break
console.print(f"[green]✓[/green] {version}")
success.append(pkg)
else:
# Try install if upgrade failed (package not installed)
if "not installed" in result.stderr.lower():
install_result = subprocess.run(
["uv", "tool", "install", pkg],
capture_output=True, text=True, timeout=120
)
if install_result.returncode == 0:
console.print("[green]✓[/green] installed")
success.append(pkg)
else:
console.print(f"[red]✗[/red] {install_result.stderr.strip()}")
failed.append(pkg)
else:
console.print(f"[red]✗[/red] {result.stderr.strip()}")
failed.append(pkg)
except subprocess.TimeoutExpired:
console.print("[red]✗[/red] timeout")
failed.append(pkg)
except Exception as e:
console.print(f"[red]✗[/red] {e}")
failed.append(pkg)
console.print()
if success:
console.print(f"[bold green]✓[/bold green] Updated: {', '.join(success)}")
if failed:
console.print(f"[bold red]✗[/bold red] Failed: {', '.join(failed)}")
def main():
"""Main entry point."""
try:
+799
View File
@@ -811,3 +811,802 @@ def install_doctor():
else:
console.print(f"[yellow]![/yellow] {install_dir} is NOT in PATH")
console.print(f" Add to your shell config: export PATH=\"{install_dir}:$PATH\"")
# ============================================================================
# IDE / Browser / AI App Integration
# ============================================================================
# MCP config paths for various apps
MCP_CONFIG_PATHS = {
"claude": {
"macos": "~/Library/Application Support/Claude/claude_desktop_config.json",
"linux": "~/.config/claude/claude_desktop_config.json",
"windows": "%APPDATA%/Claude/claude_desktop_config.json",
},
"vscode": {
"macos": "~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
"linux": "~/.config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
"windows": "%APPDATA%/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
},
"cursor": {
"macos": "~/Library/Application Support/Cursor/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
"linux": "~/.config/Cursor/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
"windows": "%APPDATA%/Cursor/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
},
"antigravity": {
"macos": "~/.gemini/antigravity/mcp_config.json",
"linux": "~/.gemini/antigravity/mcp_config.json",
"windows": "%USERPROFILE%/.gemini/antigravity/mcp_config.json",
},
"copilot": {
"macos": "~/.copilot/mcp-config.json",
"linux": "~/.copilot/mcp-config.json",
"windows": "%USERPROFILE%/.copilot/mcp-config.json",
},
"jan": {
"macos": "~/Library/Application Support/Jan/data/mcp_config.json",
"linux": "~/.config/jan/data/mcp_config.json",
"windows": "%APPDATA%/Jan/data/mcp_config.json",
},
"trae": {
"macos": "~/Library/Application Support/Trae/User/mcp.json",
"linux": "~/.config/Trae/User/mcp.json",
"windows": "%APPDATA%/Trae/User/mcp.json",
},
"5ire": {
"macos": "~/Library/Application Support/5ire/mcp.json",
"linux": "~/.config/5ire/mcp.json",
"windows": "%APPDATA%/5ire/mcp.json",
},
}
def get_mcp_config_path(app: str) -> Optional[Path]:
"""Get MCP config path for an app."""
if app not in MCP_CONFIG_PATHS:
return None
system = platform.system().lower()
os_key = {"darwin": "macos", "linux": "linux", "windows": "windows"}.get(system)
if not os_key:
return None
path_str = MCP_CONFIG_PATHS[app].get(os_key)
if not path_str:
return None
return Path(os.path.expandvars(os.path.expanduser(path_str)))
def get_mcp_server_config() -> dict:
"""Get hanzo-mcp server configuration for MCP config files."""
return {
"command": "uvx",
"args": ["--upgrade", "hanzo-mcp@latest"],
"env": {}
}
@install_group.command(name="ide")
@click.argument("target", required=False)
@click.option("--all", "install_all", is_flag=True, help="Install to all detected IDEs")
@click.option("--list", "list_only", is_flag=True, help="List available IDEs")
def install_ide(target: str, install_all: bool, list_only: bool):
"""Install Hanzo MCP to IDEs (VS Code, Cursor, Antigravity, etc.).
\b
Examples:
hanzo install ide # List detected IDEs
hanzo install ide claude # Install to Claude Desktop
hanzo install ide vscode # Install to VS Code
hanzo install ide --all # Install to all detected IDEs
\b
Supported IDEs:
claude - Claude Desktop
vscode - Visual Studio Code (via Cline extension)
cursor - Cursor IDE (via Cline extension)
antigravity - Antigravity IDE (VS Code based)
copilot - GitHub Copilot
jan - Jan AI
trae - Trae IDE
5ire - 5ire AI
"""
import json
if list_only or (not target and not install_all):
# List detected IDEs
console.print("[bold]Detected IDEs with MCP support:[/bold]")
console.print()
for app_name in MCP_CONFIG_PATHS:
config_path = get_mcp_config_path(app_name)
if config_path:
exists = config_path.exists()
parent_exists = config_path.parent.exists()
if exists:
# Check if hanzo-mcp already configured
try:
with open(config_path) as f:
config = json.load(f)
has_hanzo = "hanzo" in config.get("mcpServers", {}) or "hanzo-mcp" in config.get("mcpServers", {})
status = "[green]✓ hanzo-mcp configured[/green]" if has_hanzo else "[yellow]○ no hanzo-mcp[/yellow]"
except:
status = "[dim]○ config exists[/dim]"
console.print(f" [green]✓[/green] {app_name}: {status}")
elif parent_exists:
console.print(f" [yellow]○[/yellow] {app_name}: [dim]app installed, no MCP config[/dim]")
else:
console.print(f" [dim]○[/dim] {app_name}: [dim]not installed[/dim]")
console.print()
console.print("[dim]Run: hanzo install ide <name> to configure[/dim]")
return
targets = list(MCP_CONFIG_PATHS.keys()) if install_all else [target]
for app in targets:
if app not in MCP_CONFIG_PATHS:
console.print(f"[red]Unknown IDE: {app}[/red]")
console.print(f"Available: {', '.join(MCP_CONFIG_PATHS.keys())}")
continue
config_path = get_mcp_config_path(app)
if not config_path:
console.print(f"[yellow]![/yellow] {app}: not supported on this platform")
continue
console.print(f"[cyan]Configuring {app}...[/cyan]")
# Create parent directory if needed
config_path.parent.mkdir(parents=True, exist_ok=True)
# Load or create config
if config_path.exists():
try:
with open(config_path) as f:
config = json.load(f)
except json.JSONDecodeError:
config = {}
else:
config = {}
# Ensure mcpServers key exists
if "mcpServers" not in config:
config["mcpServers"] = {}
# Add hanzo-mcp server
config["mcpServers"]["hanzo"] = get_mcp_server_config()
# Write config
with open(config_path, "w") as f:
json.dump(config, f, indent=2)
console.print(f" [green]✓[/green] {app} configured: {config_path}")
console.print()
console.print("[bold green]✓[/bold green] MCP configuration complete")
console.print("[dim]Restart your IDE to load hanzo-mcp[/dim]")
@install_group.command(name="browser")
@click.argument("browser", required=False)
@click.option("--list", "list_only", is_flag=True, help="List browser extension install URLs")
def install_browser(browser: str, list_only: bool):
"""Install Hanzo browser extension.
\b
Examples:
hanzo install browser # List browser extension links
hanzo install browser chrome # Open Chrome Web Store
hanzo install browser firefox # Open Firefox Add-ons
\b
Supported browsers:
chrome - Google Chrome / Chromium
firefox - Mozilla Firefox
safari - Apple Safari
edge - Microsoft Edge
"""
import webbrowser
extension_urls = {
"chrome": "https://chrome.google.com/webstore/detail/hanzo-ai/placeholder",
"firefox": "https://addons.mozilla.org/en-US/firefox/addon/hanzo-ai/",
"safari": "https://apps.apple.com/app/hanzo-ai/placeholder",
"edge": "https://microsoftedge.microsoft.com/addons/detail/hanzo-ai/placeholder",
}
# Dev install instructions
dev_instructions = {
"chrome": "chrome://extensions → Enable Developer Mode → Load unpacked → Select extension folder",
"firefox": "about:debugging → This Firefox → Load Temporary Add-on → Select manifest.json",
"safari": "Safari → Preferences → Advanced → Enable Develop menu → Develop → Allow Unsigned Extensions",
"edge": "edge://extensions → Enable Developer Mode → Load unpacked → Select extension folder",
}
if list_only or not browser:
console.print("[bold]Hanzo Browser Extension[/bold]")
console.print()
# Check which browsers are available
browsers_found = []
# macOS browser detection
if platform.system() == "Darwin":
browser_paths = {
"chrome": "/Applications/Google Chrome.app",
"firefox": "/Applications/Firefox.app",
"safari": "/Applications/Safari.app",
"edge": "/Applications/Microsoft Edge.app",
}
for name, path in browser_paths.items():
if Path(path).exists():
browsers_found.append(name)
console.print("[cyan]Install from store (recommended):[/cyan]")
for name, url in extension_urls.items():
detected = " [green](detected)[/green]" if name in browsers_found else ""
console.print(f" {name}: {url}{detected}")
console.print()
console.print("[cyan]Developer install (for testing):[/cyan]")
for name, instruction in dev_instructions.items():
console.print(f" {name}:")
console.print(f" {instruction}")
console.print()
console.print("[dim]Extension source: ~/work/hanzo/extension[/dim]")
return
if browser.lower() not in extension_urls:
console.print(f"[red]Unknown browser: {browser}[/red]")
console.print(f"Available: {', '.join(extension_urls.keys())}")
return
url = extension_urls[browser.lower()]
console.print(f"[cyan]Opening {browser} extension page...[/cyan]")
webbrowser.open(url)
console.print(f"[green]✓[/green] Opened: {url}")
@install_group.command(name="ai")
@click.argument("app", required=False)
@click.option("--list", "list_only", is_flag=True, help="List AI apps")
def install_ai(app: str, list_only: bool):
"""Configure Hanzo MCP for AI apps (Claude Desktop, Jan, etc.).
This is an alias for 'hanzo install ide' focused on AI applications.
\b
Examples:
hanzo install ai # List AI apps
hanzo install ai claude # Configure Claude Desktop
hanzo install ai jan # Configure Jan AI
"""
# Delegate to install_ide
from click import Context
ctx = Context(install_ide)
ctx.invoke(install_ide, target=app, install_all=False, list_only=list_only or not app)
@install_group.command(name="all")
@click.option("--force", "-f", is_flag=True, help="Force reinstall")
def install_all_cmd(force: bool):
"""Install everything: tools + IDE integrations + browser extension info.
\b
This command:
1. Installs all Python tools (cli, mcp, agents)
2. Configures all detected IDEs with hanzo-mcp
3. Shows browser extension installation instructions
"""
console.print(Panel.fit(
"[bold cyan]Hanzo Full Installation[/bold cyan]\n"
"[dim]Installing tools, IDE integrations, and browser extension[/dim]",
border_style="cyan"
))
console.print()
# 1. Install Python tools
console.print("[bold]1. Installing CLI Tools[/bold]")
from click import Context
for tool_id in ["cli", "mcp", "agents"]:
_install_single_tool(tool_id, None, False, force)
console.print()
# 2. Configure all detected IDEs
console.print("[bold]2. Configuring IDE Integrations[/bold]")
import json
configured = []
for app_name in MCP_CONFIG_PATHS:
config_path = get_mcp_config_path(app_name)
if config_path and config_path.parent.exists():
# Create or update config
config_path.parent.mkdir(parents=True, exist_ok=True)
if config_path.exists():
try:
with open(config_path) as f:
config = json.load(f)
except:
config = {}
else:
config = {}
if "mcpServers" not in config:
config["mcpServers"] = {}
config["mcpServers"]["hanzo"] = get_mcp_server_config()
with open(config_path, "w") as f:
json.dump(config, f, indent=2)
configured.append(app_name)
console.print(f" [green]✓[/green] {app_name}")
if not configured:
console.print(" [dim](no IDEs detected)[/dim]")
console.print()
# 3. Browser extension info
console.print("[bold]3. Browser Extension[/bold]")
console.print(" Install from: https://chrome.google.com/webstore/detail/hanzo-ai/")
console.print(" Or load unpacked from: ~/work/hanzo/extension")
console.print()
console.print("[bold green]✓[/bold green] Full installation complete!")
console.print()
console.print("[dim]Next steps:[/dim]")
console.print(" 1. Restart your IDEs to load hanzo-mcp")
console.print(" 2. Install browser extension (optional)")
console.print(" 3. Run: hanzo doctor # to verify installation")
# ============================================================================
# Nanobrowser - Lightweight Agent Browser
# ============================================================================
NANOBROWSER_PATH = Path.home() / "work" / "nanobrowser" / "nanobrowser"
@install_group.command(name="nanobrowser")
@click.option("--build", "-b", is_flag=True, help="Build from source")
@click.option("--dev", is_flag=True, help="Run in development mode")
@click.option("--open", "open_browser", is_flag=True, help="Open Chrome with extension loaded")
def install_nanobrowser(build: bool, dev: bool, open_browser: bool):
"""Install/build Nanobrowser - lightweight AI browser for agents.
\b
Nanobrowser is a Chrome extension for AI web automation.
It provides a multi-agent system (Planner + Navigator) that
can browse the web autonomously using LLMs.
\b
Examples:
hanzo install nanobrowser # Check status
hanzo install nanobrowser --build # Build from source
hanzo install nanobrowser --dev # Run dev server
hanzo install nanobrowser --open # Load in Chrome
\b
LLM Support:
- OpenAI (GPT-4, GPT-4o)
- Anthropic (Claude Sonnet, Haiku)
- Google (Gemini)
- Ollama (local models)
- Groq, Cerebras, Llama
"""
nanobrowser_dir = NANOBROWSER_PATH
if not nanobrowser_dir.exists():
console.print("[red]Nanobrowser not found[/red]")
console.print(f"Expected at: {nanobrowser_dir}")
console.print()
console.print("Clone it with:")
console.print(f" git clone https://github.com/nanobrowser/nanobrowser {nanobrowser_dir}")
return
dist_dir = nanobrowser_dir / "dist"
if build or dev:
console.print("[cyan]Building Nanobrowser...[/cyan]")
# Install dependencies
console.print(" Installing dependencies...")
result = subprocess.run(
["pnpm", "install"],
cwd=str(nanobrowser_dir),
capture_output=True,
text=True
)
if result.returncode != 0:
console.print(f"[red]Failed to install dependencies: {result.stderr}[/red]")
return
if dev:
console.print(" Starting dev server...")
console.print("[dim]Press Ctrl+C to stop[/dim]")
subprocess.run(["pnpm", "dev"], cwd=str(nanobrowser_dir))
else:
console.print(" Building extension...")
result = subprocess.run(
["pnpm", "build"],
cwd=str(nanobrowser_dir),
capture_output=True,
text=True
)
if result.returncode != 0:
console.print(f"[red]Build failed: {result.stderr}[/red]")
return
console.print(f"[green]✓[/green] Built to: {dist_dir}")
elif open_browser:
if not dist_dir.exists():
console.print("[yellow]Extension not built yet. Run with --build first.[/yellow]")
return
# Open Chrome with extension loaded (macOS)
if platform.system() == "Darwin":
chrome_path = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
if not Path(chrome_path).exists():
console.print("[red]Chrome not found at default location[/red]")
return
console.print("[cyan]Opening Chrome with Nanobrowser extension...[/cyan]")
subprocess.Popen([
chrome_path,
f"--load-extension={dist_dir}",
"--new-window",
])
console.print("[green]✓[/green] Chrome launched with Nanobrowser")
else:
console.print("[yellow]Auto-open not supported on this platform[/yellow]")
console.print(f"Manually load the extension from: {dist_dir}")
else:
# Status check
console.print("[bold]Nanobrowser Status[/bold]")
console.print(f" Location: {nanobrowser_dir}")
console.print(f" Built: {'[green]✓[/green]' if dist_dir.exists() else '[yellow]○[/yellow] (run --build)'}")
# Get version from package.json
package_json = nanobrowser_dir / "package.json"
if package_json.exists():
import json
with open(package_json) as f:
pkg = json.load(f)
console.print(f" Version: {pkg.get('version', 'unknown')}")
console.print()
console.print("[cyan]Installation Steps:[/cyan]")
console.print(" 1. Build: hanzo install nanobrowser --build")
console.print(" 2. Open Chrome: chrome://extensions")
console.print(" 3. Enable 'Developer mode'")
console.print(" 4. Click 'Load unpacked'")
console.print(f" 5. Select: {dist_dir}")
console.print()
console.print("[cyan]Or auto-load:[/cyan]")
console.print(" hanzo install nanobrowser --open")
# ============================================================================
# Runtime - Sandboxed Agent Execution Environment
# ============================================================================
RUNTIME_PATH = Path.home() / "work" / "hanzo" / "runtime"
@install_group.command(name="runtime")
@click.option("--setup", "-s", is_flag=True, help="Set up the runtime environment")
@click.option("--sdk", type=click.Choice(["python", "typescript", "both"]), help="Install SDK")
@click.option("--computer-use", is_flag=True, help="Set up VNC desktop for computer use")
def install_runtime(setup: bool, sdk: str, computer_use: bool):
"""Configure Hanzo Runtime - sandboxed agent execution.
\b
The Hanzo Runtime provides:
- Sub-90ms sandbox creation
- Isolated code execution for AI-generated code
- Computer Use (VNC desktop control)
- File, Git, LSP, and Execute APIs
- OCI/Docker compatibility
\b
Examples:
hanzo install runtime # Check status
hanzo install runtime --setup # Initialize runtime
hanzo install runtime --sdk python # Install Python SDK
hanzo install runtime --computer-use # Set up desktop control
\b
SDKs:
pip install hanzo-runtime # Python
npm install @hanzo/runtime # TypeScript
"""
runtime_dir = RUNTIME_PATH
if sdk:
console.print(f"[cyan]Installing Runtime SDK ({sdk})...[/cyan]")
if sdk in ("python", "both"):
if shutil.which("uv"):
subprocess.run(["uv", "pip", "install", "hanzo-runtime"], check=True)
else:
subprocess.run([sys.executable, "-m", "pip", "install", "hanzo-runtime"], check=True)
console.print("[green]✓[/green] Python SDK installed")
if sdk in ("typescript", "both"):
subprocess.run(["npm", "install", "-g", "@hanzo/runtime"], check=True)
console.print("[green]✓[/green] TypeScript SDK installed")
return
if computer_use:
console.print("[cyan]Setting up Computer Use environment...[/cyan]")
console.print()
# Check Docker
if not shutil.which("docker"):
console.print("[red]Docker is required for Computer Use[/red]")
console.print("Install: https://docs.docker.com/get-docker/")
return
# Show Dockerfile info
dockerfile_path = runtime_dir / "hack" / "computer-use" / "Dockerfile"
if dockerfile_path.exists():
console.print("[green]✓[/green] Computer Use Dockerfile found")
console.print(f" Path: {dockerfile_path}")
else:
console.print("[yellow]![/yellow] Dockerfile not found")
console.print()
console.print("[cyan]Computer Use provides:[/cyan]")
console.print(" - Xvfb (virtual display)")
console.print(" - XFCE4 desktop environment")
console.print(" - x11vnc (VNC server on port 5901)")
console.print(" - noVNC (web access on port 6901)")
console.print(" - xdotool, xautomation (mouse/keyboard)")
console.print(" - Chromium browser")
console.print()
console.print("[cyan]Build and run:[/cyan]")
console.print(f" cd {runtime_dir / 'hack' / 'computer-use'}")
console.print(" docker build -t hanzo-computer-use .")
console.print(" docker run -p 5901:5901 -p 6901:6901 hanzo-computer-use")
console.print()
console.print("Then access: http://localhost:6901 (noVNC web client)")
return
if setup:
console.print("[cyan]Setting up Hanzo Runtime...[/cyan]")
if not runtime_dir.exists():
console.print(f"[red]Runtime not found at: {runtime_dir}[/red]")
return
# Build Go binaries
console.print(" Building CLI...")
cli_dir = runtime_dir / "apps" / "cli"
if cli_dir.exists():
result = subprocess.run(
["go", "build", "-o", str(Path.home() / ".hanzo" / "bin" / "hanzo-runtime"), "."],
cwd=str(cli_dir),
capture_output=True,
text=True
)
if result.returncode == 0:
console.print("[green]✓[/green] CLI built")
else:
console.print(f"[yellow]![/yellow] CLI build failed: {result.stderr}")
return
# Status check
console.print("[bold]Hanzo Runtime Status[/bold]")
if runtime_dir.exists():
console.print(f" [green]✓[/green] Source: {runtime_dir}")
# Check components
components = {
"CLI": runtime_dir / "apps" / "cli",
"Daemon": runtime_dir / "apps" / "daemon",
"Runner": runtime_dir / "apps" / "runner",
"Python SDK": runtime_dir / "libs" / "sdk-python",
"TypeScript SDK": runtime_dir / "libs" / "sdk-typescript",
"Computer Use": runtime_dir / "libs" / "computer-use",
}
console.print()
console.print("[cyan]Components:[/cyan]")
for name, path in components.items():
status = "[green]✓[/green]" if path.exists() else "[dim]○[/dim]"
console.print(f" {status} {name}")
# Check SDKs installed
console.print()
console.print("[cyan]Installed SDKs:[/cyan]")
# Python SDK
try:
result = subprocess.run(
[sys.executable, "-c", "import hanzo_runtime; print(hanzo_runtime.__version__)"],
capture_output=True,
text=True
)
if result.returncode == 0:
console.print(f" [green]✓[/green] Python: {result.stdout.strip()}")
else:
console.print(" [dim]○[/dim] Python: not installed")
except:
console.print(" [dim]○[/dim] Python: not installed")
# TypeScript SDK
result = subprocess.run(
["npm", "list", "-g", "@hanzo/runtime"],
capture_output=True,
text=True
)
if "@hanzo/runtime" in result.stdout:
console.print(f" [green]✓[/green] TypeScript: installed")
else:
console.print(" [dim]○[/dim] TypeScript: not installed")
else:
console.print(f" [red]○[/red] Not found: {runtime_dir}")
console.print()
console.print("[dim]Install SDKs:[/dim]")
console.print(" pip install hanzo-runtime")
console.print(" npm install @hanzo/runtime")
# ============================================================================
# Cas Infrastructure (Casdoor, Casibase, Casvisor)
# ============================================================================
CAS_PATH = Path.home() / "work" / "cas"
@install_group.command(name="cas")
@click.argument("component", required=False)
@click.option("--setup", "-s", is_flag=True, help="Set up the component")
@click.option("--start", is_flag=True, help="Start the service")
def install_cas(component: str, setup: bool, start: bool):
"""Configure Cas infrastructure (auth, AI platform, VM management).
\b
Components:
casdoor - Authentication/IAM system (Single Sign-On)
casibase - AI Cloud OS with MCP/A2A support
casvisor - Cloud operating system (VM/machine management)
\b
Examples:
hanzo install cas # Check status
hanzo install cas casdoor --setup # Set up Casdoor
hanzo install cas casibase --start # Start Casibase
\b
Casibase Features:
- AI knowledge base management
- MCP (Model Context Protocol) server
- A2A (Agent-to-Agent) management
- Supports ChatGPT, Claude, Llama, Ollama, etc.
"""
cas_dir = CAS_PATH
components_info = {
"casdoor": {
"name": "Casdoor",
"description": "Authentication/IAM - Single Sign-On",
"path": cas_dir / "casdoor",
"port": 8000,
"docs": "https://casdoor.org",
},
"casibase": {
"name": "Casibase",
"description": "AI Cloud OS - Knowledge base + MCP/A2A",
"path": cas_dir / "casibase",
"port": 14000,
"docs": "https://casibase.org",
},
"casvisor": {
"name": "Casvisor",
"description": "Cloud OS - VM/Machine management",
"path": cas_dir / "casvisor",
"port": 16001,
"docs": "https://casvisor.org",
},
}
if component:
if component not in components_info:
console.print(f"[red]Unknown component: {component}[/red]")
console.print(f"Available: {', '.join(components_info.keys())}")
return
info = components_info[component]
comp_path = info["path"]
if not comp_path.exists():
console.print(f"[red]{info['name']} not found at: {comp_path}[/red]")
console.print(f"Clone: git clone https://github.com/{component}/{component} {comp_path}")
return
if setup:
console.print(f"[cyan]Setting up {info['name']}...[/cyan]")
console.print()
# Check for Go
if not shutil.which("go"):
console.print("[red]Go is required[/red]")
console.print("Install: https://go.dev/dl/")
return
# Build
console.print(" Building...")
result = subprocess.run(
["go", "build", "."],
cwd=str(comp_path),
capture_output=True,
text=True
)
if result.returncode == 0:
console.print(f"[green]✓[/green] {info['name']} built")
else:
console.print(f"[red]Build failed: {result.stderr}[/red]")
return
# Frontend
web_dir = comp_path / "web"
if web_dir.exists():
console.print(" Building frontend...")
subprocess.run(["yarn", "install"], cwd=str(web_dir), check=False)
subprocess.run(["yarn", "build"], cwd=str(web_dir), check=False)
console.print()
console.print(f"[green]✓[/green] {info['name']} setup complete")
console.print(f"Run: cd {comp_path} && ./{component}")
elif start:
console.print(f"[cyan]Starting {info['name']}...[/cyan]")
binary = comp_path / component
if not binary.exists():
console.print(f"[yellow]Not built. Run with --setup first.[/yellow]")
return
subprocess.Popen([str(binary)], cwd=str(comp_path))
console.print(f"[green]✓[/green] Started on port {info['port']}")
console.print(f" Open: http://localhost:{info['port']}")
else:
console.print(f"[bold]{info['name']}[/bold]")
console.print(f" Description: {info['description']}")
console.print(f" Path: {comp_path}")
console.print(f" Port: {info['port']}")
console.print(f" Docs: {info['docs']}")
else:
# Status check
console.print("[bold]Cas Infrastructure[/bold]")
console.print()
for comp_id, info in components_info.items():
path = info["path"]
exists = path.exists()
binary = path / comp_id if exists else None
built = binary and binary.exists() if binary else False
status = "[green]✓[/green]" if exists else "[dim]○[/dim]"
build_status = " [green](built)[/green]" if built else " [yellow](not built)[/yellow]" if exists else ""
console.print(f" {status} {info['name']}: {info['description']}{build_status}")
console.print()
console.print("[dim]Set up a component: hanzo install cas <component> --setup[/dim]")