mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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,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
@@ -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:
|
||||
|
||||
@@ -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]")
|
||||
|
||||
Reference in New Issue
Block a user