feat: hanzo-dev Python TUI + LSP + hooks + sandbox + vim + git + multi-auth + full parity

Rename hanzo-repl → hanzo-dev to match @hanzo/dev (Rust/TS).

New packages:
- hanzo-lsp: async LSP client (diagnostics, go-to-def, references, context enrichment)
- hanzo-hooks: pre/post tool use hooks (shell scripts, exit-code deny)
- hanzo-sandbox: container detection, filesystem isolation, Linux unshare

hanzo-dev TUI:
- Vim keybindings (Normal/Insert/Visual/Command modes, 37 tests)
- Git slash commands (/branch /commit /worktree /diff /stash, 13 tests)
- All slash commands at parity: /model /permissions /clear /resume /memory
  /init /export /session /plan /solve /code /auto /fast /remote-control
- Multi-provider auth: Anthropic OAuth, OpenAI device code, Hanzo PKCE, auto-detect

Auth:
- login_with_anthropic() — PKCE via console.hanzo.ai
- login_with_openai() — device code flow via auth.openai.com
- login_auto() — env var detection + saved credential fallback
- OAuthCredentialStore supports provider keys (hanzo/anthropic/openai)

Parity test suite (38 tests): SSE, MCP normalization, permissions, compaction,
token usage, PKCE RFC vector, config, backoff, session roundtrip, hooks.

348 tests total, 0 failures across Python + Rust + Universe E2E.
This commit is contained in:
2026-04-01 10:41:46 -07:00
parent ffe73447a3
commit ebb04882d6
60 changed files with 4578 additions and 462 deletions
+16 -16
View File
@@ -11,7 +11,7 @@ on:
# hanzo-agent is now a git submodule - not tested here
- 'pkg/hanzo-memory/**'
- 'pkg/hanzo-aci/**'
- 'pkg/hanzo-repl/**'
- 'pkg/hanzo-dev/**'
- 'pkg/hanzoai/**'
- '.github/workflows/hanzo-packages-ci.yml'
tags:
@@ -22,7 +22,7 @@ on:
# hanzo-agents is now a git submodule
- 'hanzo-memory-*'
- 'hanzo-aci-*'
- 'hanzo-repl-*'
- 'hanzo-dev-*'
pull_request:
branches:
- main
@@ -33,7 +33,7 @@ on:
# hanzo-agent is now a git submodule - not tested here
- 'pkg/hanzo-memory/**'
- 'pkg/hanzo-aci/**'
- 'pkg/hanzo-repl/**'
- 'pkg/hanzo-dev/**'
- 'pkg/hanzoai/**'
- '.github/workflows/hanzo-packages-ci.yml'
@@ -152,7 +152,7 @@ jobs:
integration-test:
name: Integration Test
needs: [test-hanzo-network, test-hanzo-mcp, test-hanzo-aci, test-hanzo-memory, test-hanzo, test-hanzo-repl]
needs: [test-hanzo-network, test-hanzo-mcp, test-hanzo-aci, test-hanzo-memory, test-hanzo, test-hanzo-dev]
runs-on: hanzo-build-linux-amd64
steps:
@@ -174,7 +174,7 @@ jobs:
pip install hanzo-agents || true
cd pkg/hanzo-memory && pip install -e . && cd ../..
cd pkg/hanzo-aci && pip install -e . && cd ../..
cd pkg/hanzo-repl && pip install -e . && cd ../..
cd pkg/hanzo-dev && pip install -e . && cd ../..
- name: Run integration tests
run: |
@@ -229,9 +229,9 @@ jobs:
ruff check . || true
mypy . --ignore-missing-imports || true
- name: Lint hanzo-repl
- name: Lint hanzo-dev
run: |
cd pkg/hanzo-repl
cd pkg/hanzo-dev
ruff check . || true
mypy . --ignore-missing-imports || true
@@ -265,13 +265,13 @@ jobs:
echo "No tests directory found, skipping tests"
fi
test-hanzo-repl:
name: Test hanzo-repl
test-hanzo-dev:
name: Test hanzo-dev
runs-on: hanzo-build-linux-amd64
timeout-minutes: 5
defaults:
run:
working-directory: pkg/hanzo-repl
working-directory: pkg/hanzo-dev
steps:
- uses: actions/checkout@v4
@@ -295,14 +295,14 @@ jobs:
timeout 120 python -m pytest tests/ -v --tb=short -n 4 --maxfail=5 || true
else
echo "No tests directory found, checking for test module"
python -c "from hanzo_repl import tests; print('Test module found')" || echo "No test module"
python -c "from hanzo_dev import tests; print('Test module found')" || echo "No test module"
fi
# Auto-publish packages with new versions on push to main
auto-publish-new-versions:
name: Auto-Publish New Versions
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
needs: [test-hanzo-network, test-hanzo-mcp, test-hanzo-aci, test-hanzo-memory, test-hanzo, test-hanzo-repl, integration-test]
needs: [test-hanzo-network, test-hanzo-mcp, test-hanzo-aci, test-hanzo-memory, test-hanzo, test-hanzo-dev, integration-test]
runs-on: hanzo-build-linux-amd64
steps:
@@ -325,7 +325,7 @@ jobs:
publish-to-pypi:
name: Publish to PyPI (Tag)
if: startsWith(github.ref, 'refs/tags/')
needs: [test-hanzo-network, test-hanzo-mcp, test-hanzo-aci, test-hanzo-memory, test-hanzo, test-hanzo-repl, integration-test]
needs: [test-hanzo-network, test-hanzo-mcp, test-hanzo-aci, test-hanzo-memory, test-hanzo, test-hanzo-dev, integration-test]
runs-on: hanzo-build-linux-amd64
steps:
@@ -363,13 +363,13 @@ jobs:
PACKAGES="hanzo-memory"
elif [[ $TAG == hanzo-aci-* ]]; then
PACKAGES="hanzo-aci"
elif [[ $TAG == hanzo-repl-* ]]; then
PACKAGES="hanzo-repl"
elif [[ $TAG == hanzo-dev-* ]]; then
PACKAGES="hanzo-dev"
elif [[ $TAG == hanzo-* ]]; then
PACKAGES="hanzo"
elif [[ $TAG == v* ]]; then
# For general version tags, publish all packages (except hanzo-agents which is a submodule)
PACKAGES="hanzo hanzo-network hanzo-mcp hanzo-memory hanzo-aci hanzo-repl"
PACKAGES="hanzo hanzo-network hanzo-mcp hanzo-memory hanzo-aci hanzo-dev"
fi
echo "PACKAGES=$PACKAGES" >> $GITHUB_OUTPUT
+3 -3
View File
@@ -62,7 +62,7 @@ jobs:
TOOLS_PACKAGES="hanzo-tools-core hanzo-tools-fs hanzo-tools-shell hanzo-tools-browser hanzo-tools-memory hanzo-tools-todo hanzo-tools-reasoning hanzo-tools-lsp hanzo-tools-refactor hanzo-tools-database hanzo-tools-agent hanzo-tools-jupyter hanzo-tools-editor hanzo-tools-llm hanzo-tools-vector hanzo-tools-config hanzo-tools-mcp hanzo-tools-computer hanzo-tools"
# Main packages
MAIN_PACKAGES="hanzoai hanzo hanzo-cli hanzo-kms hanzo-iam hanzo-consensus hanzo-network hanzo-mcp hanzo-memory hanzo-aci hanzo-repl"
MAIN_PACKAGES="hanzoai hanzo hanzo-cli hanzo-kms hanzo-iam hanzo-consensus hanzo-network hanzo-mcp hanzo-memory hanzo-aci hanzo-dev"
# Handle manual workflow dispatch
if [ -n "$MANUAL_INPUT" ]; then
@@ -127,8 +127,8 @@ jobs:
PACKAGES="hanzo-memory"
elif [[ $TAG == hanzo-aci-* ]]; then
PACKAGES="hanzo-aci"
elif [[ $TAG == hanzo-repl-* ]]; then
PACKAGES="hanzo-repl"
elif [[ $TAG == hanzo-dev-* ]]; then
PACKAGES="hanzo-dev"
elif [[ $TAG == hanzoai-* ]]; then
PACKAGES="hanzoai"
elif [[ $TAG == hanzo-* ]]; then
+1 -1
View File
@@ -52,7 +52,7 @@ jobs:
packages = [
'hanzo', 'hanzo-network', 'hanzo-mcp',
# hanzo-agents is now a git submodule
'hanzo-memory', 'hanzo-aci', 'hanzo-repl'
'hanzo-memory', 'hanzo-aci', 'hanzo-dev'
]
print('📦 Package Version Status:')
+6 -6
View File
@@ -96,7 +96,7 @@ deps: ## Install all dependencies for all packages
@source .venv/bin/activate && uv pip install -e ./pkg/hanzo-agents
@source .venv/bin/activate && uv pip install -e ./pkg/hanzo-mcp
@source .venv/bin/activate && uv pip install -e ./pkg/hanzo
@source .venv/bin/activate && uv pip install -e ./pkg/hanzo-repl
@source .venv/bin/activate && uv pip install -e ./pkg/hanzo-dev
@source .venv/bin/activate && uv pip install -e ./pkg/hanzo-memory
@source .venv/bin/activate && uv pip install -e ./pkg/hanzo-network
@@ -135,7 +135,7 @@ build: ## Build all packages
@cd pkg/hanzo-agents && uv build
@cd pkg/hanzo-mcp && uv build
@cd pkg/hanzo && uv build
@cd pkg/hanzo-repl && uv build
@cd pkg/hanzo-dev && uv build
@cd pkg/hanzo-memory && uv build
@cd pkg/hanzo-network && uv build
@cd pkg/hanzo-aci && uv build
@@ -169,7 +169,7 @@ publish-all: build ## Publish all packages to PyPI
$(MAKE) publish-hanzo-agents; \
$(MAKE) publish-hanzo-mcp; \
$(MAKE) publish-hanzo; \
$(MAKE) publish-hanzo-repl; \
$(MAKE) publish-hanzo-dev; \
$(MAKE) publish-hanzo-memory; \
$(MAKE) publish-hanzo-network; \
$(MAKE) publish-hanzo-aci; \
@@ -194,9 +194,9 @@ publish-hanzo: ## Publish hanzo package
@echo -e "$(CYAN)Publishing hanzo...$(NC)"
@cd pkg/hanzo && TWINE_USERNAME=__token__ TWINE_PASSWORD=$${PYPI_TOKEN} twine upload dist/* --skip-existing
publish-hanzo-repl: ## Publish hanzo-repl package
@echo -e "$(CYAN)Publishing hanzo-repl...$(NC)"
@cd pkg/hanzo-repl && TWINE_USERNAME=__token__ TWINE_PASSWORD=$${PYPI_TOKEN} twine upload dist/* --skip-existing
publish-hanzo-dev: ## Publish hanzo-dev package
@echo -e "$(CYAN)Publishing hanzo-dev...$(NC)"
@cd pkg/hanzo-dev && TWINE_USERNAME=__token__ TWINE_PASSWORD=$${PYPI_TOKEN} twine upload dist/* --skip-existing
publish-hanzo-memory: ## Publish hanzo-memory package
@echo -e "$(CYAN)Publishing hanzo-memory...$(NC)"
+1 -1
View File
@@ -187,7 +187,7 @@ def main():
"hanzo-agents",
"hanzo-aci",
"hanzo-mcp",
"hanzo-repl",
"hanzo-dev",
"hanzo",
]
+1 -1
View File
@@ -41,7 +41,7 @@ PACKAGES=(
"hanzo-agents"
"hanzo-aci"
"hanzo-mcp"
"hanzo-repl"
"hanzo-dev"
"hanzo"
)
+1 -1
View File
@@ -3,7 +3,7 @@ pretty = True
show_error_codes = True
# Exclude non-SDK code and subpackages (they have their own CI/linting)
exclude = ^(pkg/hanzoai/_files\.py|_dev/.*\.py|tests/.*|bin/.*|examples/.*|scripts/.*|pkg/hanzo/|pkg/hanzo-aci/|pkg/hanzo-agent/|pkg/hanzo-mcp/|pkg/hanzo-memory/|pkg/hanzo-network/|pkg/hanzo-repl/)
exclude = ^(pkg/hanzoai/_files\.py|_dev/.*\.py|tests/.*|bin/.*|examples/.*|scripts/.*|pkg/hanzo/|pkg/hanzo-aci/|pkg/hanzo-agent/|pkg/hanzo-mcp/|pkg/hanzo-memory/|pkg/hanzo-network/|pkg/hanzo-dev-py/)
strict_equality = True
implicit_reexport = True
@@ -1,6 +1,6 @@
# Hanzo REPL
# Hanzo Dev
Interactive REPL for Hanzo AI - Like Claude Code in your terminal.
Interactive dev environment for Hanzo AI - Like Claude Code in your terminal.
## Features
@@ -15,16 +15,16 @@ Interactive REPL for Hanzo AI - Like Claude Code in your terminal.
```bash
# Install
pip install hanzo-repl
pip install hanzo-dev
# Start interactive REPL (recommended)
hanzo-repl
# Start interactive dev environment (recommended)
hanzo-dev
# Start IPython REPL (advanced)
hanzo-repl-ipython
# Start IPython mode (advanced)
hanzo-dev-ipython
# Start TUI mode (beautiful interface)
hanzo-repl-tui
hanzo-dev-tui
```
## Usage
@@ -105,7 +105,7 @@ export HANZO_API_KEY=your-key # For Hanzo AI
Install voice dependencies:
```bash
pip install hanzo-repl[voice]
pip install hanzo-dev[voice]
```
Enable in REPL:
@@ -134,7 +134,7 @@ def my_tool(param: str) -> str:
Use the REPL in scripts:
```python
from hanzo_repl import create_repl
from hanzo_dev import create_repl
async def main():
repl = create_repl()
@@ -1,7 +1,7 @@
[project]
name = "hanzo-repl"
name = "hanzo-dev"
version = "0.1.0"
description = "Interactive REPL for Hanzo AI - Like Claude Code in your terminal"
description = "Interactive dev environment for Hanzo AI - Like Claude Code in your terminal"
authors = [
{name = "Hanzo AI", email = "[email protected]"},
]
@@ -22,10 +22,11 @@ classifiers = [
"Topic :: Software Development :: Testing",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
]
keywords = ["repl", "mcp", "ai", "llm", "hanzo", "claude", "interactive"]
keywords = ["dev", "mcp", "ai", "llm", "hanzo", "claude", "interactive"]
requires-python = ">=3.12"
dependencies = [
"hanzoai>=2.2.0",
"hanzo-mcp>=0.1.0",
"rich>=13.0.0",
"prompt-toolkit>=3.0.0",
@@ -40,11 +41,14 @@ dependencies = [
[project.urls]
Homepage = "https://hanzo.ai"
Documentation = "https://docs.hanzo.ai/repl"
Documentation = "https://docs.hanzo.ai/dev"
Repository = "https://github.com/hanzoai/python-sdk"
Issues = "https://github.com/hanzoai/python-sdk/issues"
[project.optional-dependencies]
lsp = ["hanzo-lsp>=0.1.0"]
hooks = ["hanzo-hooks>=0.1.0"]
sandbox = ["hanzo-sandbox>=0.1.0"]
voice = [
"speechrecognition>=3.10.0",
"pyttsx3>=2.90",
@@ -63,9 +67,9 @@ dev = [
]
[project.scripts]
hanzo-repl = "hanzo_repl.cli:main"
hanzo-repl-ipython = "hanzo_repl.ipython_repl:main"
hanzo-repl-tui = "hanzo_repl.textual_repl:main"
hanzo-dev = "hanzo_dev.cli:main"
hanzo-dev-ipython = "hanzo_dev.ipython_repl:main"
hanzo-dev-tui = "hanzo_dev.textual_repl:main"
[build-system]
requires = ["hatchling"]
@@ -73,12 +77,12 @@ build-backend = "hatchling.build"
[tool.hatch.build]
include = [
"src/hanzo_repl",
"src/hanzo_dev",
"README.md",
]
[tool.hatch.build.targets.wheel]
packages = ["src/hanzo_repl"]
packages = ["src/hanzo_dev"]
[tool.ruff]
line-length = 120
@@ -12,15 +12,28 @@ from .repl import HanzoREPL
@click.option(
"--mode",
default="ipython",
type=click.Choice(["basic", "ipython"]),
type=click.Choice(["basic", "ipython", "tui"]),
help="REPL mode to use",
)
@click.option("--debug", is_flag=True, help="Enable debug mode")
@click.option("--model", help="LLM model to use")
def main(mode, debug, model):
@click.option(
"--config",
"config_home",
default=None,
help="Path to config home (default: ~/.hanzo)",
)
def main(mode, debug, model, config_home):
"""Hanzo REPL - Interactive testing environment."""
import os
if mode == "ipython":
if config_home:
os.environ["HANZO_CONFIG_HOME"] = config_home
if mode == "tui":
from .textual_repl import main as tui_main
tui_main()
elif mode == "ipython":
# Use IPython-based REPL (recommended)
ipython_main()
else:
@@ -73,32 +73,43 @@ class CommandSuggestions(Vertical):
COMMANDS = [
SlashCommand("/add-dir", "Add a new working directory"),
SlashCommand("/auth", "Authenticate with Claude personal account"),
SlashCommand("/auto", "Execute prompt autonomously with tools"),
SlashCommand("/backend", "Switch AI backend (claude/openai/embedded)"),
SlashCommand("/bug", "Submit feedback about Hanzo REPL"),
SlashCommand("/clear", "Clear conversation history and free up context"),
SlashCommand(
"/compact", "Clear conversation history but keep a summary in context"
),
SlashCommand("/config", "Open config panel", ["theme"]),
SlashCommand(
"/cost", "Show the total cost and duration of the current session"
),
SlashCommand("/doctor", "Checks the health of your Hanzo installation"),
SlashCommand("/clear", "Clear session history and start fresh"),
SlashCommand("/code", "Implement with consensus from multiple review passes"),
SlashCommand("/compact", "Compact session keeping a summary in context"),
SlashCommand("/config", "Show loaded configuration files", ["theme"]),
SlashCommand("/cost", "Show total cost and duration of current session"),
SlashCommand("/doctor", "Check the health of your Hanzo installation"),
SlashCommand("/exit", "Exit the REPL", ["quit"]),
SlashCommand("/help", "Show help and available commands"),
SlashCommand("/logout", "Logout from Claude account"),
SlashCommand("/model", "Change the AI model"),
SlashCommand("/voice", "Enable voice mode for bidirectional communication"),
SlashCommand("/search", "Search for content using MCP tools"),
SlashCommand("/file", "Read or open a file"),
SlashCommand("/run", "Run a shell command"),
SlashCommand("/memorize", "Save a snippet for later recall"),
SlashCommand("/tools", "Show available MCP tools"),
SlashCommand("/history", "Show command history"),
SlashCommand("/export", "Export conversation to file"),
SlashCommand("/fast", "Toggle fast/standard mode"),
SlashCommand("/file", "Read or open a file"),
SlashCommand("/help", "Show help and available commands"),
SlashCommand("/history", "Show command history"),
SlashCommand("/import", "Import conversation from file"),
SlashCommand("/reset", "Reset the current session"),
SlashCommand("/status", "Show current backend and auth status"),
SlashCommand("/init", "Create starter CLAUDE.md in cwd"),
SlashCommand("/login", "Authenticate via PKCE flow"),
SlashCommand("/logout", "Logout from Claude account"),
SlashCommand("/loop", "Run prompt on interval (e.g. /loop 10 check status)"),
SlashCommand("/memorize", "Save a snippet for later recall"),
SlashCommand("/memory", "Show loaded instruction files"),
SlashCommand("/model", "Show or switch active AI model"),
SlashCommand("/permissions", "Show/switch permission mode"),
SlashCommand("/plan", "Planning agent - create a detailed plan"),
SlashCommand("/providers", "List available LLM providers"),
SlashCommand("/remote-control", "Start WebSocket server for remote control"),
SlashCommand("/reset", "Reset conversation context"),
SlashCommand("/resume", "Load saved session from JSON file"),
SlashCommand("/run", "Run a shell command"),
SlashCommand("/search", "Search for content using MCP tools"),
SlashCommand("/session", "List or switch saved sessions"),
SlashCommand("/solve", "Race multiple approaches, present best solution"),
SlashCommand("/status", "Model, session info, token usage"),
SlashCommand("/tools", "Show available MCP tools"),
SlashCommand("/version", "Show version info"),
SlashCommand("/voice", "Enable voice mode for bidirectional communication"),
]
selected_index = reactive(0)
+149
View File
@@ -0,0 +1,149 @@
"""Git slash commands for the Hanzo REPL."""
import subprocess
from pathlib import Path
from typing import Optional
def _run(args: list[str], cwd: Path) -> subprocess.CompletedProcess[str]:
return subprocess.run(args, cwd=cwd, capture_output=True, text=True)
def git_status(cwd: Path) -> str:
"""Return git status summary."""
r = _run(["git", "status", "--short", "--branch"], cwd)
if r.returncode != 0:
return r.stderr.strip()
lines = [l for l in r.stdout.strip().splitlines() if not l.startswith("##")]
if not lines:
return "Nothing to commit, working tree clean."
branch_line = r.stdout.strip().splitlines()[0] if r.stdout.strip() else ""
return (branch_line + "\n" + "\n".join(lines)).strip()
def git_branch(args: str, cwd: Path) -> str:
"""List branches or create a new branch."""
if not args.strip():
r = _run(["git", "branch", "-a"], cwd)
return r.stdout.strip() if r.returncode == 0 else r.stderr.strip()
branch_name = args.strip().split()[0]
r = _run(["git", "branch", branch_name], cwd)
if r.returncode != 0:
return r.stderr.strip()
return f"Created branch: {branch_name}"
def git_commit(message: str, cwd: Path) -> str:
"""Stage all changes and commit with the given message."""
# Stage everything
_run(["git", "add", "-A"], cwd)
# Check if anything is staged
check = _run(["git", "diff", "--cached", "--quiet"], cwd)
if check.returncode == 0:
return "Nothing to commit, no changes staged."
r = _run(["git", "commit", "-m", message], cwd)
if r.returncode != 0:
return r.stderr.strip()
return r.stdout.strip()
def git_commit_push_pr(message: str, cwd: Path) -> str:
"""Commit staged changes, push, and open a pull request via gh."""
# Commit
commit_out = git_commit(message, cwd)
if "nothing" in commit_out.lower():
return commit_out
# Get current branch
branch_r = _run(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd)
if branch_r.returncode != 0:
return f"Committed but failed to get branch: {branch_r.stderr.strip()}"
branch = branch_r.stdout.strip()
# Push
push_r = _run(["git", "push", "-u", "origin", branch], cwd)
if push_r.returncode != 0:
return f"Committed but push failed: {push_r.stderr.strip()}"
# Create PR
pr_r = _run(["gh", "pr", "create", "--fill", "--head", branch], cwd)
if pr_r.returncode != 0:
# PR may already exist
if "already exists" in pr_r.stderr.lower():
return f"Committed and pushed. PR already exists for {branch}."
return f"Committed and pushed but PR creation failed: {pr_r.stderr.strip()}"
return pr_r.stdout.strip()
def git_worktree(args: str, cwd: Path) -> str:
"""List, add, or remove git worktrees."""
parts = args.strip().split() if args.strip() else []
if not parts or parts[0] == "list":
r = _run(["git", "worktree", "list"], cwd)
return r.stdout.strip() if r.returncode == 0 else r.stderr.strip()
if parts[0] == "add" and len(parts) >= 3:
r = _run(["git", "worktree", "add", parts[1], parts[2]], cwd)
if r.returncode != 0:
return r.stderr.strip()
return f"Added worktree at {parts[1]} on branch {parts[2]}"
if parts[0] == "remove" and len(parts) >= 2:
r = _run(["git", "worktree", "remove", parts[1]], cwd)
if r.returncode != 0:
return r.stderr.strip()
return f"Removed worktree at {parts[1]}"
return "Usage: worktree [list | add <path> <branch> | remove <path>]"
def git_diff(cwd: Path) -> str:
"""Return working tree diff."""
r = _run(["git", "diff"], cwd)
if r.returncode != 0:
return r.stderr.strip()
return r.stdout.strip()
def git_stash(args: str, cwd: Path) -> str:
"""Stash operations: push (default), pop, list, drop."""
subcmd = args.strip().split()[0] if args.strip() else "push"
if subcmd == "list":
r = _run(["git", "stash", "list"], cwd)
return r.stdout.strip() if r.stdout.strip() else "No stashes."
if subcmd == "pop":
r = _run(["git", "stash", "pop"], cwd)
return r.stdout.strip() if r.returncode == 0 else r.stderr.strip()
if subcmd == "drop":
r = _run(["git", "stash", "drop"], cwd)
return r.stdout.strip() if r.returncode == 0 else r.stderr.strip()
# Default: push
r = _run(["git", "stash", "push"], cwd)
if r.returncode != 0:
return r.stderr.strip()
return r.stdout.strip() if r.stdout.strip() else "No local changes to save."
def handle_git_command(command: str, args: str, cwd: Path) -> Optional[str]:
"""Dispatch a git slash command. Returns None for unknown commands."""
dispatch = {
"status": lambda: git_status(cwd),
"branch": lambda: git_branch(args, cwd),
"commit": lambda: git_commit(args, cwd),
"push-pr": lambda: git_commit_push_pr(args, cwd),
"worktree": lambda: git_worktree(args, cwd),
"diff": lambda: git_diff(cwd),
"stash": lambda: git_stash(args, cwd),
}
handler = dispatch.get(command)
if handler is None:
return None
return handler()
@@ -91,7 +91,7 @@ class HanzoMagics(Magics):
# Reload the module
import importlib
module_name = f"hanzo_repl.{line.replace('.py', '')}"
module_name = f"hanzo_dev.{line.replace('.py', '')}"
if module_name in sys.modules:
importlib.reload(sys.modules[module_name])
else:
+633
View File
@@ -0,0 +1,633 @@
"""Main REPL implementation for Hanzo MCP testing."""
import asyncio
import json
import os
import sys
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional
from hanzo_mcp.server import HanzoMCPServer
from hanzoai.config import ConfigLoader, RuntimeConfig
from hanzoai.protocols import ModelPricing, PermissionMode, PermissionPolicy, UsageTracker
from hanzoai.session import (
CompactionConfig,
Session as HanzoSession,
compact_session,
estimate_session_tokens,
should_compact,
)
from prompt_toolkit import PromptSession
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.completion import WordCompleter
from prompt_toolkit.history import FileHistory
from rich.console import Console
from rich.markdown import Markdown
from rich.panel import Panel
from rich.table import Table
from .llm_client import LLMClient
from .tool_executor import ToolExecutor
__version__ = "0.1.0"
# Default pricing for cost estimates (Claude 3.5 Sonnet tier)
_DEFAULT_PRICING = ModelPricing(
input_price_per_token=3.0e-6,
output_price_per_token=15.0e-6,
cache_creation_price_per_token=3.75e-6,
cache_read_price_per_token=0.3e-6,
)
class HanzoREPL:
"""Interactive REPL for testing Hanzo MCP tools."""
def __init__(self, config: Optional[Dict[str, Any]] = None):
self.console = Console()
self.config = config or {}
self.mcp_server = None
self.llm_client = None
self.tool_executor = None
self.session = None
self.history_file = os.path.expanduser("~/.hanzo_dev_history")
# Hierarchical config
self.runtime_config: RuntimeConfig = self._load_runtime_config()
# Session persistence
self._session_path = Path.home() / ".hanzo" / "session.json"
self._sessions_dir = Path.home() / ".hanzo" / "sessions"
self.hanzo_session = HanzoSession()
self.compaction_config = CompactionConfig()
# Mode flags
self.permission_mode = PermissionMode.Allow
self.fast_mode = False
# REPL commands
self.commands = {
"/help": self.show_help,
"/tools": self.list_tools,
"/exit": self.exit_repl,
"/quit": self.exit_repl,
"/clear": self.cmd_clear,
"/providers": self.list_providers,
"/model": self.set_model,
"/permissions": self.cmd_permissions,
"/context": self.show_context,
"/reset": self.reset_context,
"/test": self.run_tests,
"/status": self.show_status,
"/cost": self.show_cost,
"/compact": self.run_compact,
"/config": self.show_config,
"/version": self.show_version,
"/login": self.do_login,
"/loop": self.do_loop,
"/resume": self.cmd_resume,
"/memory": self.cmd_memory,
"/init": self.cmd_init,
"/export": self.cmd_export,
"/session": self.cmd_session,
"/plan": self.cmd_plan,
"/solve": self.cmd_solve,
"/code": self.cmd_code,
"/auto": self.cmd_auto,
"/fast": self.cmd_fast,
"/remote-control": self.cmd_remote_control,
}
def _load_runtime_config(self) -> RuntimeConfig:
"""Load hierarchical config via ConfigLoader."""
loader = ConfigLoader.default_for(Path.cwd())
return loader.load()
async def initialize(self):
"""Initialize MCP server and LLM client."""
self.console.print("[bold green]Initializing Hanzo REPL...[/bold green]")
# Initialize MCP server
self.console.print("Loading MCP server...")
self.mcp_server = HanzoMCPServer()
await self.mcp_server.initialize()
# Initialize LLM client
self.console.print("Detecting available LLM providers...")
self.llm_client = LLMClient()
# Apply model from config if present
cfg_model = self.runtime_config.get("model")
if cfg_model and isinstance(cfg_model, str):
try:
self.llm_client.set_model(cfg_model)
self.console.print(f"[green]Model from config: {cfg_model}[/green]")
except ValueError:
pass
available_providers = self.llm_client.get_available_providers()
if not available_providers:
self.console.print("[bold red]No LLM API keys detected![/bold red]")
self.console.print("Please set one of the following environment variables:")
self.console.print("- OPENAI_API_KEY")
self.console.print("- ANTHROPIC_API_KEY")
self.console.print("- GROQ_API_KEY")
self.console.print("- etc.")
sys.exit(1)
self.console.print(
f"[green]Available providers: {', '.join(available_providers)}[/green]"
)
# Initialize tool executor with permission policy
self.tool_executor = ToolExecutor(
self.mcp_server,
self.llm_client,
permission_policy=PermissionPolicy(default_mode=PermissionMode.Allow),
)
# Connect to MCP servers defined in config
mcp_servers = self.runtime_config.mcp_servers()
for name, cfg in mcp_servers.items():
if hasattr(cfg, "command"):
cmd = [cfg.command] + list(cfg.args)
count = await self.tool_executor.register_mcp_server(
name, cmd, env=cfg.env or None,
)
if count:
self.console.print(f"[green]MCP {name}: {count} tools[/green]")
# Load persisted session if exists
if self._session_path.is_file():
try:
self.hanzo_session = HanzoSession.load(self._session_path)
est = estimate_session_tokens(self.hanzo_session)
self.console.print(
f"[dim]Restored session: {len(self.hanzo_session.messages)} messages, ~{est:,} tokens[/dim]"
)
except Exception:
self.hanzo_session = HanzoSession()
# Initialize prompt session
tool_names = [tool.name for tool in self.mcp_server.tools.values()]
completer = WordCompleter(
list(self.commands.keys()) + tool_names, ignore_case=True
)
self.session = PromptSession(
history=FileHistory(self.history_file),
auto_suggest=AutoSuggestFromHistory(),
completer=completer,
)
self.console.print("[bold green]REPL initialized successfully![/bold green]")
self.console.print(f"Using model: [cyan]{self.llm_client.current_model}[/cyan]")
self.console.print("Type [bold]/help[/bold] for available commands.")
async def run(self):
"""Run the main REPL loop."""
await self.initialize()
while True:
try:
# Get user input
user_input = await self.session.prompt_async("hanzo> ", multiline=False)
if not user_input.strip():
continue
# Check for commands
if user_input.startswith("/"):
command = user_input.split()[0]
if command in self.commands:
await self.commands[command](user_input)
else:
self.console.print(f"[red]Unknown command: {command}[/red]")
continue
# Process as chat with MCP tools
await self.process_chat(user_input)
except KeyboardInterrupt:
self.console.print("\n[yellow]Use /exit to quit[/yellow]")
except EOFError:
await self.exit_repl("")
except Exception as e:
self.console.print(f"[red]Error: {e}[/red]")
if self.config.get("debug"):
self.console.print_exception()
async def process_chat(self, message: str):
"""Process a chat message with MCP tool support."""
self.console.print()
# Send to LLM with available tools
try:
response = await self.tool_executor.execute_with_tools(message)
# Display response
if isinstance(response, str):
self.console.print(Markdown(response))
else:
self.console.print(response)
except Exception as e:
self.console.print(f"[red]Error processing chat: {e}[/red]")
if self.config.get("debug"):
self.console.print_exception()
async def show_help(self, _):
"""Show help information."""
help_text = """
# Hanzo REPL Commands
## Session
- **/clear** - Clear session history (new session)
- **/compact** - Compact session keeping summary
- **/resume [path]** - Load saved session from JSON
- **/session [list|switch id]** - Manage saved sessions
- **/export [file]** - Export conversation to file
- **/memory** - Show loaded instruction files
- **/init** - Create starter CLAUDE.md in cwd
## Model & Config
- **/model [name]** - Show or switch active model
- **/permissions [mode]** - Show/switch permission mode
- **/config** - Show loaded configuration files
- **/fast** - Toggle fast/standard mode
- **/providers** - List available LLM providers
- **/status** - Model, session info, token usage
- **/cost** - Accumulated cost estimate
- **/version** - Show version info
## Agents
- **/plan [prompt]** - Planning agent
- **/solve [prompt]** - Multi-approach solver
- **/code [prompt]** - Consensus code review
- **/auto [prompt]** - Autonomous execution
## Other
- **/login** - Authenticate via PKCE flow
- **/loop <sec> <prompt>** - Run prompt on interval
- **/tools** - List available MCP tools
- **/context** - Show conversation context
- **/reset** - Reset conversation context
- **/test** - Run MCP tool tests
- **/remote-control** - Start WebSocket remote bridge
- **/exit** or **/quit** - Exit the REPL
"""
self.console.print(Markdown(help_text))
async def list_tools(self, _):
"""List available MCP tools."""
table = Table(title="Available MCP Tools")
table.add_column("Tool", style="cyan", no_wrap=True)
table.add_column("Description", style="white")
table.add_column("Category", style="green")
for tool_name, tool in sorted(self.mcp_server.tools.items()):
category = tool.__class__.__module__.split(".")[-1]
table.add_row(tool_name, tool.description[:60] + "...", category)
self.console.print(table)
self.console.print(f"\nTotal tools: [bold]{len(self.mcp_server.tools)}[/bold]")
async def list_providers(self, _):
"""List available LLM providers."""
providers = self.llm_client.get_available_providers()
models = self.llm_client.get_available_models()
table = Table(title="Available LLM Providers")
table.add_column("Provider", style="cyan")
table.add_column("Models", style="white")
table.add_column("Status", style="green")
for provider in providers:
provider_models = [m for m in models if m.startswith(provider)]
status = "Active" if provider == self.llm_client.current_provider else ""
table.add_row(
provider,
", ".join(provider_models[:3])
+ ("..." if len(provider_models) > 3 else ""),
status,
)
self.console.print(table)
self.console.print(
f"\nCurrent model: [bold cyan]{self.llm_client.current_model}[/bold cyan]"
)
async def set_model(self, command: str):
"""Set the LLM model to use."""
parts = command.split(maxsplit=1)
if len(parts) < 2:
self.console.print("[red]Usage: /model <model_name>[/red]")
self.console.print("Available models:")
for model in self.llm_client.get_available_models():
self.console.print(f" - {model}")
return
model = parts[1]
try:
self.llm_client.set_model(model)
self.console.print(f"[green]Model set to: {model}[/green]")
except ValueError as e:
self.console.print(f"[red]Error: {e}[/red]")
async def show_context(self, _):
"""Show current conversation context."""
context = self.tool_executor.get_context()
if not context:
self.console.print("[yellow]No conversation context yet[/yellow]")
return
self.console.print(
Panel(
json.dumps(context, indent=2),
title="Conversation Context",
border_style="blue",
)
)
async def reset_context(self, _):
"""Reset conversation context."""
self.tool_executor.reset_context()
self.console.print("[green]Conversation context reset[/green]")
async def run_tests(self, _):
"""Run MCP tool tests."""
self.console.print("[bold]Running MCP tool tests...[/bold]")
# Import and run tests
from .tests import run_tool_tests
await run_tool_tests(self.console, self.mcp_server, self.tool_executor)
async def cmd_clear(self, _):
"""Clear session history and start fresh."""
self.hanzo_session = HanzoSession()
self.tool_executor.reset_context()
os.system("cls" if os.name == "nt" else "clear") # noqa: S605
self.console.print("[green]Session cleared.[/green]")
async def cmd_permissions(self, command: str):
"""Show or switch permission mode."""
parts = command.split(maxsplit=1)
modes = {"read-only": PermissionMode.Deny, "workspace-write": PermissionMode.Ask, "full-access": PermissionMode.Allow}
if len(parts) < 2:
current = {v: k for k, v in modes.items()}.get(self.permission_mode, "full-access")
self.console.print(f"[cyan]Permission mode:[/cyan] {current}")
self.console.print(f" Options: {', '.join(modes.keys())}")
return
name = parts[1].strip()
if name not in modes:
self.console.print(f"[red]Unknown mode. Use: {', '.join(modes.keys())}[/red]")
return
self.permission_mode = modes[name]
self.tool_executor.permission_policy = PermissionPolicy(default_mode=self.permission_mode)
self.console.print(f"[green]Permission mode: {name}[/green]")
async def cmd_resume(self, command: str):
"""Load saved session from JSON file."""
parts = command.split(maxsplit=1)
path = Path(parts[1].strip()) if len(parts) > 1 else self._session_path
if not path.is_file():
self.console.print(f"[red]No session file at {path}[/red]")
return
self.hanzo_session = HanzoSession.load(path)
est = estimate_session_tokens(self.hanzo_session)
self.console.print(f"[green]Loaded: {len(self.hanzo_session.messages)} messages, ~{est:,} tokens[/green]")
async def cmd_memory(self, _):
"""Show loaded instruction files."""
self.console.print("[cyan]Loaded instruction files:[/cyan]")
for entry in self.runtime_config.loaded_entries:
self.console.print(f" [{entry.source.name}] {entry.path}")
if not self.runtime_config.loaded_entries:
self.console.print(" [dim]None[/dim]")
async def cmd_init(self, _):
"""Create starter CLAUDE.md in cwd."""
target = Path.cwd() / "CLAUDE.md"
if target.exists():
self.console.print(f"[yellow]Already exists: {target}[/yellow]")
return
target.write_text("# Project Instructions\n\nAdd project-specific instructions here.\n")
self.console.print(f"[green]Created {target}[/green]")
async def cmd_export(self, command: str):
"""Export conversation to file."""
parts = command.split(maxsplit=1)
outpath = Path(parts[1].strip()) if len(parts) > 1 else Path("hanzo-export.md")
lines = [f"# Hanzo Session Export ({datetime.now().isoformat()})\n"]
for msg in self.hanzo_session.messages:
role = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
text = "".join(b.text for b in msg.content if hasattr(b, "text"))
lines.append(f"## {role}\n\n{text}\n")
outpath.write_text("\n".join(lines))
self.console.print(f"[green]Exported {len(self.hanzo_session.messages)} messages to {outpath}[/green]")
async def cmd_session(self, command: str):
"""List or switch saved sessions."""
parts = command.split(maxsplit=2)
self._sessions_dir.mkdir(parents=True, exist_ok=True)
if len(parts) < 2 or parts[1] == "list":
sessions = sorted(self._sessions_dir.glob("*.json"))
if not sessions:
self.console.print("[yellow]No saved sessions.[/yellow]")
return
for s in sessions:
self.console.print(f" {s.stem}")
return
if parts[1] == "switch" and len(parts) > 2:
target = self._sessions_dir / f"{parts[2]}.json"
if not target.is_file():
self.console.print(f"[red]Session not found: {parts[2]}[/red]")
return
self.hanzo_session = HanzoSession.load(target)
self.console.print(f"[green]Switched to session: {parts[2]}[/green]")
return
self.console.print("[red]Usage: /session [list|switch <id>][/red]")
async def _agent_chat(self, system_prefix: str, command: str, label: str):
"""Send a prompt with a system prefix for agent commands."""
parts = command.split(maxsplit=1)
if len(parts) < 2:
self.console.print(f"[red]Usage: /{label} <prompt>[/red]")
return
prompt = f"{system_prefix}\n\nUser request: {parts[1]}"
await self.process_chat(prompt)
async def cmd_plan(self, command: str):
"""Planning agent."""
await self._agent_chat("You are a planning agent. Create a detailed plan.", command, "plan")
async def cmd_solve(self, command: str):
"""Multi-approach solver."""
await self._agent_chat("Race multiple approaches. Present the best solution.", command, "solve")
async def cmd_code(self, command: str):
"""Consensus code review."""
await self._agent_chat("Implement this with consensus from multiple review passes.", command, "code")
async def cmd_auto(self, command: str):
"""Autonomous execution."""
await self._agent_chat("Execute this task autonomously. Use tools as needed.", command, "auto")
async def cmd_fast(self, _):
"""Toggle fast/standard mode."""
self.fast_mode = not self.fast_mode
state = "fast" if self.fast_mode else "standard"
self.console.print(f"[cyan]Mode: {state}[/cyan]")
async def cmd_remote_control(self, _):
"""Start WebSocket server for remote control."""
try:
import websockets # noqa: F401
except ImportError:
self.console.print("[red]Install websockets: pip install websockets[/red]")
return
port = 9229
self.console.print(f"[cyan]Remote control bridge: ws://localhost:{port}[/cyan]")
self.console.print("[yellow]Send JSON messages: {{\"type\": \"prompt\", \"text\": \"...\"}}[/yellow]")
self.console.print("[dim]Press Ctrl+C to stop.[/dim]")
async def handler(ws):
async for raw in ws:
try:
msg = json.loads(raw)
if msg.get("type") == "prompt":
await self.process_chat(msg["text"])
await ws.send(json.dumps({"type": "done"}))
except Exception as e:
await ws.send(json.dumps({"type": "error", "message": str(e)}))
import websockets
try:
async with websockets.serve(handler, "localhost", port):
await asyncio.Future() # run forever
except KeyboardInterrupt:
self.console.print("[yellow]Remote control stopped.[/yellow]")
async def show_status(self, _):
"""Show model, session info, and token usage."""
tracker = self.tool_executor.usage_tracker if self.tool_executor else UsageTracker()
cum = tracker.cumulative_usage()
est_tokens = estimate_session_tokens(self.hanzo_session)
table = Table(title="Status")
table.add_column("Key", style="cyan")
table.add_column("Value", style="white")
table.add_row("Model", self.llm_client.current_model if self.llm_client else "n/a")
table.add_row("Provider", self.llm_client.current_provider if self.llm_client else "n/a")
table.add_row("Turns", str(tracker.turns))
table.add_row("Input tokens", f"{cum.input_tokens:,}")
table.add_row("Output tokens", f"{cum.output_tokens:,}")
table.add_row("Session tokens (est)", f"{est_tokens:,}")
table.add_row("Session messages", str(len(self.hanzo_session.messages)))
table.add_row("Config files loaded", str(len(self.runtime_config.loaded_entries)))
self.console.print(table)
async def show_cost(self, _):
"""Show accumulated cost estimate."""
tracker = self.tool_executor.usage_tracker if self.tool_executor else UsageTracker()
cum = tracker.cumulative_usage()
cost = _DEFAULT_PRICING.cost(cum)
self.console.print(
f"[cyan]Cost estimate:[/cyan] ${cost:.6f} "
f"({cum.input_tokens:,} in / {cum.output_tokens:,} out, {tracker.turns} turns)"
)
async def run_compact(self, _):
"""Compact the session to reclaim context."""
est = estimate_session_tokens(self.hanzo_session)
result = compact_session(self.hanzo_session, self.compaction_config)
if result.removed_message_count == 0:
self.console.print("[yellow]Session too small to compact.[/yellow]")
return
self.hanzo_session = result.compacted_session
new_est = estimate_session_tokens(self.hanzo_session)
self.console.print(
f"[green]Compacted: removed {result.removed_message_count} messages, "
f"{est:,} -> {new_est:,} est tokens.[/green]"
)
async def show_config(self, _):
"""Show loaded configuration."""
if not self.runtime_config.loaded_entries:
self.console.print("[yellow]No config files loaded.[/yellow]")
return
for entry in self.runtime_config.loaded_entries:
self.console.print(f" [{entry.source.name}] {entry.path}")
mcp_servers = self.runtime_config.mcp_servers()
if mcp_servers:
self.console.print(f"\n MCP servers: {', '.join(mcp_servers.keys())}")
async def show_version(self, _):
"""Show version info."""
from hanzoai._version import __version__ as hanzoai_version
self.console.print(f" hanzo-dev {__version__}")
self.console.print(f" hanzoai {hanzoai_version}")
async def do_login(self, _):
"""Login via PKCE flow."""
from hanzoai.auth import HanzoAuth
auth = HanzoAuth()
oauth_cfg = self.runtime_config.oauth()
kwargs: Dict[str, Any] = {}
if oauth_cfg:
kwargs["authorize_url"] = oauth_cfg.authorize_url
kwargs["token_url"] = oauth_cfg.token_url
kwargs["client_id"] = oauth_cfg.client_id
if oauth_cfg.scopes:
kwargs["scopes"] = oauth_cfg.scopes
if oauth_cfg.callback_port:
kwargs["redirect_port"] = oauth_cfg.callback_port
self.console.print("[yellow]Starting PKCE login flow...[/yellow]")
try:
token_set = await auth.login_with_pkce(**kwargs)
self.console.print(f"[green]Logged in. Scopes: {', '.join(token_set.scopes)}[/green]")
except Exception as e:
self.console.print(f"[red]Login failed: {e}[/red]")
async def do_loop(self, command: str):
"""Run a prompt on a recurring interval. Usage: /loop <seconds> <prompt>"""
parts = command.split(maxsplit=2)
if len(parts) < 3:
self.console.print("[red]Usage: /loop <seconds> <prompt>[/red]")
return
try:
interval = int(parts[1])
except ValueError:
self.console.print("[red]Interval must be an integer (seconds).[/red]")
return
prompt = parts[2]
self.console.print(f"[yellow]Looping every {interval}s: {prompt}[/yellow]")
try:
while True:
await self.process_chat(prompt)
await asyncio.sleep(interval)
except KeyboardInterrupt:
self.console.print("[yellow]Loop stopped.[/yellow]")
async def exit_repl(self, _):
"""Exit the REPL, saving session."""
# Persist session
self._session_path.parent.mkdir(parents=True, exist_ok=True)
self.hanzo_session.save(self._session_path)
self.console.print("[yellow]Goodbye![/yellow]")
sys.exit(0)
async def main():
"""Main entry point."""
repl = HanzoREPL()
await repl.run()
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,319 @@
"""Tests for vim keybinding support."""
import pytest
from hanzo_dev.vim_mode import VimMode, VimState
class FakeWidget:
"""Minimal InputWidget implementation for testing."""
def __init__(self, value: str = "", cursor_position: int = 0):
self._value = value
self._cursor_position = cursor_position
@property
def value(self) -> str:
return self._value
@value.setter
def value(self, v: str) -> None:
self._value = v
@property
def cursor_position(self) -> int:
return self._cursor_position
@cursor_position.setter
def cursor_position(self, v: int) -> None:
self._cursor_position = v
# -- Mode transitions --
class TestModeTransitions:
def test_starts_in_normal(self):
s = VimState()
assert s.mode == VimMode.Normal
def test_i_enters_insert(self):
s = VimState()
w = FakeWidget("hello")
s.handle_key("i", w)
assert s.mode == VimMode.Insert
def test_a_enters_insert_after(self):
s = VimState()
w = FakeWidget("hello", 2)
s.handle_key("a", w)
assert s.mode == VimMode.Insert
assert w.cursor_position == 3
def test_I_enters_insert_at_start(self):
s = VimState()
w = FakeWidget("hello", 3)
s.handle_key("I", w)
assert s.mode == VimMode.Insert
assert w.cursor_position == 0
def test_A_enters_insert_at_end(self):
s = VimState()
w = FakeWidget("hello", 0)
s.handle_key("A", w)
assert s.mode == VimMode.Insert
assert w.cursor_position == 5
def test_o_enters_insert_at_end(self):
s = VimState()
w = FakeWidget("hello", 0)
s.handle_key("o", w)
assert s.mode == VimMode.Insert
assert w.cursor_position == 5
def test_O_enters_insert_at_start(self):
s = VimState()
w = FakeWidget("hello", 3)
s.handle_key("O", w)
assert s.mode == VimMode.Insert
assert w.cursor_position == 0
def test_escape_from_insert(self):
s = VimState(mode=VimMode.Insert)
w = FakeWidget("hello", 3)
s.handle_key("escape", w)
assert s.mode == VimMode.Normal
assert w.cursor_position == 2 # backs up one
def test_escape_from_insert_at_zero(self):
s = VimState(mode=VimMode.Insert)
w = FakeWidget("hello", 0)
s.handle_key("escape", w)
assert s.mode == VimMode.Normal
assert w.cursor_position == 0
def test_v_enters_visual(self):
s = VimState()
w = FakeWidget("hello", 2)
s.handle_key("v", w)
assert s.mode == VimMode.Visual
assert s.visual_anchor == 2
def test_escape_from_visual(self):
s = VimState(mode=VimMode.Visual)
w = FakeWidget("hello")
s.handle_key("escape", w)
assert s.mode == VimMode.Normal
def test_colon_enters_command(self):
s = VimState()
w = FakeWidget("hello")
s.handle_key(":", w)
assert s.mode == VimMode.Command
def test_escape_from_command(self):
s = VimState(mode=VimMode.Command)
w = FakeWidget("hello")
s.handle_key("escape", w)
assert s.mode == VimMode.Normal
# -- Cursor movement --
class TestCursorMovement:
def test_h_moves_left(self):
s = VimState()
w = FakeWidget("hello", 3)
s.handle_key("h", w)
assert w.cursor_position == 2
def test_h_clamps_at_zero(self):
s = VimState()
w = FakeWidget("hello", 0)
s.handle_key("h", w)
assert w.cursor_position == 0
def test_l_moves_right(self):
s = VimState()
w = FakeWidget("hello", 2)
s.handle_key("l", w)
assert w.cursor_position == 3
def test_l_clamps_at_end(self):
s = VimState()
w = FakeWidget("hello", 5)
s.handle_key("l", w)
assert w.cursor_position == 5
def test_0_goes_to_start(self):
s = VimState()
w = FakeWidget("hello", 4)
s.handle_key("0", w)
assert w.cursor_position == 0
def test_dollar_goes_to_end(self):
s = VimState()
w = FakeWidget("hello", 0)
s.handle_key("$", w)
assert w.cursor_position == 5
def test_w_next_word(self):
s = VimState()
w = FakeWidget("hello world", 0)
s.handle_key("w", w)
assert w.cursor_position == 6
def test_b_prev_word(self):
s = VimState()
w = FakeWidget("hello world", 8)
s.handle_key("b", w)
assert w.cursor_position == 6
def test_gg_goes_to_start(self):
s = VimState()
w = FakeWidget("hello", 4)
s.handle_key("g", w)
s.handle_key("g", w)
assert w.cursor_position == 0
def test_j_k_consumed(self):
s = VimState()
w = FakeWidget("hello", 2)
assert s.handle_key("j", w) is True
assert w.cursor_position == 2
assert s.handle_key("k", w) is True
assert w.cursor_position == 2
# -- Delete / Yank / Paste --
class TestEditOperations:
def test_x_deletes_char(self):
s = VimState()
w = FakeWidget("hello", 1)
s.handle_key("x", w)
assert w.value == "hllo"
assert s.register == "e"
def test_x_at_end_noop(self):
s = VimState()
w = FakeWidget("hi", 2)
s.handle_key("x", w)
assert w.value == "hi"
def test_dd_deletes_line(self):
s = VimState()
w = FakeWidget("hello world", 3)
s.handle_key("d", w)
s.handle_key("d", w)
assert w.value == ""
assert s.register == "hello world"
def test_yy_yanks_line(self):
s = VimState()
w = FakeWidget("hello", 2)
s.handle_key("y", w)
s.handle_key("y", w)
assert s.register == "hello"
assert w.value == "hello" # unchanged
def test_p_pastes_after(self):
s = VimState()
s.register = "XY"
w = FakeWidget("abc", 1)
s.handle_key("p", w)
assert w.value == "abXYc"
def test_dw_deletes_word(self):
s = VimState()
w = FakeWidget("hello world", 0)
s.handle_key("d", w)
s.handle_key("w", w)
assert w.value == "world"
assert s.register == "hello "
# -- Undo --
class TestUndo:
def test_undo_restores(self):
s = VimState()
w = FakeWidget("hello", 2)
s.handle_key("x", w)
assert w.value == "helo"
s.handle_key("u", w)
assert w.value == "hello"
assert w.cursor_position == 2
def test_undo_empty_stack(self):
s = VimState()
w = FakeWidget("hello", 0)
s.handle_key("u", w) # should not crash
assert w.value == "hello"
# -- Visual mode --
class TestVisualMode:
def test_visual_yank(self):
s = VimState()
w = FakeWidget("hello", 1)
s.handle_key("v", w)
s.handle_key("l", w)
s.handle_key("l", w)
s.handle_key("y", w)
assert s.register == "ell"
assert s.mode == VimMode.Normal
def test_visual_delete(self):
s = VimState()
w = FakeWidget("hello", 1)
s.handle_key("v", w)
s.handle_key("l", w)
s.handle_key("d", w)
assert w.value == "hlo"
assert s.register == "el"
assert s.mode == VimMode.Normal
# -- Command mode --
class TestCommandMode:
def test_accumulate_and_return(self):
s = VimState()
w = FakeWidget()
s.handle_key(":", w)
s.handle_key("w", w)
s.handle_key("q", w)
result = s.handle_key("enter", w)
assert result == "wq"
assert s.mode == VimMode.Normal
def test_backspace_in_command(self):
s = VimState()
w = FakeWidget()
s.handle_key(":", w)
s.handle_key("a", w)
s.handle_key("b", w)
s.handle_key("backspace", w)
result = s.handle_key("enter", w)
assert result == "a"
def test_search_prefix(self):
s = VimState()
w = FakeWidget("hello")
s.handle_key("/", w)
assert s.mode == VimMode.Command
assert s.command_buffer == "/"
s.handle_key("h", w)
result = s.handle_key("enter", w)
assert result == "/h"
# -- Insert passthrough --
class TestInsertMode:
def test_regular_key_returns_false(self):
s = VimState(mode=VimMode.Insert)
w = FakeWidget("hello")
result = s.handle_key("a", w)
assert result is False # not consumed, widget handles it
@@ -1,10 +1,26 @@
"""Beautiful Textual-based REPL interface for Hanzo."""
import contextlib
import json
import time
from typing import Dict, Optional
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional
from hanzo_mcp.server import HanzoMCPServer
from hanzoai.config import ConfigLoader, RuntimeConfig
from hanzoai.protocols import (
ModelPricing,
PermissionMode,
PermissionPolicy,
UsageTracker,
)
from hanzoai.session import (
CompactionConfig,
Session as HanzoSession,
compact_session,
estimate_session_tokens,
)
from rich.console import Console
from rich.markdown import Markdown
from rich.text import Text
@@ -20,6 +36,14 @@ from .command_suggestions import CommandSuggestions
from .llm_client import LLMClient
from .tool_executor import ToolExecutor
# Default pricing for cost display (Claude 3.5 Sonnet tier)
_DEFAULT_PRICING = ModelPricing(
input_price_per_token=3.0e-6,
output_price_per_token=15.0e-6,
cache_creation_price_per_token=3.75e-6,
cache_read_price_per_token=0.3e-6,
)
class StatusBar(Static):
"""Animated status bar showing thinking state."""
@@ -74,12 +98,19 @@ class StatusBar(Static):
class ContextIndicator(Static):
"""Shows context usage."""
"""Shows context usage based on estimate_session_tokens()."""
context_percent = reactive(10)
context_percent = reactive(100)
def __init__(self, **kwargs):
def __init__(self, max_tokens: int = 200_000, **kwargs):
super().__init__(**kwargs)
self.max_tokens = max_tokens
def update_from_session(self, session: HanzoSession) -> None:
"""Recompute percentage from live session token estimate."""
used = estimate_session_tokens(session)
remaining = max(0, self.max_tokens - used)
self.context_percent = int((remaining / self.max_tokens) * 100)
def render(self) -> Text:
"""Render context indicator."""
@@ -182,6 +213,16 @@ class HanzoTextualREPL(App):
self.verbose_mode = False
self.memory = {} # For memorized snippets
self.command_suggestions = None
self.fast_mode = False
self.permission_mode = PermissionMode.Allow
# hanzoai core wiring
loader = ConfigLoader.default_for(Path.cwd())
self.runtime_config: RuntimeConfig = loader.load()
self.hanzo_session = HanzoSession()
self.compaction_config = CompactionConfig()
self._session_path = Path.home() / ".hanzo" / "session.json"
self._sessions_dir = Path.home() / ".hanzo" / "sessions"
def compose(self) -> ComposeResult:
"""Create child widgets."""
@@ -226,6 +267,14 @@ class HanzoTextualREPL(App):
# Initialize LLM client (for embedded backend)
self.llm_client = LLMClient()
# Apply model from config if present
cfg_model = self.runtime_config.get("model")
if cfg_model and isinstance(cfg_model, str):
try:
self.llm_client.set_model(cfg_model)
except ValueError:
pass
# Initialize backend manager
self.backend_manager = BackendManager(self.llm_client)
@@ -254,8 +303,38 @@ class HanzoTextualREPL(App):
Text(f"● Model: {self.llm_client.current_model}", style="green")
)
# Initialize tool executor with backend
self.tool_executor = ToolExecutor(self.mcp_server, self.backend_manager)
# Initialize tool executor with backend + permission policy
self.tool_executor = ToolExecutor(
self.mcp_server,
self.backend_manager,
permission_policy=PermissionPolicy(default_mode=PermissionMode.Allow),
)
# Connect to MCP servers from config
mcp_servers = self.runtime_config.mcp_servers()
for name, cfg in mcp_servers.items():
if hasattr(cfg, "command"):
cmd = [cfg.command] + list(cfg.args)
count = await self.tool_executor.register_mcp_server(
name, cmd, env=cfg.env or None,
)
if count:
messages.write(Text(f"● MCP {name}: {count} tools", style="green"))
# Load persisted session
if self._session_path.is_file():
try:
self.hanzo_session = HanzoSession.load(self._session_path)
est = estimate_session_tokens(self.hanzo_session)
messages.write(
Text(f"● Restored session: {len(self.hanzo_session.messages)} msgs, ~{est:,} tokens", style="dim")
)
except Exception:
self.hanzo_session = HanzoSession()
# Update context indicator from session
context_indicator = self.query_one("#permissions", ContextIndicator)
context_indicator.update_from_session(self.hanzo_session)
# List available backends
backends = self.backend_manager.list_backends()
@@ -324,20 +403,35 @@ class HanzoTextualREPL(App):
async def process_chat(self, message: str) -> None:
"""Process a chat message."""
from hanzoai.session import ConversationMessage, TextBlock
# Start thinking animation
status = self.query_one("#status-bar", StatusBar)
status.start_thinking()
try:
# Track user message in session
self.hanzo_session.messages.append(ConversationMessage.user_text(message))
# Execute with tools
response = await self.tool_executor.execute_with_tools(message)
# Track assistant response in session
self.hanzo_session.messages.append(
ConversationMessage.assistant([TextBlock(text=response or "")])
)
# Stop animation
status.stop_thinking()
# Update token count on status bar
if self.tool_executor:
cum = self.tool_executor.usage_tracker.cumulative_usage()
status.update_tokens(cum.total_tokens())
# Show response
messages = self.query_one("#messages", RichLog)
messages.write("")
msgs_widget = self.query_one("#messages", RichLog)
msgs_widget.write("")
# Format as markdown
console = Console()
@@ -346,14 +440,13 @@ class HanzoTextualREPL(App):
for line in capture.get().split("\n"):
if line.strip():
messages.write(f"● {line}")
msgs_widget.write(f"● {line}")
messages.write("")
msgs_widget.write("")
# Update context usage indicator (estimated - real tracking pending)
self.context_usage = max(5, self.context_usage - 2)
# Update context indicator from real session token estimate
context_indicator = self.query_one("#permissions", ContextIndicator)
context_indicator.context_percent = self.context_usage
context_indicator.update_from_session(self.hanzo_session)
except Exception as e:
status.stop_thinking()
@@ -556,11 +649,31 @@ class HanzoTextualREPL(App):
"""Show slash commands menu."""
self.show_message("/ commands:", "cyan")
commands = [
"/search <query> - Search for content",
"/file <path> - Open file",
"/run <command> - Run shell command",
"/voice - Enable voice mode",
"/model <name> - Change AI model",
"/help Show shortcuts",
"/status Model, session info, token usage",
"/cost Accumulated cost estimate",
"/clear Clear session history",
"/compact Compact session, keep summary",
"/model [name] Show or switch AI model",
"/permissions [m] Show/switch permission mode",
"/fast Toggle fast/standard mode",
"/config Show loaded config files",
"/memory Show loaded instruction files",
"/init Create starter CLAUDE.md",
"/resume [path] Load saved session",
"/export [file] Export conversation",
"/session [list|..] Manage saved sessions",
"/plan <prompt> Planning agent",
"/solve <prompt> Multi-approach solver",
"/code <prompt> Consensus code review",
"/auto <prompt> Autonomous execution",
"/login Authenticate via PKCE",
"/loop <s> <msg> Run prompt on interval",
"/backend <name> Switch backend",
"/tools List MCP tools",
"/remote-control WebSocket remote bridge",
"/version Show version info",
"/exit Quit",
]
messages = self.query_one("#messages", RichLog)
for cmd in commands:
@@ -640,6 +753,9 @@ class HanzoTextualREPL(App):
"Model selection only available for embedded backend", "yellow"
)
return True
if command == "model" and not arg:
await self.show_model_selector()
return True
if command == "auth":
await self.handle_auth_command()
return True
@@ -650,13 +766,76 @@ class HanzoTextualREPL(App):
await self.handle_logout_command()
return True
if command == "status":
await self.show_backend_status()
await self.handle_status_command()
return True
if command == "cost":
await self.handle_cost_command()
return True
if command == "compact":
await self.handle_compact_command()
return True
if command == "config":
await self.handle_config_command()
return True
if command == "version":
await self.handle_version_command()
return True
if command == "login":
await self.handle_login_command()
return True
if command == "loop":
await self.handle_loop_command(arg)
return True
if command == "permissions":
await self.handle_permissions_command(arg)
return True
if command == "resume":
await self.handle_resume_command(arg)
return True
if command == "memory":
await self.handle_memory_command()
return True
if command == "init":
await self.handle_init_command()
return True
if command == "export":
await self.handle_export_command(arg)
return True
if command == "session":
await self.handle_session_command(arg)
return True
if command == "plan":
await self.handle_agent_command("You are a planning agent. Create a detailed plan.", arg, "plan")
return True
if command == "solve":
await self.handle_agent_command("Race multiple approaches. Present the best solution.", arg, "solve")
return True
if command == "code":
await self.handle_agent_command("Implement this with consensus from multiple review passes.", arg, "code")
return True
if command == "auto":
await self.handle_agent_command("Execute this task autonomously. Use tools as needed.", arg, "auto")
return True
if command == "fast":
self.fast_mode = not self.fast_mode
self.show_message(f"Mode: {'fast' if self.fast_mode else 'standard'}", "cyan")
return True
if command == "remote-control":
self.show_message("Remote control: ws://localhost:9229", "cyan")
self.show_message("Install websockets and run from CLI REPL for full support.", "yellow")
return True
if command == "exit" or command == "quit":
# Save session before exit
self._session_path.parent.mkdir(parents=True, exist_ok=True)
self.hanzo_session.save(self._session_path)
self.exit()
return True
if command == "clear":
self.hanzo_session = HanzoSession()
if self.tool_executor:
self.tool_executor.reset_context()
self.action_clear()
self.show_message("Session cleared.", "green")
return True
if command == "help":
self.action_shortcuts()
@@ -849,6 +1028,214 @@ class HanzoTextualREPL(App):
else:
self.show_message("No active authentication session", "dim")
async def handle_status_command(self) -> None:
"""Show model, session info, token usage."""
messages = self.query_one("#messages", RichLog)
tracker = self.tool_executor.usage_tracker if self.tool_executor else UsageTracker()
cum = tracker.cumulative_usage()
est = estimate_session_tokens(self.hanzo_session)
model = self.llm_client.current_model if self.llm_client else "n/a"
provider = self.llm_client.current_provider if self.llm_client else "n/a"
backend = self.backend_manager.current_backend if self.backend_manager else "n/a"
messages.write(Text("Status:", style="cyan"))
messages.write(f" Backend: {backend}")
messages.write(f" Model: {model}")
messages.write(f" Provider: {provider}")
messages.write(f" Turns: {tracker.turns}")
messages.write(f" Input tokens: {cum.input_tokens:,}")
messages.write(f" Output tokens: {cum.output_tokens:,}")
messages.write(f" Session tokens: ~{est:,}")
messages.write(f" Session messages: {len(self.hanzo_session.messages)}")
messages.write(f" Config files: {len(self.runtime_config.loaded_entries)}")
messages.write("")
async def handle_cost_command(self) -> None:
"""Show accumulated cost estimate."""
tracker = self.tool_executor.usage_tracker if self.tool_executor else UsageTracker()
cum = tracker.cumulative_usage()
cost = _DEFAULT_PRICING.cost(cum)
self.show_message(
f"Cost: ${cost:.6f} ({cum.input_tokens:,} in / {cum.output_tokens:,} out, {tracker.turns} turns)",
"cyan",
)
async def handle_compact_command(self) -> None:
"""Compact the session to reclaim context."""
est_before = estimate_session_tokens(self.hanzo_session)
result = compact_session(self.hanzo_session, self.compaction_config)
if result.removed_message_count == 0:
self.show_message("Session too small to compact.", "yellow")
return
self.hanzo_session = result.compacted_session
est_after = estimate_session_tokens(self.hanzo_session)
self.show_message(
f"Compacted: removed {result.removed_message_count} messages, "
f"{est_before:,} -> {est_after:,} est tokens.",
"green",
)
# Update context indicator
context_indicator = self.query_one("#permissions", ContextIndicator)
context_indicator.update_from_session(self.hanzo_session)
async def handle_config_command(self) -> None:
"""Show loaded configuration files."""
messages = self.query_one("#messages", RichLog)
if not self.runtime_config.loaded_entries:
self.show_message("No config files loaded.", "yellow")
return
messages.write(Text("Config:", style="cyan"))
for entry in self.runtime_config.loaded_entries:
messages.write(f" [{entry.source.name}] {entry.path}")
mcp_servers = self.runtime_config.mcp_servers()
if mcp_servers:
messages.write(f" MCP servers: {', '.join(mcp_servers.keys())}")
messages.write("")
async def handle_version_command(self) -> None:
"""Show version info."""
from hanzoai._version import __version__ as hanzoai_version
from hanzo_dev import __version__ as dev_version
messages = self.query_one("#messages", RichLog)
messages.write(Text("Version:", style="cyan"))
messages.write(f" hanzo-dev {dev_version}")
messages.write(f" hanzoai {hanzoai_version}")
messages.write("")
async def handle_login_command(self) -> None:
"""Login via PKCE flow."""
from hanzoai.auth import HanzoAuth
auth = HanzoAuth()
oauth_cfg = self.runtime_config.oauth()
kwargs: Dict[str, Any] = {}
if oauth_cfg:
kwargs["authorize_url"] = oauth_cfg.authorize_url
kwargs["token_url"] = oauth_cfg.token_url
kwargs["client_id"] = oauth_cfg.client_id
if oauth_cfg.scopes:
kwargs["scopes"] = oauth_cfg.scopes
if oauth_cfg.callback_port:
kwargs["redirect_port"] = oauth_cfg.callback_port
self.show_message("Starting PKCE login flow...", "yellow")
try:
token_set = await auth.login_with_pkce(**kwargs)
self.show_message(f"Logged in. Scopes: {', '.join(token_set.scopes)}", "green")
except Exception as e:
self.show_error(f"Login failed: {e}")
async def handle_loop_command(self, arg: str) -> None:
"""Run a prompt on a recurring interval. Usage: /loop <seconds> <prompt>"""
import asyncio
parts = arg.split(maxsplit=1)
if len(parts) < 2:
self.show_error("Usage: /loop <seconds> <prompt>")
return
try:
interval = int(parts[0])
except ValueError:
self.show_error("Interval must be an integer (seconds).")
return
prompt = parts[1]
self.show_message(f"Looping every {interval}s: {prompt}", "yellow")
try:
while True:
await self.process_chat(prompt)
await asyncio.sleep(interval)
except asyncio.CancelledError:
self.show_message("Loop stopped.", "yellow")
async def handle_permissions_command(self, arg: str) -> None:
"""Show or switch permission mode."""
modes = {"read-only": PermissionMode.Deny, "workspace-write": PermissionMode.Ask, "full-access": PermissionMode.Allow}
if not arg:
current = {v: k for k, v in modes.items()}.get(self.permission_mode, "full-access")
self.show_message(f"Permission mode: {current}", "cyan")
self.show_message(f"Options: {', '.join(modes.keys())}", "dim")
return
if arg not in modes:
self.show_error(f"Unknown mode. Use: {', '.join(modes.keys())}")
return
self.permission_mode = modes[arg]
if self.tool_executor:
self.tool_executor.permission_policy = PermissionPolicy(default_mode=self.permission_mode)
self.show_message(f"Permission mode: {arg}", "green")
async def handle_resume_command(self, arg: str) -> None:
"""Load saved session from JSON file."""
path = Path(arg.strip()) if arg else self._session_path
if not path.is_file():
self.show_error(f"No session file at {path}")
return
self.hanzo_session = HanzoSession.load(path)
est = estimate_session_tokens(self.hanzo_session)
self.show_message(f"Loaded: {len(self.hanzo_session.messages)} messages, ~{est:,} tokens", "green")
async def handle_memory_command(self) -> None:
"""Show loaded instruction files."""
messages = self.query_one("#messages", RichLog)
messages.write(Text("Loaded instruction files:", style="cyan"))
for entry in self.runtime_config.loaded_entries:
messages.write(f" [{entry.source.name}] {entry.path}")
if not self.runtime_config.loaded_entries:
messages.write(" (none)")
messages.write("")
async def handle_init_command(self) -> None:
"""Create starter CLAUDE.md in cwd."""
target = Path.cwd() / "CLAUDE.md"
if target.exists():
self.show_message(f"Already exists: {target}", "yellow")
return
target.write_text("# Project Instructions\n\nAdd project-specific instructions here.\n")
self.show_message(f"Created {target}", "green")
async def handle_export_command(self, arg: str) -> None:
"""Export conversation to file."""
outpath = Path(arg.strip()) if arg else Path("hanzo-export.md")
lines = [f"# Hanzo Session Export ({datetime.now().isoformat()})\n"]
for msg in self.hanzo_session.messages:
role = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
text = "".join(b.text for b in msg.content if hasattr(b, "text"))
lines.append(f"## {role}\n\n{text}\n")
outpath.write_text("\n".join(lines))
self.show_message(f"Exported {len(self.hanzo_session.messages)} messages to {outpath}", "green")
async def handle_session_command(self, arg: str) -> None:
"""List or switch saved sessions."""
self._sessions_dir.mkdir(parents=True, exist_ok=True)
parts = arg.split(maxsplit=1) if arg else []
if not parts or parts[0] == "list":
sessions = sorted(self._sessions_dir.glob("*.json"))
if not sessions:
self.show_message("No saved sessions.", "yellow")
return
messages = self.query_one("#messages", RichLog)
for s in sessions:
messages.write(f" {s.stem}")
messages.write("")
return
if parts[0] == "switch" and len(parts) > 1:
target = self._sessions_dir / f"{parts[1]}.json"
if not target.is_file():
self.show_error(f"Session not found: {parts[1]}")
return
self.hanzo_session = HanzoSession.load(target)
self.show_message(f"Switched to session: {parts[1]}", "green")
return
self.show_error("Usage: /session [list|switch <id>]")
async def handle_agent_command(self, system_prefix: str, arg: str, label: str) -> None:
"""Send a prompt with a system prefix for agent commands."""
if not arg:
self.show_error(f"Usage: /{label} <prompt>")
return
prompt = f"{system_prefix}\n\nUser request: {arg}"
await self.process_chat(prompt)
async def show_backend_status(self) -> None:
"""Show current backend status."""
backend_name = self.backend_manager.current_backend
@@ -1,22 +1,91 @@
"""Tool executor for running MCP tools based on LLM responses."""
import json
from typing import Any, Dict, List
import logging
from typing import Any, Dict, List, Optional
from hanzo_mcp.server import HanzoMCPServer
from hanzoai.mcp import MCPClient, MCPClientError
from hanzoai.protocols import (
PermissionMode,
PermissionOutcome,
PermissionPolicy,
PermissionPrompter,
PermissionRequest,
TokenUsage,
UsageTracker,
)
from rich.console import Console
from rich.panel import Panel
logger = logging.getLogger(__name__)
class _DefaultPrompter:
"""Prompter that allows everything (bypass mode)."""
def decide(self, request: PermissionRequest) -> PermissionOutcome:
return PermissionOutcome.allow()
class ToolExecutor:
"""Execute MCP tools based on LLM requests."""
def __init__(self, mcp_server: HanzoMCPServer, backend):
def __init__(
self,
mcp_server: HanzoMCPServer,
backend,
permission_policy: Optional[PermissionPolicy] = None,
prompter: Optional[PermissionPrompter] = None,
):
self.mcp_server = mcp_server
self.backend = backend # Can be LLMClient or BackendManager
self.console = Console()
self.conversation_history = []
self.max_iterations = 10 # Prevent infinite loops
self.permission_policy = permission_policy or PermissionPolicy(
default_mode=PermissionMode.Allow,
)
self.prompter = prompter or _DefaultPrompter()
self.usage_tracker = UsageTracker()
self._mcp_clients: dict[str, MCPClient] = {}
self._mcp_tools: dict[str, dict[str, Any]] = {} # server_name -> {tool_name: schema}
# -- MCP client management ------------------------------------------------
async def register_mcp_server(
self,
server_name: str,
command: list[str],
env: dict[str, str] | None = None,
) -> int:
"""Connect to an MCP server subprocess via stdio and register its tools.
Returns the number of tools discovered.
"""
client = MCPClient(server_command=command, env=env)
try:
await client.connect()
except MCPClientError as exc:
logger.warning("Failed to connect to MCP server %s: %s", server_name, exc)
return 0
tools = await client.list_tools()
self._mcp_clients[server_name] = client
self._mcp_tools[server_name] = {t["name"]: t for t in tools}
return len(tools)
async def disconnect_mcp_servers(self) -> None:
"""Disconnect all registered MCP server clients."""
for client in self._mcp_clients.values():
try:
await client.disconnect()
except Exception:
pass
self._mcp_clients.clear()
self._mcp_tools.clear()
# -- Context management ----------------------------------------------------
def get_context(self) -> List[Dict[str, str]]:
"""Get current conversation context."""
@@ -27,7 +96,7 @@ class ToolExecutor:
self.conversation_history = []
def _format_tools_for_llm(self) -> List[Dict[str, Any]]:
"""Format MCP tools for LLM consumption."""
"""Format MCP tools for LLM consumption (local + remote MCP servers)."""
tools = []
for tool_name, tool in self.mcp_server.tools.items():
@@ -42,10 +111,39 @@ class ToolExecutor:
}
tools.append(tool_spec)
# Append tools from external MCP servers
for server_name, server_tools in self._mcp_tools.items():
for tname, tschema in server_tools.items():
canonical = f"mcp__{server_name}__{tname}"
tool_spec = {
"type": "function",
"function": {
"name": canonical,
"description": tschema.get("description", ""),
"parameters": tschema.get("inputSchema", {}),
},
}
tools.append(tool_spec)
return tools
async def _execute_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Any:
"""Execute a single MCP tool."""
"""Execute a single tool after checking permissions."""
input_str = json.dumps(arguments)
# Check permission policy before execution
outcome = self.permission_policy.authorize(
tool_name, input_str, self.prompter,
)
if not outcome.allowed:
msg = f"Tool '{tool_name}' denied: {outcome.reason}"
self.console.print(Panel(f"[bold red]{msg}[/bold red]", border_style="red"))
raise PermissionError(msg)
# Route to external MCP server if tool name matches mcp__server__tool
if tool_name.startswith("mcp__"):
return await self._execute_mcp_tool(tool_name, arguments)
if tool_name not in self.mcp_server.tools:
raise ValueError(f"Unknown tool: {tool_name}")
@@ -86,6 +184,45 @@ class ToolExecutor:
)
raise
async def _execute_mcp_tool(
self, canonical_name: str, arguments: Dict[str, Any]
) -> Any:
"""Execute a tool on an external MCP server via its client."""
# Parse mcp__server__tool
parts = canonical_name.split("__", 2)
if len(parts) != 3:
raise ValueError(f"Invalid MCP tool name: {canonical_name}")
_, server_name, tool_name = parts
client = self._mcp_clients.get(server_name)
if client is None:
raise ValueError(f"No MCP client for server: {server_name}")
self.console.print(
Panel(
f"[bold cyan]MCP Call:[/bold cyan] {server_name}/{tool_name}\n"
f"[dim]Arguments:[/dim] {json.dumps(arguments, indent=2)}",
border_style="blue",
)
)
try:
result = await client.call_tool(tool_name, arguments)
content = result.get("content", [])
text_parts = [c.get("text", "") for c in content if c.get("type") == "text"]
output = "\n".join(text_parts) if text_parts else json.dumps(result)
display = output[:500] + "... (truncated)" if len(output) > 500 else output
self.console.print(
Panel(f"[bold green]Result:[/bold green]\n{display}", border_style="green")
)
return output
except MCPClientError as e:
self.console.print(
Panel(f"[bold red]MCP Error:[/bold red] {e}", border_style="red")
)
raise
async def execute_with_tools(self, user_message: str) -> str:
"""Execute a user message with MCP tool support."""
# Add user message to history
@@ -209,6 +346,13 @@ Available tool categories:
{"role": "assistant", "content": final_response}
)
# Track token usage estimate (char_count / 4 heuristic)
est_input = len(str(messages)) // 4
est_output = len(final_response) // 4 if final_response else 0
self.usage_tracker.record(
TokenUsage(input_tokens=est_input, output_tokens=est_output)
)
break
if iterations >= self.max_iterations:
+221
View File
@@ -0,0 +1,221 @@
"""Vim keybinding support for Textual Input widget."""
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Protocol, runtime_checkable
class VimMode(Enum):
Normal = auto()
Insert = auto()
Visual = auto()
Command = auto()
@runtime_checkable
class InputWidget(Protocol):
@property
def cursor_position(self) -> int: ...
@cursor_position.setter
def cursor_position(self, value: int) -> None: ...
@property
def value(self) -> str: ...
@value.setter
def value(self, value: str) -> None: ...
@dataclass
class VimState:
mode: VimMode = VimMode.Normal
register: str = ""
visual_anchor: int = 0
command_buffer: str = ""
_pending: str = ""
_undo_stack: list[tuple[str, int]] = field(default_factory=list)
def handle_key(self, key: str, widget: InputWidget) -> bool | str:
"""Handle a keypress. Returns True if consumed, a command string, or False."""
handler = {
VimMode.Normal: self._normal, VimMode.Insert: self._insert,
VimMode.Visual: self._visual, VimMode.Command: self._command,
}.get(self.mode)
return handler(key, widget) if handler else False
# -- Normal mode --
def _normal(self, key: str, widget: InputWidget) -> bool | str:
val, pos = widget.value, widget.cursor_position
if self._pending:
return self._do_pending(key, widget)
# Motions
if key == "h":
widget.cursor_position = max(0, pos - 1); return True
if key == "l":
widget.cursor_position = min(len(val), pos + 1); return True
if key in ("j", "k"):
return True
if key == "0":
widget.cursor_position = 0; return True
if key == "$":
widget.cursor_position = len(val); return True
if key == "w":
widget.cursor_position = self._next_word(val, pos); return True
if key == "b":
widget.cursor_position = self._prev_word(val, pos); return True
if key == "e":
widget.cursor_position = self._end_word(val, pos); return True
# Insert mode entries
if key == "i":
self._save_undo(widget); self.mode = VimMode.Insert; return True
if key == "a":
self._save_undo(widget); self.mode = VimMode.Insert
widget.cursor_position = min(len(val), pos + 1); return True
if key == "I":
self._save_undo(widget); self.mode = VimMode.Insert
widget.cursor_position = 0; return True
if key == "A":
self._save_undo(widget); self.mode = VimMode.Insert
widget.cursor_position = len(val); return True
if key in ("o", "O"):
self._save_undo(widget); self.mode = VimMode.Insert
widget.cursor_position = len(val) if key == "o" else 0; return True
# Two-char starters
if key in ("d", "y", "g"):
self._pending = key; return True
# Single-char editing
if key == "x":
if pos < len(val):
self._save_undo(widget); self.register = val[pos]
widget.value = val[:pos] + val[pos + 1:]
widget.cursor_position = min(pos, max(0, len(widget.value) - 1))
return True
if key == "p" and self.register:
self._save_undo(widget); np = pos + 1
widget.value = val[:np] + self.register + val[np:]
widget.cursor_position = np + len(self.register) - 1; return True
if key == "u":
self._pop_undo(widget); return True
if key == "v":
self.mode = VimMode.Visual; self.visual_anchor = pos; return True
if key == ":":
self.mode = VimMode.Command; self.command_buffer = ""; return True
if key == "/":
self.mode = VimMode.Command; self.command_buffer = "/"; return True
if key == "escape":
return True
if key == "p":
return True
return False
def _do_pending(self, key: str, widget: InputWidget) -> bool:
seq, self._pending = self._pending + key, ""
val = widget.value
if seq == "dd":
self._save_undo(widget); self.register = val
widget.value = ""; widget.cursor_position = 0; return True
if seq == "yy":
self.register = val; return True
if seq == "gg":
widget.cursor_position = 0; return True
if seq[0] in ("d", "y"):
return self._op_motion(seq[0], key, widget)
return True
def _op_motion(self, op: str, motion: str, widget: InputWidget) -> bool:
val, pos = widget.value, widget.cursor_position
target = self._motion_target(motion, val, pos)
if target is None:
return True
start, end = (min(pos, target), max(pos, target))
self.register = val[start:end]
if op == "d":
self._save_undo(widget)
widget.value = val[:start] + val[end:]
widget.cursor_position = start
return True
def _motion_target(self, m: str, val: str, pos: int) -> int | None:
return {"w": self._next_word, "b": self._prev_word, "e": self._end_word}.get(m, lambda v, p: {"$": len(v), "0": 0}.get(m))(val, pos)
# -- Insert mode --
def _insert(self, key: str, widget: InputWidget) -> bool | str:
if key == "escape":
self.mode = VimMode.Normal
if widget.cursor_position > 0:
widget.cursor_position -= 1
return True
return False
# -- Visual mode --
def _visual(self, key: str, widget: InputWidget) -> bool | str:
val, pos = widget.value, widget.cursor_position
if key == "escape":
self.mode = VimMode.Normal; return True
if key == "h":
widget.cursor_position = max(0, pos - 1); return True
if key == "l":
widget.cursor_position = min(len(val), pos + 1); return True
if key in ("y", "d"):
start, end = min(self.visual_anchor, pos), max(self.visual_anchor, pos) + 1
self.register = val[start:end]
if key == "d":
self._save_undo(widget)
widget.value = val[:start] + val[end:]
widget.cursor_position = start
self.mode = VimMode.Normal; return True
return True
# -- Command mode --
def _command(self, key: str, widget: InputWidget) -> bool | str:
if key == "escape":
self.mode = VimMode.Normal; self.command_buffer = ""; return True
if key == "enter":
cmd = self.command_buffer; self.mode = VimMode.Normal
self.command_buffer = ""; return cmd if cmd else True
if key == "backspace":
if self.command_buffer:
self.command_buffer = self.command_buffer[:-1]
else:
self.mode = VimMode.Normal
return True
if len(key) == 1:
self.command_buffer += key
return True
# -- Word motion helpers --
@staticmethod
def _next_word(val: str, pos: int) -> int:
n, i = len(val), pos
while i < n and not val[i].isspace(): i += 1
while i < n and val[i].isspace(): i += 1
return i
@staticmethod
def _prev_word(val: str, pos: int) -> int:
if pos <= 0: return 0
i = pos - 1
while i > 0 and val[i].isspace(): i -= 1
while i > 0 and not val[i - 1].isspace(): i -= 1
return i
@staticmethod
def _end_word(val: str, pos: int) -> int:
n = len(val)
if pos >= n - 1: return max(0, n - 1)
i = pos + 1
while i < n and val[i].isspace(): i += 1
while i < n - 1 and not val[i + 1].isspace(): i += 1
return i
# -- Undo --
def _save_undo(self, w: InputWidget) -> None:
self._undo_stack.append((w.value, w.cursor_position))
def _pop_undo(self, w: InputWidget) -> None:
if self._undo_stack:
v, p = self._undo_stack.pop(); w.value = v; w.cursor_position = p
View File
+117
View File
@@ -0,0 +1,117 @@
"""Tests for git slash commands using a temporary git repo."""
import os
import subprocess
from pathlib import Path
import pytest
from hanzo_dev.git_commands import (
git_branch,
git_commit,
git_diff,
git_stash,
git_status,
git_worktree,
handle_git_command,
)
_ENV = {
**os.environ,
"GIT_AUTHOR_NAME": "test",
"GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "test",
"GIT_COMMITTER_EMAIL": "t@t",
}
def _run(*args: str, cwd: Path) -> None:
subprocess.run(args, cwd=cwd, capture_output=True, text=True, env=_ENV, check=True)
@pytest.fixture()
def git_repo(tmp_path: Path) -> Path:
"""Create a temporary git repo with one committed file."""
_run("git", "init", "-b", "main", cwd=tmp_path)
(tmp_path / "README.md").write_text("# test\n")
_run("git", "add", "README.md", cwd=tmp_path)
_run("git", "commit", "-m", "init", cwd=tmp_path)
return tmp_path
class TestGitStatus:
def test_clean_repo(self, git_repo: Path) -> None:
out = git_status(git_repo)
assert "nothing to commit" in out.lower() or "clean" in out.lower()
def test_dirty_repo(self, git_repo: Path) -> None:
(git_repo / "new.txt").write_text("hello\n")
out = git_status(git_repo)
assert "new.txt" in out
class TestGitBranch:
def test_list_branches(self, git_repo: Path) -> None:
out = git_branch("", git_repo)
assert "main" in out
def test_create_branch(self, git_repo: Path) -> None:
out = git_branch("feature-x", git_repo)
assert "feature-x" in out
listing = git_branch("", git_repo)
assert "feature-x" in listing
class TestGitCommit:
def test_commit_staged(self, git_repo: Path) -> None:
(git_repo / "a.txt").write_text("a\n")
out = git_commit("add a.txt", git_repo)
assert "add a.txt" in out or "1 file changed" in out or "create mode" in out
def test_commit_nothing(self, git_repo: Path) -> None:
out = git_commit("empty", git_repo)
assert "nothing" in out.lower() or "no changes" in out.lower()
class TestGitDiff:
def test_no_diff(self, git_repo: Path) -> None:
out = git_diff(git_repo)
assert out.strip() == "" or "no changes" in out.lower()
def test_has_diff(self, git_repo: Path) -> None:
(git_repo / "README.md").write_text("# changed\n")
out = git_diff(git_repo)
assert "changed" in out
class TestGitStash:
def test_stash_and_pop(self, git_repo: Path) -> None:
(git_repo / "README.md").write_text("# stashed\n")
out = git_stash("", git_repo)
assert "saved" in out.lower() or "stash" in out.lower()
list_out = git_stash("list", git_repo)
assert "stash@" in list_out or "stash" in list_out.lower()
git_stash("pop", git_repo)
assert "stashed" in (git_repo / "README.md").read_text()
def test_stash_nothing(self, git_repo: Path) -> None:
out = git_stash("", git_repo)
assert "no local changes" in out.lower() or "nothing" in out.lower() or "no changes" in out.lower()
class TestGitWorktree:
def test_list_worktrees(self, git_repo: Path) -> None:
out = git_worktree("", git_repo)
assert str(git_repo) in out or "main" in out
class TestDispatcher:
def test_known_command(self, git_repo: Path) -> None:
out = handle_git_command("status", "", git_repo)
assert out is not None
def test_unknown_command(self, git_repo: Path) -> None:
out = handle_git_command("frobnicate", "", git_repo)
assert out is None
+6
View File
@@ -0,0 +1,6 @@
"""hanzo-hooks: shell hook runner for pre/post tool-use lifecycle events."""
from .runner import HookRunner
from .types import HookConfig, HookEvent, HookRunResult
__all__ = ["HookConfig", "HookEvent", "HookRunner", "HookRunResult"]
+97
View File
@@ -0,0 +1,97 @@
"""HookRunner: execute shell commands around tool invocations."""
from __future__ import annotations
import json
import os
import subprocess
import sys
from .types import HookConfig, HookEvent, HookRunResult
class HookRunner:
__slots__ = ("_config",)
def __init__(self, config: HookConfig) -> None:
self._config = config
@classmethod
def from_settings(cls, path: str) -> HookRunner:
return cls(HookConfig.from_json(path))
def run_pre_tool_use(self, tool_name: str, tool_input: str) -> HookRunResult:
return self._run_commands(
HookEvent.PreToolUse, self._config.pre_tool_use, tool_name, tool_input,
)
def run_post_tool_use(
self, tool_name: str, tool_input: str, tool_output: str, is_error: bool = False,
) -> HookRunResult:
return self._run_commands(
HookEvent.PostToolUse, self._config.post_tool_use,
tool_name, tool_input, tool_output=tool_output, is_error=is_error,
)
def _run_commands(
self, event: HookEvent, commands: list[str], tool_name: str, tool_input: str,
tool_output: str | None = None, is_error: bool = False,
) -> HookRunResult:
if not commands:
return HookRunResult.allow()
try:
parsed_input = json.loads(tool_input)
except (json.JSONDecodeError, TypeError):
parsed_input = {"raw": tool_input}
payload = json.dumps({
"hook_event_name": event.value, "tool_name": tool_name,
"tool_input": parsed_input, "tool_input_json": tool_input,
"tool_output": tool_output, "tool_result_is_error": is_error,
})
env = {
"HOOK_EVENT": event.value, "HOOK_TOOL_NAME": tool_name,
"HOOK_TOOL_INPUT": tool_input, "HOOK_TOOL_IS_ERROR": "1" if is_error else "0",
}
if tool_output is not None:
env["HOOK_TOOL_OUTPUT"] = tool_output
messages: list[str] = []
for command in commands:
kind, msg = _run_one(command, event, tool_name, env, payload)
if kind == "allow":
if msg:
messages.append(msg)
elif kind == "deny":
messages.append(msg or f"{event.value} hook denied tool `{tool_name}`")
return HookRunResult(denied=True, messages=messages)
else:
messages.append(msg)
return HookRunResult.allow(messages)
def _run_one(
command: str, event: HookEvent, tool_name: str,
env: dict[str, str], payload: str,
) -> tuple[str, str]:
"""Returns (outcome_type, message). outcome_type: allow/deny/warn."""
args = ["cmd", "/C", command] if sys.platform == "win32" else ["sh", "-lc", command]
try:
proc = subprocess.run(
args, input=payload, capture_output=True, text=True,
env={**os.environ, **env},
)
except OSError as exc:
return ("warn", f"{event.value} hook `{command}` failed to start for `{tool_name}`: {exc}")
stdout, stderr = proc.stdout.strip(), proc.stderr.strip()
if proc.returncode == 0:
return ("allow", stdout)
if proc.returncode == 2:
return ("deny", stdout)
msg = f"Hook `{command}` exited with status {proc.returncode}; allowing tool execution to continue"
if stdout:
msg += f": {stdout}"
elif stderr:
msg += f": {stderr}"
return ("warn", msg)
+72
View File
@@ -0,0 +1,72 @@
"""Types for the hook runner system."""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from enum import Enum
class HookEvent(Enum):
PreToolUse = "PreToolUse"
PostToolUse = "PostToolUse"
@dataclass(frozen=True, slots=True)
class HookRunResult:
denied: bool
messages: list[str]
@classmethod
def allow(cls, messages: list[str] | None = None) -> HookRunResult:
return cls(denied=False, messages=messages or [])
def to_permission_outcome(self) -> object:
"""Convert to hanzoai.protocols.PermissionOutcome if available.
Returns a duck-typed object with .allowed and .reason when the
hanzoai package is not installed, so callers can use it without
a hard dependency.
"""
try:
from hanzoai.protocols import PermissionOutcome
except ImportError:
PermissionOutcome = None
if PermissionOutcome is not None:
if self.denied:
return PermissionOutcome.deny("; ".join(self.messages))
return PermissionOutcome.allow()
# Fallback: return a simple namespace matching the protocol.
if self.denied:
return _Outcome(allowed=False, reason="; ".join(self.messages))
return _Outcome(allowed=True, reason="")
@dataclass(frozen=True, slots=True)
class _Outcome:
"""Minimal stand-in for PermissionOutcome when hanzoai is not installed."""
allowed: bool
reason: str
@dataclass(frozen=True, slots=True)
class HookConfig:
"""Loadable from settings.json ``hooks`` key."""
pre_tool_use: list[str] = field(default_factory=list)
post_tool_use: list[str] = field(default_factory=list)
@classmethod
def from_dict(cls, d: dict) -> HookConfig:
return cls(
pre_tool_use=list(d.get("pre_tool_use") or d.get("PreToolUse") or []),
post_tool_use=list(d.get("post_tool_use") or d.get("PostToolUse") or []),
)
@classmethod
def from_json(cls, path: str) -> HookConfig:
with open(path) as f:
data = json.load(f)
hooks = data.get("hooks", data)
return cls.from_dict(hooks)
+26
View File
@@ -0,0 +1,26 @@
[build-system]
requires = ["setuptools>=61.0.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "hanzo-hooks"
version = "0.1.0"
description = "Shell hook runner for pre/post tool-use lifecycle events."
requires-python = ">=3.12"
license = { text = "MIT" }
authors = [{ name = "Hanzo Industries Inc", email = "[email protected]" }]
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
]
keywords = ["hanzo", "hooks", "tools", "ai"]
dependencies = []
[project.urls]
"Homepage" = "https://github.com/hanzoai/python-sdk"
"Bug Tracker" = "https://github.com/hanzoai/python-sdk/issues"
[tool.setuptools.packages.find]
where = ["."]
include = ["hanzo_hooks*"]
View File
+161
View File
@@ -0,0 +1,161 @@
"""Tests for hanzo_hooks."""
from __future__ import annotations
import json
import tempfile
from pathlib import Path
from hanzo_hooks import HookConfig, HookEvent, HookRunner, HookRunResult
class TestHookEvent:
def test_values(self):
assert HookEvent.PreToolUse.value == "PreToolUse"
assert HookEvent.PostToolUse.value == "PostToolUse"
class TestHookRunResult:
def test_allow(self):
r = HookRunResult.allow()
assert not r.denied
assert r.messages == []
def test_allow_with_messages(self):
r = HookRunResult.allow(["msg1", "msg2"])
assert not r.denied
assert r.messages == ["msg1", "msg2"]
def test_denied(self):
r = HookRunResult(denied=True, messages=["blocked"])
assert r.denied
assert r.messages == ["blocked"]
def test_to_permission_outcome_allow(self):
r = HookRunResult.allow()
outcome = r.to_permission_outcome()
assert outcome.allowed is True
def test_to_permission_outcome_deny(self):
r = HookRunResult(denied=True, messages=["reason A", "reason B"])
outcome = r.to_permission_outcome()
assert outcome.allowed is False
assert "reason A" in outcome.reason
assert "reason B" in outcome.reason
class TestHookConfig:
def test_from_dict(self):
cfg = HookConfig.from_dict({
"pre_tool_use": ["echo pre"],
"post_tool_use": ["echo post"],
})
assert cfg.pre_tool_use == ["echo pre"]
assert cfg.post_tool_use == ["echo post"]
def test_from_dict_camel_case(self):
cfg = HookConfig.from_dict({
"PreToolUse": ["echo pre"],
"PostToolUse": ["echo post"],
})
assert cfg.pre_tool_use == ["echo pre"]
assert cfg.post_tool_use == ["echo post"]
def test_from_dict_empty(self):
cfg = HookConfig.from_dict({})
assert cfg.pre_tool_use == []
assert cfg.post_tool_use == []
def test_from_json(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
json.dump({"hooks": {"pre_tool_use": ["echo hi"]}}, f)
f.flush()
cfg = HookConfig.from_json(f.name)
assert cfg.pre_tool_use == ["echo hi"]
assert cfg.post_tool_use == []
Path(f.name).unlink()
class TestHookRunner:
def test_no_commands_returns_allow(self):
runner = HookRunner(HookConfig())
result = runner.run_pre_tool_use("Read", '{"path":"README.md"}')
assert not result.denied
assert result.messages == []
def test_exit_zero_captures_stdout(self):
runner = HookRunner(HookConfig(pre_tool_use=["printf 'pre ok'"]))
result = runner.run_pre_tool_use("Read", '{"path":"README.md"}')
assert not result.denied
assert result.messages == ["pre ok"]
def test_exit_two_denies(self):
runner = HookRunner(HookConfig(pre_tool_use=["printf 'blocked'; exit 2"]))
result = runner.run_pre_tool_use("Bash", '{"command":"pwd"}')
assert result.denied
assert result.messages == ["blocked"]
def test_exit_two_without_stdout_uses_default_message(self):
runner = HookRunner(HookConfig(pre_tool_use=["exit 2"]))
result = runner.run_pre_tool_use("Bash", '{"command":"pwd"}')
assert result.denied
assert "denied" in result.messages[0]
assert "Bash" in result.messages[0]
def test_other_exit_code_warns_but_allows(self):
runner = HookRunner(HookConfig(pre_tool_use=["printf 'oops'; exit 1"]))
result = runner.run_pre_tool_use("Edit", '{"file":"lib.py"}')
assert not result.denied
assert len(result.messages) == 1
assert "allowing tool execution to continue" in result.messages[0]
def test_short_circuit_on_deny(self):
runner = HookRunner(HookConfig(pre_tool_use=[
"printf 'first ok'",
"printf 'deny'; exit 2",
"printf 'never reached'",
]))
result = runner.run_pre_tool_use("Bash", '{}')
assert result.denied
assert result.messages == ["first ok", "deny"]
def test_post_tool_use(self):
runner = HookRunner(HookConfig(post_tool_use=["printf 'post ok'"]))
result = runner.run_post_tool_use("Read", '{}', "file contents", is_error=False)
assert not result.denied
assert result.messages == ["post ok"]
def test_env_vars_passed(self):
runner = HookRunner(HookConfig(pre_tool_use=[
'printf "%s %s" "$HOOK_EVENT" "$HOOK_TOOL_NAME"'
]))
result = runner.run_pre_tool_use("Bash", '{"command":"ls"}')
assert not result.denied
assert result.messages == ["PreToolUse Bash"]
def test_stdin_payload(self):
runner = HookRunner(HookConfig(pre_tool_use=[
"""python3 -c "import sys, json; d=json.load(sys.stdin); print(d['tool_name'])" """
]))
result = runner.run_pre_tool_use("Grep", '{"pattern":"foo"}')
assert not result.denied
assert result.messages == ["Grep"]
def test_multiple_commands_collect_messages(self):
runner = HookRunner(HookConfig(pre_tool_use=[
"printf 'msg1'",
"printf 'msg2'",
]))
result = runner.run_pre_tool_use("Read", '{}')
assert not result.denied
assert result.messages == ["msg1", "msg2"]
def test_from_settings(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
json.dump({"hooks": {"pre_tool_use": ["printf 'from settings'"]}}, f)
f.flush()
runner = HookRunner.from_settings(f.name)
result = runner.run_pre_tool_use("Bash", '{}')
assert not result.denied
assert result.messages == ["from settings"]
Path(f.name).unlink()
+7
View File
@@ -0,0 +1,7 @@
"""hanzo-lsp: Async LSP client for managing language server subprocesses."""
from .client import LspClient, LspError
from .manager import LspManager
from .types import (Diagnostic, FileDiagnostics, LspContextEnrichment,
LspServerConfig, SymbolLocation, WorkspaceDiagnostics)
__all__ = ["Diagnostic", "FileDiagnostics", "LspClient", "LspContextEnrichment",
"LspError", "LspManager", "LspServerConfig", "SymbolLocation", "WorkspaceDiagnostics"]
+185
View File
@@ -0,0 +1,185 @@
"""Async LSP client -- Content-Length framed JSON-RPC over subprocess stdio."""
from __future__ import annotations
import asyncio, json, os
from pathlib import Path
from typing import Any
from .types import Diagnostic, LspServerConfig, SymbolLocation
class LspError(Exception):
pass
class LspClient:
def __init__(self, config: LspServerConfig) -> None:
self._cfg, self._proc, self._id = config, None, 1
self._pending: dict[int, asyncio.Future[Any]] = {}
self._diagnostics: dict[str, list[Diagnostic]] = {}
self._open: dict[Path, int] = {}
self._reader: asyncio.Task[None] | None = None
async def connect(self) -> None:
P = asyncio.subprocess.PIPE
self._proc = await asyncio.create_subprocess_exec(
self._cfg.command, *self._cfg.args, stdin=P, stdout=P, stderr=P,
cwd=str(self._cfg.workspace_root), env={**os.environ, **self._cfg.env})
if not self._proc.stdout or not self._proc.stdin:
raise LspError("failed to open LSP subprocess pipes")
self._reader = asyncio.get_event_loop().create_task(self._read_loop())
ws = self._cfg.workspace_root.as_uri()
await self._request("initialize", {
"processId": os.getpid(), "rootUri": ws,
"rootPath": str(self._cfg.workspace_root),
"workspaceFolders": [{"uri": ws, "name": self._cfg.name}],
"initializationOptions": self._cfg.initialization_options or {},
"capabilities": {
"textDocument": {"publishDiagnostics": {"relatedInformation": True},
"definition": {"linkSupport": True}, "references": {}},
"workspace": {"configuration": False, "workspaceFolders": True},
"general": {"positionEncodings": ["utf-16"]}},
})
await self._notify("initialized", {})
async def open_document(self, path: Path, text: str) -> None:
lang = self._cfg.language_id_for(path)
if lang is None:
raise LspError(f"no language mapping for {path}")
await self._notify("textDocument/didOpen", {
"textDocument": {"uri": path.as_uri(), "languageId": lang, "version": 1, "text": text},
})
self._open[path] = 1
async def ensure_open(self, path: Path) -> None:
if path not in self._open:
await self.open_document(path, path.read_text())
async def change_document(self, path: Path, text: str) -> None:
if path not in self._open:
return await self.open_document(path, text)
self._open[path] += 1
await self._notify("textDocument/didChange", {
"textDocument": {"uri": path.as_uri(), "version": self._open[path]},
"contentChanges": [{"text": text}],
})
async def save_document(self, path: Path) -> None:
if path in self._open:
await self._notify("textDocument/didSave", {"textDocument": {"uri": path.as_uri()}})
async def close_document(self, path: Path) -> None:
if path in self._open:
await self._notify("textDocument/didClose", {"textDocument": {"uri": path.as_uri()}})
del self._open[path]
async def go_to_definition(self, path: Path, line: int, char: int) -> list[SymbolLocation]:
await self.ensure_open(path)
p = {"textDocument": {"uri": path.as_uri()}, "position": {"line": line, "character": char}}
return _parse_locations(await self._request("textDocument/definition", p))
async def find_references(
self, path: Path, line: int, char: int, *, include_declaration: bool = True,
) -> list[SymbolLocation]:
await self.ensure_open(path)
p = {"textDocument": {"uri": path.as_uri()}, "position": {"line": line, "character": char},
"context": {"includeDeclaration": include_declaration}}
return _parse_locations(await self._request("textDocument/references", p))
def diagnostics_snapshot(self) -> dict[str, list[Diagnostic]]:
return dict(self._diagnostics)
async def shutdown(self) -> None:
try: await self._request("shutdown", {})
except Exception: pass
try: await self._notify("exit", None)
except Exception: pass
if self._proc:
try: self._proc.kill()
except ProcessLookupError: pass
await self._proc.wait()
if self._reader and not self._reader.done():
self._reader.cancel()
try: await self._reader
except asyncio.CancelledError: pass
async def _request(self, method: str, params: Any) -> Any:
rid = self._id; self._id += 1
fut: asyncio.Future[Any] = asyncio.get_event_loop().create_future()
self._pending[rid] = fut
try: await self._send({"jsonrpc": "2.0", "id": rid, "method": method, "params": params})
except Exception: self._pending.pop(rid, None); raise
return await fut
async def _notify(self, method: str, params: Any) -> None:
await self._send({"jsonrpc": "2.0", "method": method, "params": params})
async def _send(self, msg: dict[str, Any]) -> None:
assert self._proc and self._proc.stdin
b = json.dumps(msg).encode()
self._proc.stdin.write(f"Content-Length: {len(b)}\r\n\r\n".encode() + b)
await self._proc.stdin.drain()
async def _read_loop(self) -> None:
assert self._proc and self._proc.stdout
reader = self._proc.stdout
try:
while True:
msg = await _read_message(reader)
if msg is None: break
if "id" in msg and "method" not in msg:
fut = self._pending.pop(msg["id"], None)
if fut and not fut.done():
if "error" in msg:
fut.set_exception(LspError(json.dumps(msg["error"])))
else:
fut.set_result(msg.get("result"))
elif msg.get("method") == "textDocument/publishDiagnostics":
p = msg.get("params", {})
uri, raw = p.get("uri", ""), p.get("diagnostics", [])
if raw:
self._diagnostics[uri] = [_parse_diag(d) for d in raw]
else:
self._diagnostics.pop(uri, None)
except (asyncio.CancelledError, ConnectionError): pass
finally:
for f in self._pending.values():
if not f.done(): f.set_exception(LspError("LSP connection closed"))
self._pending.clear()
async def _read_message(reader: asyncio.StreamReader) -> dict[str, Any] | None:
length: int | None = None
while True:
line = await reader.readline()
if not line: return None
s = line.decode("utf-8")
if s == "\r\n": break
if ":" in s:
k, v = s.split(":", 1)
if k.strip().lower() == "content-length":
length = int(v.strip())
if length is None: raise LspError("missing Content-Length header")
return json.loads(await reader.readexactly(length))
def _parse_diag(raw: dict[str, Any]) -> Diagnostic:
r = raw.get("range", {}); s = r.get("start", {}); e = r.get("end", {})
return Diagnostic(
s.get("line", 0), s.get("character", 0), e.get("line", 0), e.get("character", 0),
raw.get("severity", 0), raw.get("message", ""), raw.get("source", ""),
)
def _parse_locations(result: Any) -> list[SymbolLocation]:
if result is None: return []
if isinstance(result, dict): result = [result]
out: list[SymbolLocation] = []
for item in result:
uri = item.get("targetUri") or item.get("uri")
r = item.get("targetSelectionRange") or item.get("range", {})
if not uri or not uri.startswith("file://"): continue
s = r.get("start", {}); e = r.get("end", {})
out.append(SymbolLocation(
Path(uri[7:]), s.get("line", 0), s.get("character", 0),
e.get("line", 0), e.get("character", 0),
))
return out
+92
View File
@@ -0,0 +1,92 @@
"""LspManager -- routes LSP requests by file extension."""
from __future__ import annotations
from pathlib import Path
from urllib.parse import unquote, urlparse
from .client import LspClient, LspError
from .types import (FileDiagnostics, LspContextEnrichment, LspServerConfig,
SymbolLocation, WorkspaceDiagnostics, _normalize_ext)
class LspManager:
def __init__(self, configs: list[LspServerConfig]) -> None:
self._configs: dict[str, LspServerConfig] = {}
self._ext_map: dict[str, str] = {}
self._clients: dict[str, LspClient] = {}
for cfg in configs:
for ext in cfg.extension_to_language:
norm = _normalize_ext(ext)
if norm in self._ext_map:
raise LspError(f"duplicate extension {norm}: {self._ext_map[norm]} and {cfg.name}")
self._ext_map[norm] = cfg.name
self._configs[cfg.name] = cfg
def supports_path(self, path: Path) -> bool:
return bool(path.suffix) and _normalize_ext(path.suffix) in self._ext_map
async def open_document(self, path: Path, text: str) -> None:
await (await self._client_for(path)).open_document(path, text)
async def sync_document_from_disk(self, path: Path) -> None:
c = await self._client_for(path)
await c.change_document(path, path.read_text())
await c.save_document(path)
async def change_document(self, path: Path, text: str) -> None:
await (await self._client_for(path)).change_document(path, text)
async def save_document(self, path: Path) -> None:
await (await self._client_for(path)).save_document(path)
async def close_document(self, path: Path) -> None:
await (await self._client_for(path)).close_document(path)
async def go_to_definition(self, path: Path, line: int, char: int) -> list[SymbolLocation]:
return _dedupe(await (await self._client_for(path)).go_to_definition(path, line, char))
async def find_references(
self, path: Path, line: int, char: int, *, include_declaration: bool = True,
) -> list[SymbolLocation]:
return _dedupe(await (await self._client_for(path)).find_references(
path, line, char, include_declaration=include_declaration))
async def collect_workspace_diagnostics(self) -> WorkspaceDiagnostics:
files: list[FileDiagnostics] = []
for c in self._clients.values():
for uri, ds in c.diagnostics_snapshot().items():
if not ds: continue
p = urlparse(uri)
if p.scheme == "file":
files.append(FileDiagnostics(Path(unquote(p.path)), uri, list(ds)))
files.sort(key=lambda f: f.path)
return WorkspaceDiagnostics(files=files)
async def context_enrichment(self, path: Path, line: int, char: int) -> LspContextEnrichment:
return LspContextEnrichment(
file_path=path, diagnostics=await self.collect_workspace_diagnostics(),
definitions=await self.go_to_definition(path, line, char),
references=await self.find_references(path, line, char))
async def shutdown(self) -> None:
for client in self._clients.values():
await client.shutdown()
self._clients.clear()
async def _client_for(self, path: Path) -> LspClient:
ext = _normalize_ext(path.suffix) if path.suffix else ""
name = self._ext_map.get(ext)
if not name: raise LspError(f"no LSP server for {path}")
if name not in self._clients:
client = LspClient(self._configs[name])
await client.connect()
self._clients[name] = client
return self._clients[name]
def _dedupe(locs: list[SymbolLocation]) -> list[SymbolLocation]:
seen: set[tuple[Path, int, int, int, int]] = set()
out: list[SymbolLocation] = []
for loc in locs:
key = (loc.path, loc.start_line, loc.start_character, loc.end_line, loc.end_character)
if key not in seen:
seen.add(key); out.append(loc)
return out
View File
+116
View File
@@ -0,0 +1,116 @@
"""LSP types: config, diagnostics, symbols, context enrichment."""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
_MAX_D, _MAX_L = 12, 12
_SEV = {1: "error", 2: "warning", 3: "info", 4: "hint"}
def _normalize_ext(ext: str) -> str:
ext = ext.lower()
return ext if ext.startswith(".") else f".{ext}"
@dataclass
class LspServerConfig:
name: str
command: str
args: list[str] = field(default_factory=list)
env: dict[str, str] = field(default_factory=dict)
workspace_root: Path = field(default_factory=Path.cwd)
initialization_options: dict[str, Any] | None = None
extension_to_language: dict[str, str] = field(default_factory=dict)
def language_id_for(self, path: Path) -> str | None:
return self.extension_to_language.get(_normalize_ext(path.suffix)) if path.suffix else None
@dataclass
class Diagnostic:
range_start_line: int
range_start_char: int
range_end_line: int
range_end_char: int
severity: int
message: str
source: str = ""
@property
def severity_label(self) -> str:
return _SEV.get(self.severity, "unknown")
@dataclass
class FileDiagnostics:
path: Path
uri: str
diagnostics: list[Diagnostic] = field(default_factory=list)
@dataclass
class WorkspaceDiagnostics:
files: list[FileDiagnostics] = field(default_factory=list)
@property
def is_empty(self) -> bool:
return not self.files
@property
def total_diagnostics(self) -> int:
return sum(len(f.diagnostics) for f in self.files)
@dataclass
class SymbolLocation:
path: Path
start_line: int
start_character: int
end_line: int
end_character: int
@property
def display_line(self) -> int:
return self.start_line + 1
@property
def display_character(self) -> int:
return self.start_character + 1
def __str__(self) -> str:
return f"{self.path}:{self.display_line}:{self.display_character}"
@dataclass
class LspContextEnrichment:
file_path: Path = field(default_factory=lambda: Path("."))
diagnostics: WorkspaceDiagnostics = field(default_factory=WorkspaceDiagnostics)
definitions: list[SymbolLocation] = field(default_factory=list)
references: list[SymbolLocation] = field(default_factory=list)
@property
def is_empty(self) -> bool:
return self.diagnostics.is_empty and not self.definitions and not self.references
def render_prompt_section(self) -> str:
o = ["# LSP context", f" - Focus file: {self.file_path}",
f" - Workspace diagnostics: {self.diagnostics.total_diagnostics}"
f" across {len(self.diagnostics.files)} file(s)"]
if self.diagnostics.files:
o += ["", "Diagnostics:"]
n = 0
for fd in self.diagnostics.files:
for d in fd.diagnostics:
if n >= _MAX_D:
o.append(" - Additional diagnostics omitted for brevity."); break
o.append(f" - {fd.path}:{d.range_start_line+1}:{d.range_start_char+1}"
f" [{d.severity_label}] {d.message.replace(chr(10), ' ')}")
n += 1
if n >= _MAX_D: break
for label, locs in [("Definitions", self.definitions), ("References", self.references)]:
if not locs: continue
o += ["", f"{label}:"] + [f" - {l}" for l in locs[:_MAX_L]]
if len(locs) > _MAX_L:
o.append(f" - Additional {label.lower()} omitted for brevity.")
return "\n".join(o)
+35
View File
@@ -0,0 +1,35 @@
[build-system]
requires = ["setuptools>=61.0.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "hanzo-lsp"
version = "0.1.0"
description = "Async LSP client for managing language server subprocesses"
readme = "README.md"
requires-python = ">=3.12"
license = { text = "MIT" }
authors = [{ name = "Hanzo Industries Inc", email = "[email protected]" }]
keywords = ["hanzo", "lsp", "language-server", "code-intelligence"]
dependencies = [
"hanzoai>=2.2.0",
]
[project.optional-dependencies]
test = [
"pytest>=7.0.0",
"pytest-asyncio>=0.26.0,<1.0.0",
]
[tool.setuptools.packages.find]
where = ["."]
include = ["hanzo_lsp*"]
[tool.setuptools.package-data]
hanzo_lsp = ["py.typed"]
[tool.pytest.ini_options]
addopts = "--no-header --no-summary -p asyncio"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
testpaths = ["tests"]
View File
+113
View File
@@ -0,0 +1,113 @@
"""Mock LSP server for integration tests. Speaks Content-Length framed JSON-RPC."""
import json
import sys
def read_message():
headers = {}
while True:
line = sys.stdin.buffer.readline()
if not line:
return None
if line == b"\r\n":
break
key, value = line.decode("utf-8").split(":", 1)
headers[key.strip().lower()] = value.strip()
length = int(headers["content-length"])
body = sys.stdin.buffer.read(length)
return json.loads(body)
def write_message(payload):
raw = json.dumps(payload).encode("utf-8")
sys.stdout.buffer.write(f"Content-Length: {len(raw)}\r\n\r\n".encode("utf-8"))
sys.stdout.buffer.write(raw)
sys.stdout.buffer.flush()
while True:
message = read_message()
if message is None:
break
method = message.get("method")
if method == "initialize":
write_message({
"jsonrpc": "2.0",
"id": message["id"],
"result": {
"capabilities": {
"definitionProvider": True,
"referencesProvider": True,
"textDocumentSync": 1,
}
},
})
elif method == "initialized":
continue
elif method == "textDocument/didOpen":
document = message["params"]["textDocument"]
write_message({
"jsonrpc": "2.0",
"method": "textDocument/publishDiagnostics",
"params": {
"uri": document["uri"],
"diagnostics": [
{
"range": {
"start": {"line": 0, "character": 0},
"end": {"line": 0, "character": 3},
},
"severity": 1,
"source": "mock-server",
"message": "mock error",
}
],
},
})
elif method == "textDocument/didChange":
continue
elif method == "textDocument/didSave":
continue
elif method == "textDocument/definition":
uri = message["params"]["textDocument"]["uri"]
write_message({
"jsonrpc": "2.0",
"id": message["id"],
"result": [
{
"uri": uri,
"range": {
"start": {"line": 0, "character": 0},
"end": {"line": 0, "character": 3},
},
}
],
})
elif method == "textDocument/references":
uri = message["params"]["textDocument"]["uri"]
write_message({
"jsonrpc": "2.0",
"id": message["id"],
"result": [
{
"uri": uri,
"range": {
"start": {"line": 0, "character": 0},
"end": {"line": 0, "character": 3},
},
},
{
"uri": uri,
"range": {
"start": {"line": 1, "character": 4},
"end": {"line": 1, "character": 7},
},
},
],
})
elif method == "shutdown":
write_message({"jsonrpc": "2.0", "id": message["id"], "result": None})
elif method == "exit":
break
+110
View File
@@ -0,0 +1,110 @@
"""Integration tests for hanzo-lsp using mock LSP server."""
from __future__ import annotations
import asyncio
import shutil
import sys
import tempfile
from pathlib import Path
import pytest
from hanzo_lsp import LspManager, LspServerConfig
MOCK_SERVER = str(Path(__file__).parent / "mock_lsp_server.py")
def _make_config(workspace: Path) -> LspServerConfig:
return LspServerConfig(
name="mock",
command=sys.executable,
args=[MOCK_SERVER],
workspace_root=workspace,
extension_to_language={".py": "python", ".rs": "rust"},
)
async def _wait_diagnostics(manager: LspManager, timeout: float = 2.0) -> None:
deadline = asyncio.get_event_loop().time() + timeout
while asyncio.get_event_loop().time() < deadline:
ws = await manager.collect_workspace_diagnostics()
if ws.total_diagnostics > 0:
return
await asyncio.sleep(0.01)
raise TimeoutError("diagnostics never arrived")
@pytest.fixture
def workspace(tmp_path: Path) -> Path:
src = tmp_path / "src"
src.mkdir()
return tmp_path
class TestLspManagerIntegration:
async def test_diagnostics_and_navigation(self, workspace: Path) -> None:
source = workspace / "src" / "main.py"
source.write_text("x = undefined_var\ny = x + 1\n")
manager = LspManager([_make_config(workspace)])
await manager.open_document(source, source.read_text())
await _wait_diagnostics(manager)
diags = await manager.collect_workspace_diagnostics()
assert len(diags.files) == 1
assert diags.total_diagnostics == 1
assert diags.files[0].diagnostics[0].severity == 1
assert diags.files[0].diagnostics[0].message == "mock error"
defs = await manager.go_to_definition(source, 0, 0)
assert len(defs) == 1
assert defs[0].display_line == 1
refs = await manager.find_references(source, 0, 0)
assert len(refs) == 2
assert refs[0].display_line == 1
assert refs[1].display_line == 2
await manager.shutdown()
async def test_context_enrichment_render(self, workspace: Path) -> None:
source = workspace / "src" / "lib.py"
source.write_text("def answer(): return 42\n")
manager = LspManager([_make_config(workspace)])
await manager.open_document(source, source.read_text())
await _wait_diagnostics(manager)
enrichment = await manager.context_enrichment(source, 0, 0)
rendered = enrichment.render_prompt_section()
assert "# LSP context" in rendered
assert "Workspace diagnostics: 1 across 1 file(s)" in rendered
assert "Diagnostics:" in rendered
assert "mock error" in rendered
assert "Definitions:" in rendered
assert "References:" in rendered
await manager.shutdown()
async def test_supports_path(self, workspace: Path) -> None:
manager = LspManager([_make_config(workspace)])
assert manager.supports_path(Path("foo.py"))
assert manager.supports_path(Path("bar.rs"))
assert not manager.supports_path(Path("baz.txt"))
assert not manager.supports_path(Path("no_ext"))
async def test_duplicate_extension_raises(self, workspace: Path) -> None:
from hanzo_lsp import LspError
cfg1 = LspServerConfig(
name="a", command="x", workspace_root=workspace,
extension_to_language={".py": "python"},
)
cfg2 = LspServerConfig(
name="b", command="y", workspace_root=workspace,
extension_to_language={".py": "python"},
)
with pytest.raises(LspError, match="duplicate extension"):
LspManager([cfg1, cfg2])
-281
View File
@@ -1,281 +0,0 @@
"""Main REPL implementation for Hanzo MCP testing."""
import asyncio
import json
import os
import sys
from typing import Any, Dict, Optional
from hanzo_mcp.server import HanzoMCPServer
from prompt_toolkit import PromptSession
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.completion import WordCompleter
from prompt_toolkit.history import FileHistory
from rich.console import Console
from rich.markdown import Markdown
from rich.panel import Panel
from rich.table import Table
from .llm_client import LLMClient
from .tool_executor import ToolExecutor
class HanzoREPL:
"""Interactive REPL for testing Hanzo MCP tools."""
def __init__(self, config: Optional[Dict[str, Any]] = None):
self.console = Console()
self.config = config or {}
self.mcp_server = None
self.llm_client = None
self.tool_executor = None
self.session = None
self.history_file = os.path.expanduser("~/.hanzo_repl_history")
# REPL commands
self.commands = {
"/help": self.show_help,
"/tools": self.list_tools,
"/exit": self.exit_repl,
"/quit": self.exit_repl,
"/clear": self.clear_screen,
"/providers": self.list_providers,
"/model": self.set_model,
"/context": self.show_context,
"/reset": self.reset_context,
"/test": self.run_tests,
}
async def initialize(self):
"""Initialize MCP server and LLM client."""
self.console.print("[bold green]Initializing Hanzo REPL...[/bold green]")
# Initialize MCP server
self.console.print("Loading MCP server...")
self.mcp_server = HanzoMCPServer()
await self.mcp_server.initialize()
# Initialize LLM client
self.console.print("Detecting available LLM providers...")
self.llm_client = LLMClient()
available_providers = self.llm_client.get_available_providers()
if not available_providers:
self.console.print("[bold red]No LLM API keys detected![/bold red]")
self.console.print("Please set one of the following environment variables:")
self.console.print("- OPENAI_API_KEY")
self.console.print("- ANTHROPIC_API_KEY")
self.console.print("- GROQ_API_KEY")
self.console.print("- etc.")
sys.exit(1)
self.console.print(
f"[green]Available providers: {', '.join(available_providers)}[/green]"
)
# Initialize tool executor
self.tool_executor = ToolExecutor(self.mcp_server, self.llm_client)
# Initialize prompt session
tool_names = [tool.name for tool in self.mcp_server.tools.values()]
completer = WordCompleter(
list(self.commands.keys()) + tool_names, ignore_case=True
)
self.session = PromptSession(
history=FileHistory(self.history_file),
auto_suggest=AutoSuggestFromHistory(),
completer=completer,
)
self.console.print("[bold green]REPL initialized successfully![/bold green]")
self.console.print(f"Using model: [cyan]{self.llm_client.current_model}[/cyan]")
self.console.print("Type [bold]/help[/bold] for available commands.")
async def run(self):
"""Run the main REPL loop."""
await self.initialize()
while True:
try:
# Get user input
user_input = await self.session.prompt_async("hanzo> ", multiline=False)
if not user_input.strip():
continue
# Check for commands
if user_input.startswith("/"):
command = user_input.split()[0]
if command in self.commands:
await self.commands[command](user_input)
else:
self.console.print(f"[red]Unknown command: {command}[/red]")
continue
# Process as chat with MCP tools
await self.process_chat(user_input)
except KeyboardInterrupt:
self.console.print("\n[yellow]Use /exit to quit[/yellow]")
except EOFError:
await self.exit_repl("")
except Exception as e:
self.console.print(f"[red]Error: {e}[/red]")
if self.config.get("debug"):
self.console.print_exception()
async def process_chat(self, message: str):
"""Process a chat message with MCP tool support."""
self.console.print()
# Send to LLM with available tools
try:
response = await self.tool_executor.execute_with_tools(message)
# Display response
if isinstance(response, str):
self.console.print(Markdown(response))
else:
self.console.print(response)
except Exception as e:
self.console.print(f"[red]Error processing chat: {e}[/red]")
if self.config.get("debug"):
self.console.print_exception()
async def show_help(self, _):
"""Show help information."""
help_text = """
# Hanzo REPL Commands
- **/help** - Show this help message
- **/tools** - List available MCP tools
- **/providers** - List available LLM providers
- **/model <model>** - Set the LLM model to use
- **/context** - Show current conversation context
- **/reset** - Reset conversation context
- **/test** - Run MCP tool tests
- **/clear** - Clear the screen
- **/exit** or **/quit** - Exit the REPL
## Usage
Simply type your message and the REPL will:
1. Send it to the configured LLM
2. Execute any MCP tools the LLM requests
3. Display the results
## Examples
- "Read the file README.md"
- "Search for Python files containing 'async'"
- "Run the command 'ls -la'"
- "Create a new file called test.txt with 'Hello World'"
"""
self.console.print(Markdown(help_text))
async def list_tools(self, _):
"""List available MCP tools."""
table = Table(title="Available MCP Tools")
table.add_column("Tool", style="cyan", no_wrap=True)
table.add_column("Description", style="white")
table.add_column("Category", style="green")
for tool_name, tool in sorted(self.mcp_server.tools.items()):
category = tool.__class__.__module__.split(".")[-1]
table.add_row(tool_name, tool.description[:60] + "...", category)
self.console.print(table)
self.console.print(f"\nTotal tools: [bold]{len(self.mcp_server.tools)}[/bold]")
async def list_providers(self, _):
"""List available LLM providers."""
providers = self.llm_client.get_available_providers()
models = self.llm_client.get_available_models()
table = Table(title="Available LLM Providers")
table.add_column("Provider", style="cyan")
table.add_column("Models", style="white")
table.add_column("Status", style="green")
for provider in providers:
provider_models = [m for m in models if m.startswith(provider)]
status = "Active" if provider == self.llm_client.current_provider else ""
table.add_row(
provider,
", ".join(provider_models[:3])
+ ("..." if len(provider_models) > 3 else ""),
status,
)
self.console.print(table)
self.console.print(
f"\nCurrent model: [bold cyan]{self.llm_client.current_model}[/bold cyan]"
)
async def set_model(self, command: str):
"""Set the LLM model to use."""
parts = command.split(maxsplit=1)
if len(parts) < 2:
self.console.print("[red]Usage: /model <model_name>[/red]")
self.console.print("Available models:")
for model in self.llm_client.get_available_models():
self.console.print(f" - {model}")
return
model = parts[1]
try:
self.llm_client.set_model(model)
self.console.print(f"[green]Model set to: {model}[/green]")
except ValueError as e:
self.console.print(f"[red]Error: {e}[/red]")
async def show_context(self, _):
"""Show current conversation context."""
context = self.tool_executor.get_context()
if not context:
self.console.print("[yellow]No conversation context yet[/yellow]")
return
self.console.print(
Panel(
json.dumps(context, indent=2),
title="Conversation Context",
border_style="blue",
)
)
async def reset_context(self, _):
"""Reset conversation context."""
self.tool_executor.reset_context()
self.console.print("[green]Conversation context reset[/green]")
async def run_tests(self, _):
"""Run MCP tool tests."""
self.console.print("[bold]Running MCP tool tests...[/bold]")
# Import and run tests
from .tests import run_tool_tests
await run_tool_tests(self.console, self.mcp_server, self.tool_executor)
async def clear_screen(self, _):
"""Clear the screen."""
os.system( # noqa: S605
"cls" if os.name == "nt" else "clear"
) # Safe screen clear command
async def exit_repl(self, _):
"""Exit the REPL."""
self.console.print("[yellow]Goodbye![/yellow]")
sys.exit(0)
async def main():
"""Main entry point."""
repl = HanzoREPL()
await repl.run()
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,29 @@
from hanzo_sandbox.sandbox import (
ContainerEnvironment,
FilesystemIsolationMode,
LinuxSandboxCommand,
SandboxConfig,
SandboxDetectionInputs,
SandboxRequest,
SandboxStatus,
build_linux_sandbox_command,
detect_container_environment,
detect_container_environment_from,
resolve_sandbox_status,
resolve_sandbox_status_for_request,
)
__all__ = [
"ContainerEnvironment",
"FilesystemIsolationMode",
"LinuxSandboxCommand",
"SandboxConfig",
"SandboxDetectionInputs",
"SandboxRequest",
"SandboxStatus",
"build_linux_sandbox_command",
"detect_container_environment",
"detect_container_environment_from",
"resolve_sandbox_status",
"resolve_sandbox_status_for_request",
]
+209
View File
@@ -0,0 +1,209 @@
"""Linux sandbox isolation via unshare -- ported from claw-code sandbox.rs."""
from __future__ import annotations
import os
import shutil
import sys
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
class FilesystemIsolationMode(Enum):
OFF = "off"
WORKSPACE_ONLY = "workspace-only"
ALLOW_LIST = "allow-list"
@dataclass
class SandboxConfig:
enabled: bool | None = None
namespace_restrictions: bool | None = None
network_isolation: bool | None = None
filesystem_mode: FilesystemIsolationMode | None = None
allowed_mounts: list[str] = field(default_factory=list)
def resolve_request(
self,
enabled_override: bool | None = None,
namespace_override: bool | None = None,
network_override: bool | None = None,
filesystem_mode_override: FilesystemIsolationMode | None = None,
allowed_mounts_override: list[str] | None = None,
) -> SandboxRequest:
enabled = enabled_override if enabled_override is not None else (self.enabled if self.enabled is not None else True)
ns = namespace_override if namespace_override is not None else (self.namespace_restrictions if self.namespace_restrictions is not None else True)
net = network_override if network_override is not None else (self.network_isolation if self.network_isolation is not None else False)
fs_mode = filesystem_mode_override or self.filesystem_mode or FilesystemIsolationMode.WORKSPACE_ONLY
mounts = allowed_mounts_override if allowed_mounts_override is not None else list(self.allowed_mounts)
return SandboxRequest(
enabled=enabled,
namespace_restrictions=ns,
network_isolation=net,
filesystem_mode=fs_mode,
allowed_mounts=mounts,
)
@dataclass
class SandboxRequest:
enabled: bool = True
namespace_restrictions: bool = True
network_isolation: bool = False
filesystem_mode: FilesystemIsolationMode = FilesystemIsolationMode.WORKSPACE_ONLY
allowed_mounts: list[str] = field(default_factory=list)
@dataclass
class ContainerEnvironment:
in_container: bool = False
markers: list[str] = field(default_factory=list)
@dataclass
class SandboxDetectionInputs:
env_pairs: list[tuple[str, str]] = field(default_factory=list)
dockerenv_exists: bool = False
containerenv_exists: bool = False
proc_1_cgroup: str | None = None
@dataclass
class SandboxStatus:
enabled: bool = False
requested: SandboxRequest = field(default_factory=SandboxRequest)
supported: bool = False
active: bool = False
namespace_supported: bool = False
namespace_active: bool = False
network_supported: bool = False
network_active: bool = False
filesystem_mode: FilesystemIsolationMode = FilesystemIsolationMode.WORKSPACE_ONLY
filesystem_active: bool = False
allowed_mounts: list[str] = field(default_factory=list)
in_container: bool = False
container_markers: list[str] = field(default_factory=list)
fallback_reason: str | None = None
@dataclass
class LinuxSandboxCommand:
program: str = ""
args: list[str] = field(default_factory=list)
env: list[tuple[str, str]] = field(default_factory=list)
_CONTAINER_ENV_KEYS = frozenset({"container", "docker", "podman", "kubernetes_service_host"})
_CGROUP_NEEDLES = ("docker", "containerd", "kubepods", "podman", "libpod")
def detect_container_environment() -> ContainerEnvironment:
proc_1_cgroup: str | None = None
try:
proc_1_cgroup = Path("/proc/1/cgroup").read_text()
except OSError:
pass
return detect_container_environment_from(SandboxDetectionInputs(
env_pairs=list(os.environ.items()),
dockerenv_exists=Path("/.dockerenv").exists(),
containerenv_exists=Path("/run/.containerenv").exists(),
proc_1_cgroup=proc_1_cgroup,
))
def detect_container_environment_from(inputs: SandboxDetectionInputs) -> ContainerEnvironment:
markers: list[str] = []
if inputs.dockerenv_exists:
markers.append("/.dockerenv")
if inputs.containerenv_exists:
markers.append("/run/.containerenv")
for key, value in inputs.env_pairs:
if key.lower() in _CONTAINER_ENV_KEYS and value:
markers.append(f"env:{key}={value}")
if inputs.proc_1_cgroup is not None:
for needle in _CGROUP_NEEDLES:
if needle in inputs.proc_1_cgroup:
markers.append(f"/proc/1/cgroup:{needle}")
markers = sorted(set(markers))
return ContainerEnvironment(in_container=bool(markers), markers=markers)
def _command_exists(name: str) -> bool:
return shutil.which(name) is not None
def _is_linux() -> bool:
return sys.platform == "linux"
def _normalize_mounts(mounts: list[str], cwd: Path) -> list[str]:
result: list[str] = []
for mount in mounts:
p = Path(mount)
result.append(str(p if p.is_absolute() else cwd / p))
return result
def resolve_sandbox_status(config: SandboxConfig, cwd: Path) -> SandboxStatus:
request = config.resolve_request()
return resolve_sandbox_status_for_request(request, cwd)
def resolve_sandbox_status_for_request(request: SandboxRequest, cwd: Path) -> SandboxStatus:
container = detect_container_environment()
namespace_supported = _is_linux() and _command_exists("unshare")
network_supported = namespace_supported
filesystem_active = request.enabled and request.filesystem_mode != FilesystemIsolationMode.OFF
fallback_reasons: list[str] = []
if request.enabled and request.namespace_restrictions and not namespace_supported:
fallback_reasons.append("namespace isolation unavailable (requires Linux with `unshare`)")
if request.enabled and request.network_isolation and not network_supported:
fallback_reasons.append("network isolation unavailable (requires Linux with `unshare`)")
if request.enabled and request.filesystem_mode == FilesystemIsolationMode.ALLOW_LIST and not request.allowed_mounts:
fallback_reasons.append("filesystem allow-list requested without configured mounts")
active = request.enabled and (not request.namespace_restrictions or namespace_supported) and (not request.network_isolation or network_supported)
allowed_mounts = _normalize_mounts(request.allowed_mounts, cwd)
return SandboxStatus(
enabled=request.enabled,
requested=request,
supported=namespace_supported,
active=active,
namespace_supported=namespace_supported,
namespace_active=request.enabled and request.namespace_restrictions and namespace_supported,
network_supported=network_supported,
network_active=request.enabled and request.network_isolation and network_supported,
filesystem_mode=request.filesystem_mode,
filesystem_active=filesystem_active,
allowed_mounts=allowed_mounts,
in_container=container.in_container,
container_markers=container.markers,
fallback_reason="; ".join(fallback_reasons) if fallback_reasons else None,
)
def build_linux_sandbox_command(command: str, cwd: Path, status: SandboxStatus) -> LinuxSandboxCommand | None:
if not _is_linux() or not status.enabled or (not status.namespace_active and not status.network_active):
return None
args = ["--user", "--map-root-user", "--mount", "--ipc", "--pid", "--uts", "--fork"]
if status.network_active:
args.append("--net")
args.extend(["sh", "-lc", command])
sandbox_home = str(cwd / ".sandbox-home")
sandbox_tmp = str(cwd / ".sandbox-tmp")
env: list[tuple[str, str]] = [
("HOME", sandbox_home),
("TMPDIR", sandbox_tmp),
("SANDBOX_FILESYSTEM_MODE", status.filesystem_mode.value),
("SANDBOX_ALLOWED_MOUNTS", ":".join(status.allowed_mounts)),
]
path = os.environ.get("PATH")
if path is not None:
env.append(("PATH", path))
return LinuxSandboxCommand(program="unshare", args=args, env=env)
+27
View File
@@ -0,0 +1,27 @@
[build-system]
requires = ["setuptools>=61.0.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "hanzo-sandbox"
version = "0.1.0"
description = "Linux sandbox isolation for Hanzo agent runtimes (unshare, namespace, network, filesystem)."
readme = "README.md"
requires-python = ">=3.12"
license = { text = "MIT" }
authors = [{ name = "Hanzo Industries Inc", email = "[email protected]" }]
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
]
keywords = ["hanzo", "sandbox", "isolation", "unshare"]
dependencies = []
[project.urls]
"Homepage" = "https://github.com/hanzoai/python-sdk"
"Bug Tracker" = "https://github.com/hanzoai/python-sdk/issues"
[tool.setuptools.packages.find]
where = ["."]
include = ["hanzo_sandbox*"]
+217
View File
@@ -0,0 +1,217 @@
"""Tests ported from claw-code sandbox.rs test suite."""
from pathlib import Path
from hanzo_sandbox import (
ContainerEnvironment,
FilesystemIsolationMode,
SandboxConfig,
SandboxDetectionInputs,
SandboxRequest,
build_linux_sandbox_command,
detect_container_environment_from,
resolve_sandbox_status_for_request,
)
def test_detects_container_markers_from_multiple_sources():
detected = detect_container_environment_from(SandboxDetectionInputs(
env_pairs=[("container", "docker")],
dockerenv_exists=True,
containerenv_exists=False,
proc_1_cgroup="12:memory:/docker/abc",
))
assert detected.in_container
assert "/.dockerenv" in detected.markers
assert "env:container=docker" in detected.markers
assert "/proc/1/cgroup:docker" in detected.markers
def test_no_container_when_no_markers():
detected = detect_container_environment_from(SandboxDetectionInputs(
env_pairs=[],
dockerenv_exists=False,
containerenv_exists=False,
proc_1_cgroup=None,
))
assert not detected.in_container
assert detected.markers == []
def test_containerenv_marker():
detected = detect_container_environment_from(SandboxDetectionInputs(
env_pairs=[],
dockerenv_exists=False,
containerenv_exists=True,
proc_1_cgroup=None,
))
assert detected.in_container
assert "/run/.containerenv" in detected.markers
def test_kubernetes_env_detected():
detected = detect_container_environment_from(SandboxDetectionInputs(
env_pairs=[("KUBERNETES_SERVICE_HOST", "10.0.0.1")],
dockerenv_exists=False,
containerenv_exists=False,
proc_1_cgroup=None,
))
assert detected.in_container
assert "env:KUBERNETES_SERVICE_HOST=10.0.0.1" in detected.markers
def test_empty_env_value_ignored():
detected = detect_container_environment_from(SandboxDetectionInputs(
env_pairs=[("DOCKER", "")],
dockerenv_exists=False,
containerenv_exists=False,
proc_1_cgroup=None,
))
assert not detected.in_container
def test_cgroup_multiple_needles():
detected = detect_container_environment_from(SandboxDetectionInputs(
env_pairs=[],
dockerenv_exists=False,
containerenv_exists=False,
proc_1_cgroup="1:name=systemd:/kubepods/burstable/containerd/abc",
))
assert detected.in_container
assert "/proc/1/cgroup:kubepods" in detected.markers
assert "/proc/1/cgroup:containerd" in detected.markers
def test_markers_sorted_and_deduped():
detected = detect_container_environment_from(SandboxDetectionInputs(
env_pairs=[("DOCKER", "1"), ("container", "docker")],
dockerenv_exists=True,
containerenv_exists=True,
proc_1_cgroup="12:memory:/docker/abc",
))
assert detected.markers == sorted(set(detected.markers))
def test_resolves_request_defaults():
config = SandboxConfig()
request = config.resolve_request()
assert request.enabled is True
assert request.namespace_restrictions is True
assert request.network_isolation is False
assert request.filesystem_mode == FilesystemIsolationMode.WORKSPACE_ONLY
assert request.allowed_mounts == []
def test_resolves_request_with_overrides():
config = SandboxConfig(
enabled=True,
namespace_restrictions=True,
network_isolation=False,
filesystem_mode=FilesystemIsolationMode.WORKSPACE_ONLY,
allowed_mounts=["logs"],
)
request = config.resolve_request(
enabled_override=True,
namespace_override=False,
network_override=True,
filesystem_mode_override=FilesystemIsolationMode.ALLOW_LIST,
allowed_mounts_override=["tmp"],
)
assert request.enabled is True
assert request.namespace_restrictions is False
assert request.network_isolation is True
assert request.filesystem_mode == FilesystemIsolationMode.ALLOW_LIST
assert request.allowed_mounts == ["tmp"]
def test_config_values_used_when_no_overrides():
config = SandboxConfig(
enabled=False,
namespace_restrictions=False,
network_isolation=True,
filesystem_mode=FilesystemIsolationMode.OFF,
allowed_mounts=["/data"],
)
request = config.resolve_request()
assert request.enabled is False
assert request.namespace_restrictions is False
assert request.network_isolation is True
assert request.filesystem_mode == FilesystemIsolationMode.OFF
assert request.allowed_mounts == ["/data"]
def test_sandbox_status_unsupported_on_non_linux():
import sys
request = SandboxRequest(enabled=True, namespace_restrictions=True, network_isolation=True)
status = resolve_sandbox_status_for_request(request, Path("/workspace"))
if sys.platform != "linux":
assert not status.supported
assert not status.active
assert not status.namespace_active
assert not status.network_active
assert status.fallback_reason is not None
assert "unshare" in status.fallback_reason
def test_sandbox_disabled_means_not_active():
request = SandboxRequest(enabled=False)
status = resolve_sandbox_status_for_request(request, Path("/workspace"))
assert not status.active
assert not status.namespace_active
assert not status.network_active
assert not status.filesystem_active
def test_filesystem_active_unless_off():
request = SandboxRequest(enabled=True, filesystem_mode=FilesystemIsolationMode.WORKSPACE_ONLY)
status = resolve_sandbox_status_for_request(request, Path("/workspace"))
assert status.filesystem_active
request_off = SandboxRequest(enabled=True, filesystem_mode=FilesystemIsolationMode.OFF)
status_off = resolve_sandbox_status_for_request(request_off, Path("/workspace"))
assert not status_off.filesystem_active
def test_allowlist_without_mounts_warns():
request = SandboxRequest(
enabled=True,
filesystem_mode=FilesystemIsolationMode.ALLOW_LIST,
allowed_mounts=[],
)
status = resolve_sandbox_status_for_request(request, Path("/workspace"))
assert status.fallback_reason is not None
assert "allow-list" in status.fallback_reason
def test_normalize_mounts_relative_resolved():
request = SandboxRequest(enabled=True, allowed_mounts=["logs", "/absolute/path"])
status = resolve_sandbox_status_for_request(request, Path("/workspace"))
assert "/workspace/logs" in status.allowed_mounts
assert "/absolute/path" in status.allowed_mounts
def test_build_returns_none_on_non_linux():
import sys
if sys.platform != "linux":
from hanzo_sandbox import SandboxStatus
status = SandboxStatus(
enabled=True,
namespace_active=True,
network_active=True,
filesystem_mode=FilesystemIsolationMode.WORKSPACE_ONLY,
)
result = build_linux_sandbox_command("echo hi", Path("/workspace"), status)
assert result is None
def test_build_returns_none_when_disabled():
from hanzo_sandbox import SandboxStatus
status = SandboxStatus(enabled=False)
result = build_linux_sandbox_command("echo hi", Path("/workspace"), status)
assert result is None
def test_enum_values():
assert FilesystemIsolationMode.OFF.value == "off"
assert FilesystemIsolationMode.WORKSPACE_ONLY.value == "workspace-only"
assert FilesystemIsolationMode.ALLOW_LIST.value == "allow-list"
+5 -7
View File
@@ -52,22 +52,20 @@ def chat_command(
os.environ["HANZO_SYSTEM_PROMPT"] = system
if ipython:
from hanzo_repl.ipython_repl import main
from hanzo_dev.ipython_repl import main
sys.exit(main())
elif tui:
from hanzo_repl.textual_repl import main
from hanzo_dev.textual_repl import main
sys.exit(main())
else:
from hanzo_repl.cli import main
from hanzo_dev.cli import main
sys.exit(main())
except ImportError:
console.print("[red]Error:[/red] hanzo-repl not installed")
console.print("Install with: pip install hanzo[repl]")
console.print("\nAlternatively:")
console.print(" pip install hanzo-repl")
console.print("[red]Error:[/red] hanzo-dev not installed")
console.print("Install with: pip install hanzo-dev")
return
prompt_text = " ".join(prompt) if prompt else None
+14 -14
View File
@@ -33,11 +33,11 @@ def start(ctx, model: str, local: bool, ipython: bool, tui: bool, voice: bool):
os.environ["HANZO_ENABLE_VOICE"] = "true"
if ipython:
from hanzo_repl.ipython_repl import main
from hanzo_dev.ipython_repl import main
elif tui:
from hanzo_repl.textual_repl import main
from hanzo_dev.textual_repl import main
else:
from hanzo_repl.cli import main
from hanzo_dev.cli import main
console.print("[cyan]Starting Hanzo REPL...[/cyan]")
console.print("All MCP tools available. Type 'help' for commands.\n")
@@ -45,8 +45,8 @@ def start(ctx, model: str, local: bool, ipython: bool, tui: bool, voice: bool):
sys.exit(main())
except ImportError:
console.print("[red]Error:[/red] hanzo-repl not installed")
console.print("Install with: pip install hanzo[repl]")
console.print("[red]Error:[/red] hanzo-dev not installed")
console.print("Install with: pip install hanzo-dev")
console.print("\nFeatures:")
console.print(" • Direct access to 70+ MCP tools")
console.print(" • Chat with AI that can use tools")
@@ -61,9 +61,9 @@ def info(ctx):
"""Show REPL information and status."""
try:
import hanzo_mcp
from hanzo_repl import __version__
from hanzo_dev import __version__
console.print("[cyan]Hanzo REPL[/cyan]")
console.print("[cyan]Hanzo Dev[/cyan]")
console.print(f" Version: {__version__}")
console.print(f" MCP Tools: {len(hanzo_mcp.get_all_tools())}")
@@ -110,7 +110,7 @@ def info(ctx):
)
except ImportError:
console.print("[red]Error:[/red] hanzo-repl not installed")
console.print("[red]Error:[/red] hanzo-dev not installed")
@repl_group.command()
@@ -144,7 +144,7 @@ def install_extras(ctx, interface: str):
console.print(f"[green]✓[/green] Installed {interface} components")
except subprocess.CalledProcessError:
console.print(f"[red]Failed to install components[/red]")
console.print("Try manually: pip install hanzo-repl[voice]")
console.print("Try manually: pip install hanzo-dev[voice]")
@repl_group.command()
@@ -156,7 +156,7 @@ def exec(ctx, command: tuple, model: str):
try:
import asyncio
from hanzo_repl import create_repl
from hanzo_dev import create_repl
repl = create_repl(model=model)
command_str = " ".join(command)
@@ -170,7 +170,7 @@ def exec(ctx, command: tuple, model: str):
except ImportError as e:
console.print(f"[red]Import Error:[/red] {e}")
console.print(
"[yellow]Note:[/yellow] hanzo-repl may not be installed correctly"
"[yellow]Note:[/yellow] hanzo-dev may not be installed correctly"
)
except Exception as e:
console.print(f"[red]Error: {e}[/red]")
@@ -181,13 +181,13 @@ def exec(ctx, command: tuple, model: str):
def demo(ctx):
"""Run REPL demo showcasing features."""
try:
from hanzo_repl.demos import run_demo
from hanzo_dev.demos import run_demo
console.print("[cyan]Running Hanzo REPL demo...[/cyan]\n")
console.print("[cyan]Running Hanzo Dev demo...[/cyan]\n")
run_demo()
except ImportError:
console.print("[red]Error:[/red] hanzo-repl not installed")
console.print("[red]Error:[/red] hanzo-dev not installed")
console.print("\nThe demo would show:")
console.print(" • File operations with MCP tools")
console.print(" • Code search and analysis")
+1 -1
View File
@@ -17,7 +17,7 @@ class HanzoREPL:
def __init__(self, console: Optional[Console] = None):
self.console = console or Console()
self.session = PromptSession(
history=FileHistory(".hanzo_repl_history"),
history=FileHistory(".hanzo_dev_history"),
auto_suggest=AutoSuggestFromHistory(),
)
self.commands = {
+260 -43
View File
@@ -1,10 +1,13 @@
"""Authentication module for Hanzo AI.
Supports multiple authentication methods:
1. API Key (HANZO_API_KEY environment variable)
1. API Key (HANZO_API_KEY, ANTHROPIC_API_KEY, OPENAI_API_KEY)
2. Email/Password via IAM (iam.hanzo.ai - Casdoor)
3. SSO authentication
4. MCP flow authentication
4. Anthropic OAuth (PKCE via console.anthropic.com)
5. OpenAI/ChatGPT OAuth (device code via auth.openai.com)
6. MCP flow authentication
7. Auto-detect (login_auto)
"""
import base64
@@ -118,8 +121,21 @@ class OAuthCredentialStore:
Respects HANZO_CONFIG_HOME env var. Uses atomic writes.
Preserves other keys in the credentials file.
Supports multiple providers via the `provider` parameter:
- "hanzo" -> stored under "oauth" key (backwards compatible)
- "anthropic" -> stored under "oauth_anthropic" key
- "openai" -> stored under "oauth_openai" key
"""
# Map provider names to JSON keys. Default "hanzo" uses "oauth"
# for backwards compatibility with existing credentials files.
_PROVIDER_KEYS = {
"hanzo": "oauth",
"anthropic": "oauth_anthropic",
"openai": "oauth_openai",
}
def __init__(self, config_home: Optional[str] = None):
self._config_home = config_home
@@ -131,51 +147,51 @@ class OAuthCredentialStore:
return Path(env)
return Path.home() / ".hanzo"
def _key_for(self, provider: str) -> str:
key = self._PROVIDER_KEYS.get(provider)
if key is None:
raise ValueError(f"unknown provider: {provider!r}")
return key
def credentials_path(self) -> Path:
return self._get_config_home() / "credentials.json"
def save(self, token_set: OAuthTokenSet) -> None:
"""Save OAuth token set. Atomic write, preserves other keys."""
def _read_file(self) -> Dict[str, Any]:
path = self.credentials_path()
if not path.exists():
return {}
with open(path, "r") as f:
return json.load(f)
def _write_file(self, data: Dict[str, Any]) -> None:
path = self.credentials_path()
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_suffix(".json.tmp")
with open(tmp_path, "w") as f:
json.dump(data, f, indent=2)
os.replace(tmp_path, path)
if sys.platform != "win32":
os.chmod(path, 0o600)
# Read existing data
existing: Dict[str, Any] = {}
if path.exists():
with open(path, "r") as f:
existing = json.load(f)
existing["oauth"] = {
def save(self, token_set: OAuthTokenSet, provider: str = "hanzo") -> None:
"""Save OAuth token set. Atomic write, preserves other keys."""
key = self._key_for(provider)
existing = self._read_file()
existing[key] = {
"access_token": token_set.access_token,
"refresh_token": token_set.refresh_token,
"expires_at": token_set.expires_at,
"scopes": token_set.scopes,
}
self._write_file(existing)
# Atomic write: write to .tmp then rename
tmp_path = path.with_suffix(".json.tmp")
with open(tmp_path, "w") as f:
json.dump(existing, f, indent=2)
os.replace(tmp_path, path)
# Restrictive permissions
if sys.platform != "win32":
os.chmod(path, 0o600)
def load(self) -> Optional[OAuthTokenSet]:
def load(self, provider: str = "hanzo") -> Optional[OAuthTokenSet]:
"""Load OAuth token set from credentials file. Returns None if absent."""
path = self.credentials_path()
if not path.exists():
return None
with open(path, "r") as f:
data = json.load(f)
oauth = data.get("oauth")
key = self._key_for(provider)
data = self._read_file()
oauth = data.get(key)
if not oauth:
return None
return OAuthTokenSet(
access_token=oauth["access_token"],
refresh_token=oauth.get("refresh_token"),
@@ -183,25 +199,33 @@ class OAuthCredentialStore:
scopes=oauth.get("scopes", []),
)
def clear(self) -> None:
"""Remove oauth key from credentials, preserving other keys."""
def clear(self, provider: str = "hanzo") -> None:
"""Remove provider's oauth key from credentials, preserving other keys."""
key = self._key_for(provider)
path = self.credentials_path()
if not path.exists():
return
data = self._read_file()
data.pop(key, None)
self._write_file(data)
with open(path, "r") as f:
data = json.load(f)
data.pop("oauth", None)
# ---------------------------------------------------------------------------
# Provider constants (matching codex-rs/login/src/auth/manager.rs)
# ---------------------------------------------------------------------------
tmp_path = path.with_suffix(".json.tmp")
with open(tmp_path, "w") as f:
json.dump(data, f, indent=2)
# OpenAI / ChatGPT
OPENAI_ISSUER = "https://auth.openai.com"
OPENAI_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
os.replace(tmp_path, path)
# Anthropic / Claude
ANTHROPIC_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
ANTHROPIC_ISSUER = "https://claude.ai"
ANTHROPIC_TOKEN_URL = "https://platform.claude.com/v1/oauth/token"
ANTHROPIC_AUTHORIZE_URL = "https://claude.ai/oauth/authorize"
if sys.platform != "win32":
os.chmod(path, 0o600)
# Hanzo IAM
HANZO_CLIENT_ID = "hanzo-dev"
class HanzoAuth:
@@ -536,6 +560,199 @@ class HanzoAuth:
return token_set
async def login_with_anthropic(
self,
redirect_port: int = 4545,
) -> OAuthTokenSet:
"""Login with Anthropic console OAuth (PKCE flow).
Opens browser to claude.ai/oauth/authorize, catches callback,
exchanges code for tokens via platform.claude.com.
Args:
redirect_port: Local port for redirect callback (default 4545)
Returns:
OAuthTokenSet with access token
"""
token_set = await self.login_with_pkce(
authorize_url=ANTHROPIC_AUTHORIZE_URL,
token_url=ANTHROPIC_TOKEN_URL,
client_id=ANTHROPIC_CLIENT_ID,
scopes=["openid", "profile", "email"],
redirect_port=redirect_port,
)
# Also save under anthropic provider key
store = OAuthCredentialStore()
store.save(token_set, provider="anthropic")
return token_set
async def login_with_openai(
self,
open_browser: bool = True,
poll_interval: float = 5.0,
timeout: float = 900.0,
) -> OAuthTokenSet:
"""Login with OpenAI/ChatGPT device code flow.
Requests a device code from auth.openai.com, displays a user code,
and polls until the user completes authentication.
Args:
open_browser: Whether to open browser automatically
poll_interval: Seconds between poll attempts (default 5)
timeout: Max seconds to wait (default 900 / 15 min)
Returns:
OAuthTokenSet with access token
"""
import time
base_url = OPENAI_ISSUER.rstrip("/")
api_url = f"{base_url}/api/accounts"
async with httpx.AsyncClient() as client:
# Step 1: Request device code
response = await client.post(
f"{api_url}/deviceauth/usercode",
json={"client_id": OPENAI_CLIENT_ID},
)
response.raise_for_status()
data = response.json()
device_auth_id = data["device_auth_id"]
user_code = data["user_code"]
interval = float(data.get("interval", poll_interval))
verification_url = f"{base_url}/codex/device"
# Step 2: Display instructions
print(f"\nTo sign in with ChatGPT, visit:")
print(f" {verification_url}")
print(f"\nEnter code: {user_code}")
print(f"(expires in 15 minutes)\n")
if open_browser:
try:
webbrowser.open(verification_url)
except Exception:
pass
# Step 3: Poll for completion
start_time = time.time()
while time.time() - start_time < timeout:
await asyncio.sleep(interval)
poll_resp = await client.post(
f"{api_url}/deviceauth/token",
json={
"device_auth_id": device_auth_id,
"user_code": user_code,
},
)
if poll_resp.status_code == 200:
code_data = poll_resp.json()
# Exchange the authorization code for tokens
pkce = PkceCodePair(
verifier=code_data["code_verifier"],
challenge=code_data["code_challenge"],
)
redirect_uri = f"{base_url}/deviceauth/callback"
exchange_resp = await client.post(
f"{base_url}/oauth/token",
data={
"grant_type": "authorization_code",
"code": code_data["authorization_code"],
"redirect_uri": redirect_uri,
"client_id": OPENAI_CLIENT_ID,
"code_verifier": pkce.verifier,
},
)
exchange_resp.raise_for_status()
token_data = exchange_resp.json()
token_set = OAuthTokenSet(
access_token=token_data["access_token"],
refresh_token=token_data.get("refresh_token"),
expires_at=token_data.get("expires_at"),
scopes=token_data.get("scope", "openid profile email").split(),
)
self._token = token_set.access_token
store = OAuthCredentialStore()
store.save(token_set, provider="openai")
print("Authentication successful.\n")
return token_set
if poll_resp.status_code in (403, 404):
# User hasn't completed auth yet
continue
# Unexpected error
poll_resp.raise_for_status()
raise RuntimeError("OpenAI device code authentication timed out.")
async def login_auto(self) -> Dict[str, Any]:
"""Auto-detect authentication provider.
Priority:
1. HANZO_API_KEY env var
2. ANTHROPIC_API_KEY env var
3. OPENAI_API_KEY env var
4. Interactive prompt to choose provider
Returns:
Dict with auth info (token or api_key, and provider name)
"""
# Check env vars in priority order
if api_key := os.environ.get("HANZO_API_KEY"):
self.api_key = api_key
return {"provider": "hanzo", "api_key": api_key}
if api_key := os.environ.get("ANTHROPIC_API_KEY"):
self.api_key = api_key
return {"provider": "anthropic", "api_key": api_key}
if api_key := os.environ.get("OPENAI_API_KEY"):
self.api_key = api_key
return {"provider": "openai", "api_key": api_key}
# Check saved credentials
store = OAuthCredentialStore()
for provider in ("hanzo", "anthropic", "openai"):
token_set = store.load(provider=provider)
if token_set:
self._token = token_set.access_token
return {"provider": provider, "token": token_set.access_token}
# Interactive: prompt user to choose
print("\nNo API key or saved credentials found.")
print("Choose authentication provider:")
print(" 1. Hanzo (hanzo.id)")
print(" 2. Anthropic (console.anthropic.com)")
print(" 3. OpenAI (ChatGPT)")
choice = input("\nEnter choice [1/2/3]: ").strip()
if choice == "1":
token_set = await self.login_with_pkce()
return {"provider": "hanzo", "token": token_set.access_token}
elif choice == "2":
token_set = await self.login_with_anthropic()
return {"provider": "anthropic", "token": token_set.access_token}
elif choice == "3":
token_set = await self.login_with_openai()
return {"provider": "openai", "token": token_set.access_token}
else:
raise ValueError(f"invalid choice: {choice!r}")
async def logout(self):
"""Logout and clear credentials."""
if self._token:
+7 -7
View File
@@ -70,7 +70,7 @@ members = [
"pkg/hanzo-mcp",
"pkg/hanzo-memory",
"pkg/hanzo-network",
"pkg/hanzo-repl",
"pkg/hanzo-dev-py",
"pkg/hanzo-s3",
"pkg/hanzo-tools-ui",
"pkg/hanzo-zap",
@@ -208,7 +208,7 @@ exclude = [
"pkg/hanzo-memory",
"pkg/hanzo-network",
"pkg/hanzo-node",
"pkg/hanzo-repl",
"pkg/hanzo-dev-py",
"pkg/hanzo-tools",
"pkg/hanzo-tools-agent",
"pkg/hanzo-tools-api",
@@ -469,7 +469,7 @@ exclude = [
"pkg/hanzo-mcp/",
"pkg/hanzo-memory/",
"pkg/hanzo-network/",
"pkg/hanzo-repl/",
"pkg/hanzo-dev-py/",
"pkg/hanzo/",
# Non-production code
"bin/",
@@ -502,10 +502,10 @@ hanzo-tools-ui = { workspace = true }
"**/__main__.py" = ["T201", "T203"]
# Specific file ignores
"pkg/hanzo-memory/src/hanzo_memory/db/base.py" = ["B027"]
"pkg/hanzo-repl/src/hanzo_repl/backends.py" = ["S603"]
"pkg/hanzo-repl/src/hanzo_repl/repl.py" = ["S605"]
"pkg/hanzo-repl/src/hanzo_repl/tests.py" = ["ARG001"]
"pkg/hanzo-repl/src/hanzo_repl/ipython_repl.py" = ["ARG002"]
"pkg/hanzo-dev-py/src/hanzo_dev/backends.py" = ["S603"]
"pkg/hanzo-dev-py/src/hanzo_dev/repl.py" = ["S605"]
"pkg/hanzo-dev-py/src/hanzo_dev/tests.py" = ["ARG001"]
"pkg/hanzo-dev-py/src/hanzo_dev/ipython_repl.py" = ["ARG002"]
"**/cli.py" = ["T201", "T203"]
# Test files
"**/test_*.py" = ["F401", "F841"]
+1 -1
View File
@@ -24,7 +24,7 @@ if [ ! -f "pyproject.toml" ] || [ ! -d "pkg" ]; then
fi
# Packages to build
PACKAGES=("hanzoai" "hanzo" "hanzo-mcp" "hanzo-memory" "hanzo-network" "hanzo-agents" "hanzo-repl")
PACKAGES=("hanzoai" "hanzo" "hanzo-mcp" "hanzo-memory" "hanzo-network" "hanzo-agents" "hanzo-dev")
echo -e "${YELLOW}Packages to build:${NC}"
for pkg in "${PACKAGES[@]}"; do
+1 -1
View File
@@ -31,7 +31,7 @@ if [ -z "$PYPI_TOKEN" ]; then
fi
# Packages to publish (in order)
PACKAGES=("hanzoai" "hanzo" "hanzo-mcp" "hanzo-memory" "hanzo-network" "hanzo-agents" "hanzo-repl")
PACKAGES=("hanzoai" "hanzo" "hanzo-mcp" "hanzo-memory" "hanzo-network" "hanzo-agents" "hanzo-dev")
echo -e "${YELLOW}Packages to publish:${NC}"
for pkg in "${PACKAGES[@]}"; do
+1 -1
View File
@@ -21,7 +21,7 @@ if [ $# -eq 0 ]; then
echo " • hanzo-memory (memory systems)"
echo " • hanzo-network (distributed compute)"
echo " • hanzo-agents (multi-agent workflows)"
echo " • hanzo-repl (interactive REPL)"
echo " • hanzo-dev (interactive dev environment)"
exit 1
fi
+199 -1
View File
@@ -11,14 +11,21 @@ import stat
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from unittest.mock import AsyncMock, MagicMock, patch
from urllib.parse import parse_qs, urlparse
from hanzoai.auth import (
ANTHROPIC_AUTHORIZE_URL,
ANTHROPIC_CLIENT_ID,
ANTHROPIC_TOKEN_URL,
HANZO_CLIENT_ID,
HanzoAuth,
OAuthAuthorizationRequest,
OAuthCredentialStore,
OAuthTokenExchangeRequest,
OAuthTokenSet,
OPENAI_CLIENT_ID,
OPENAI_ISSUER,
PkceCodePair,
generate_pkce_pair,
generate_state,
@@ -309,5 +316,196 @@ class TestOAuthCredentialStore(unittest.TestCase):
self.assertTrue(store.credentials_path().exists())
class TestOAuthCredentialStoreMultiProvider(unittest.TestCase):
"""Tests for multi-provider credential storage."""
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
self.store = OAuthCredentialStore(config_home=self.tmpdir)
def test_save_load_anthropic_provider(self):
token_set = OAuthTokenSet(
access_token="anth-tok", scopes=["openid"], refresh_token="anth-ref"
)
self.store.save(token_set, provider="anthropic")
loaded = self.store.load(provider="anthropic")
self.assertIsNotNone(loaded)
self.assertEqual(loaded.access_token, "anth-tok")
self.assertEqual(loaded.refresh_token, "anth-ref")
def test_save_load_openai_provider(self):
token_set = OAuthTokenSet(
access_token="oai-tok", scopes=["openid"], expires_at=123456
)
self.store.save(token_set, provider="openai")
loaded = self.store.load(provider="openai")
self.assertIsNotNone(loaded)
self.assertEqual(loaded.access_token, "oai-tok")
self.assertEqual(loaded.expires_at, 123456)
def test_default_provider_is_hanzo(self):
token_set = OAuthTokenSet(access_token="hz-tok", scopes=["openid"])
self.store.save(token_set) # no provider arg
loaded = self.store.load() # no provider arg
self.assertIsNotNone(loaded)
self.assertEqual(loaded.access_token, "hz-tok")
def test_providers_are_independent(self):
"""Saving to one provider does not affect another."""
self.store.save(
OAuthTokenSet(access_token="hz", scopes=["a"]), provider="hanzo"
)
self.store.save(
OAuthTokenSet(access_token="anth", scopes=["b"]), provider="anthropic"
)
self.store.save(
OAuthTokenSet(access_token="oai", scopes=["c"]), provider="openai"
)
self.assertEqual(self.store.load(provider="hanzo").access_token, "hz")
self.assertEqual(self.store.load(provider="anthropic").access_token, "anth")
self.assertEqual(self.store.load(provider="openai").access_token, "oai")
def test_clear_only_affects_target_provider(self):
self.store.save(
OAuthTokenSet(access_token="hz", scopes=["a"]), provider="hanzo"
)
self.store.save(
OAuthTokenSet(access_token="anth", scopes=["b"]), provider="anthropic"
)
self.store.clear(provider="hanzo")
self.assertIsNone(self.store.load(provider="hanzo"))
self.assertEqual(self.store.load(provider="anthropic").access_token, "anth")
def test_backwards_compatible_json_key(self):
"""Default hanzo provider uses 'oauth' key in JSON for backwards compat."""
self.store.save(
OAuthTokenSet(access_token="hz", scopes=["a"]), provider="hanzo"
)
with open(self.store.credentials_path()) as f:
data = json.load(f)
self.assertIn("oauth", data)
self.assertEqual(data["oauth"]["access_token"], "hz")
def test_anthropic_uses_distinct_json_key(self):
self.store.save(
OAuthTokenSet(access_token="anth", scopes=["a"]), provider="anthropic"
)
with open(self.store.credentials_path()) as f:
data = json.load(f)
self.assertIn("oauth_anthropic", data)
self.assertNotIn("oauth", data)
def test_openai_uses_distinct_json_key(self):
self.store.save(
OAuthTokenSet(access_token="oai", scopes=["a"]), provider="openai"
)
with open(self.store.credentials_path()) as f:
data = json.load(f)
self.assertIn("oauth_openai", data)
self.assertNotIn("oauth", data)
def test_unknown_provider_raises(self):
with self.assertRaises(ValueError):
self.store.save(
OAuthTokenSet(access_token="x", scopes=["a"]), provider="github"
)
with self.assertRaises(ValueError):
self.store.load(provider="github")
with self.assertRaises(ValueError):
self.store.clear(provider="github")
def test_load_returns_none_for_missing_provider(self):
self.store.save(
OAuthTokenSet(access_token="hz", scopes=["a"]), provider="hanzo"
)
self.assertIsNone(self.store.load(provider="anthropic"))
self.assertIsNone(self.store.load(provider="openai"))
class TestProviderConstants(unittest.TestCase):
"""Verify provider constants match the Rust codex-rs values."""
def test_openai_client_id(self):
self.assertEqual(OPENAI_CLIENT_ID, "app_EMoamEEZ73f0CkXaXp7hrann")
def test_openai_issuer(self):
self.assertEqual(OPENAI_ISSUER, "https://auth.openai.com")
def test_anthropic_client_id(self):
self.assertEqual(ANTHROPIC_CLIENT_ID, "9d1c250a-e61b-44d9-88ed-5944d1962f5e")
def test_anthropic_authorize_url(self):
self.assertEqual(ANTHROPIC_AUTHORIZE_URL, "https://claude.ai/oauth/authorize")
def test_anthropic_token_url(self):
self.assertEqual(ANTHROPIC_TOKEN_URL, "https://platform.claude.com/v1/oauth/token")
def test_hanzo_client_id(self):
self.assertEqual(HANZO_CLIENT_ID, "hanzo-dev")
class TestLoginAuto(unittest.TestCase):
"""Tests for HanzoAuth.login_auto() env var detection."""
def test_hanzo_api_key_takes_priority(self):
import asyncio
auth = HanzoAuth()
env = {
"HANZO_API_KEY": "hk-test",
"ANTHROPIC_API_KEY": "sk-ant-test",
"OPENAI_API_KEY": "sk-test",
}
with patch.dict(os.environ, env):
result = asyncio.run(auth.login_auto())
self.assertEqual(result["provider"], "hanzo")
self.assertEqual(result["api_key"], "hk-test")
self.assertEqual(auth.api_key, "hk-test")
def test_anthropic_api_key_second_priority(self):
import asyncio
auth = HanzoAuth()
env = {"ANTHROPIC_API_KEY": "sk-ant-test"}
with patch.dict(os.environ, env, clear=False):
# Ensure HANZO_API_KEY is not set
os.environ.pop("HANZO_API_KEY", None)
os.environ.pop("OPENAI_API_KEY", None)
result = asyncio.run(auth.login_auto())
self.assertEqual(result["provider"], "anthropic")
self.assertEqual(result["api_key"], "sk-ant-test")
def test_openai_api_key_third_priority(self):
import asyncio
auth = HanzoAuth()
env = {"OPENAI_API_KEY": "sk-test"}
with patch.dict(os.environ, env, clear=False):
os.environ.pop("HANZO_API_KEY", None)
os.environ.pop("ANTHROPIC_API_KEY", None)
result = asyncio.run(auth.login_auto())
self.assertEqual(result["provider"], "openai")
self.assertEqual(result["api_key"], "sk-test")
def test_saved_credentials_used_when_no_env(self):
import asyncio
tmpdir = tempfile.mkdtemp()
store = OAuthCredentialStore(config_home=tmpdir)
store.save(
OAuthTokenSet(access_token="saved-anth", scopes=["openid"]),
provider="anthropic",
)
auth = HanzoAuth()
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("HANZO_API_KEY", None)
os.environ.pop("ANTHROPIC_API_KEY", None)
os.environ.pop("OPENAI_API_KEY", None)
# Patch the OAuthCredentialStore used inside login_auto
with patch("hanzoai.auth.OAuthCredentialStore", return_value=store):
result = asyncio.run(auth.login_auto())
self.assertEqual(result["provider"], "anthropic")
self.assertEqual(result["token"], "saved-anth")
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -162,7 +162,7 @@ class TestLoad:
"gamma": {"command": "g", "transport": "stdio"},
}
})
cfg = ConfigLoader(cwd=tmp_path, config_home=home).load()
cfg = ConfigLoader(cwd=tmp_path, config_home=home).load(trust_project_mcp=True)
servers = cfg.mcp_servers()
assert set(servers.keys()) == {"gamma"}
+484
View File
@@ -0,0 +1,484 @@
"""Rust/Python parity tests.
Each test feeds the EXACT inputs from a Rust crate test and asserts the Python
implementation produces the SAME output. Every test is tagged with the Rust
source file and test name it mirrors.
"""
from __future__ import annotations
import hashlib
import json
import tempfile
from pathlib import Path
# ── 1. SSE Parser ────────────────────────────────────────────────────────────
from hanzoai._streaming import SSEDecoder, ServerSentEvent
def _sse_events(raw: bytes) -> list[ServerSentEvent]:
"""Feed raw bytes through SSEDecoder and collect non-None events."""
decoder = SSEDecoder()
return list(decoder.iter_bytes(iter([raw])))
def _sse_events_chunked(chunks: list[bytes]) -> list[ServerSentEvent]:
decoder = SSEDecoder()
return list(decoder.iter_bytes(iter(chunks)))
# Parity: hanzo-dev/sse/src/lib.rs:parses_single_frame
def test_sse_single_frame():
raw = (
b"event: content_block_start\n"
b"data: {\"type\":\"content_block_start\",\"index\":0}\n\n"
)
events = _sse_events(raw)
assert len(events) == 1
assert events[0].event == "content_block_start"
assert events[0].data == '{"type":"content_block_start","index":0}'
# Parity: hanzo-dev/sse/src/lib.rs:parses_chunked_stream
def test_sse_chunked_stream():
first = b"event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"text\":\"Hel"
second = b"lo\"}\n\n"
events = _sse_events_chunked([first, second])
assert len(events) == 1
assert events[0].event == "content_block_delta"
assert events[0].data == '{"type":"content_block_delta","text":"Hello"}'
# Parity: hanzo-dev/sse/src/lib.rs:ignores_ping_and_done
def test_sse_ping_and_done_filtering():
raw = (
b": keepalive\n"
b"event: ping\n"
b"data: {\"type\":\"ping\"}\n\n"
b"event: message_delta\n"
b"data: {\"type\":\"message_delta\",\"stop_reason\":\"end_turn\"}\n\n"
b"event: message_stop\n"
b"data: {\"type\":\"message_stop\"}\n\n"
b"data: [DONE]\n\n"
)
events = _sse_events(raw)
assert len(events) == 2
assert events[0].event == "message_delta"
assert events[1].event == "message_stop"
# Parity: hanzo-dev/sse/src/lib.rs:handles_crlf_separator
def test_sse_crlf_separator():
raw = b"event: test\r\ndata: {\"ok\":true}\r\n\r\n"
events = _sse_events(raw)
assert len(events) == 1
assert events[0].data == '{"ok":true}'
# Parity: hanzo-dev/sse/src/lib.rs:handles_bare_cr_cr_separator
def test_sse_bare_cr_cr_separator():
raw = b"event: content_block_delta\ndata: {\"type\":\"text\"}\r\r"
events = _sse_events(raw)
assert len(events) == 1
assert events[0].event == "content_block_delta"
assert events[0].data == '{"type":"text"}'
# Parity: hanzo-dev/sse/src/lib.rs:parses_split_json_across_data_lines
def test_sse_split_json_across_data_lines():
raw = (
b"event: content_block_delta\n"
b"data: {\"type\":\"content_block_delta\",\"index\":0,\n"
b"data: \"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}\n\n"
)
events = _sse_events(raw)
assert len(events) == 1
assert events[0].event == "content_block_delta"
assert events[0].data == (
'{"type":"content_block_delta","index":0,\n'
'"delta":{"type":"text_delta","text":"Hello"}}'
)
# Parity: hanzo-dev/sse/src/lib.rs:done_marker_returns_none
def test_sse_done_marker_filtered():
raw = b"data: [DONE]\n\n"
events = _sse_events(raw)
assert len(events) == 0
# Parity: hanzo-dev/sse/src/lib.rs:skips_comment_only_frame
def test_sse_comment_only():
raw = b": this is a comment\n\n"
events = _sse_events(raw)
assert len(events) == 0
# ── 2. MCP Name Normalization ────────────────────────────────────────────────
from hanzoai.mcp import normalize_mcp_name, mcp_tool_name
# Parity: claw-code/rust/crates/runtime/src/mcp.rs:normalizes_server_names_for_mcp_tooling
def test_mcp_normalize_dot_name():
assert normalize_mcp_name("github.com") == "github_com"
def test_mcp_normalize_exclamation():
assert normalize_mcp_name("tool name!") == "tool_name_"
def test_mcp_normalize_claudeai_prefix():
assert normalize_mcp_name("claude.ai Example Server!!") == "claude_ai_Example_Server"
def test_mcp_tool_name_full():
assert mcp_tool_name("claude.ai Example Server", "weather tool") == (
"mcp__claude_ai_Example_Server__weather_tool"
)
def test_mcp_hyphens_kept():
assert normalize_mcp_name("my-tool") == "my-tool"
# ── 3. Permission Policy ─────────────────────────────────────────────────────
from hanzoai.protocols import (
PermissionMode,
PermissionOutcome,
PermissionPolicy,
PermissionRequest,
)
# Parity: claw-code/rust/crates/runtime/src/permissions.rs:allows_tools_when_active_mode_meets_requirement
def test_permission_allow_mode():
policy = PermissionPolicy(default_mode=PermissionMode.Allow)
outcome = policy.authorize("bash", "{}")
assert outcome.allowed is True
# Parity: claw-code/rust/crates/runtime/src/permissions.rs:denies_read_only_escalations_without_prompt
def test_permission_deny_mode():
policy = PermissionPolicy(default_mode=PermissionMode.Deny)
outcome = policy.authorize("bash", "{}")
assert outcome.allowed is False
assert "denied by policy" in outcome.reason
# Parity: claw-code/rust/crates/runtime/src/permissions.rs (prompt without prompter)
def test_permission_prompt_without_prompter():
policy = PermissionPolicy(default_mode=PermissionMode.Prompt)
outcome = policy.authorize("bash", "{}")
assert outcome.allowed is False
assert "no prompter" in outcome.reason
# Parity: claw-code/rust/crates/runtime/src/permissions.rs (tool override)
def test_permission_tool_override():
policy = (
PermissionPolicy(default_mode=PermissionMode.Deny)
.with_tool_mode("bash", PermissionMode.Allow)
)
assert policy.authorize("bash", "{}").allowed is True
assert policy.authorize("other", "{}").allowed is False
# ── 4. Session Compaction ────────────────────────────────────────────────────
from hanzoai.session import (
CompactionConfig,
ConversationMessage,
MessageRole,
Session,
TextBlock,
ToolResultBlock,
compact_session,
format_compact_summary,
)
# Parity: claw-code/rust/crates/runtime/src/compact.rs:leaves_small_sessions_unchanged
def test_compact_small_session_unchanged():
session = Session(messages=[ConversationMessage.user_text("hello")])
result = compact_session(session, CompactionConfig())
assert result.removed_message_count == 0
assert result.compacted_session is session
assert result.summary == ""
# Parity: claw-code/rust/crates/runtime/src/compact.rs:compacts_older_messages_into_a_system_summary
def test_compact_large_session():
session = Session(messages=[
ConversationMessage.user_text("one " * 200),
ConversationMessage.assistant([TextBlock(text="two " * 200)]),
ConversationMessage.tool_result("1", "bash", "ok " * 200, False),
ConversationMessage(
role=MessageRole.Assistant,
blocks=[TextBlock(text="recent")],
),
])
config = CompactionConfig(preserve_recent_messages=2, max_estimated_tokens=1)
result = compact_session(session, config)
assert result.removed_message_count == 2
assert result.compacted_session.messages[0].role == MessageRole.System
system_text = result.compacted_session.messages[0].blocks[0].text
assert "Summary:" in system_text or "Conversation summary:" in system_text
assert "Scope:" in result.formatted_summary
assert "Key timeline:" in result.formatted_summary
# Parity: claw-code/rust/crates/runtime/src/compact.rs:compacts_older_messages (continuation msg)
def test_compact_continuation_message():
session = Session(messages=[
ConversationMessage.user_text("one " * 200),
ConversationMessage.assistant([TextBlock(text="two " * 200)]),
ConversationMessage.tool_result("1", "bash", "ok " * 200, False),
ConversationMessage(
role=MessageRole.Assistant,
blocks=[TextBlock(text="recent")],
),
])
config = CompactionConfig(preserve_recent_messages=2, max_estimated_tokens=1)
result = compact_session(session, config)
system_text = result.compacted_session.messages[0].blocks[0].text
assert "This session is being continued" in system_text
# Parity: claw-code/rust/crates/runtime/src/compact.rs:formats_compact_summary_like_upstream
def test_format_compact_summary():
summary = "<analysis>scratch</analysis>\n<summary>Kept work</summary>"
assert format_compact_summary(summary) == "Summary:\nKept work"
# ── 5. Token Usage ───────────────────────────────────────────────────────────
from hanzoai.protocols import TokenUsage, UsageTracker
# Parity: claw-code/rust/crates/runtime/src/usage.rs:tracks_true_cumulative_usage
def test_usage_tracker_cumulative():
tracker = UsageTracker()
tracker.record(TokenUsage(
input_tokens=10, output_tokens=4,
cache_creation_input_tokens=2, cache_read_input_tokens=1,
))
tracker.record(TokenUsage(
input_tokens=20, output_tokens=6,
cache_creation_input_tokens=3, cache_read_input_tokens=2,
))
assert tracker.turns == 2
cum = tracker.cumulative_usage()
assert cum.input_tokens == 30
assert cum.output_tokens == 10
assert cum.total_tokens() == 48
# Parity: claw-code/rust/crates/runtime/src/usage.rs:total_tokens formula
def test_token_usage_total():
usage = TokenUsage(
input_tokens=10, output_tokens=4,
cache_creation_input_tokens=2, cache_read_input_tokens=1,
)
assert usage.total_tokens() == 10 + 4 + 2 + 1
# ── 6. OAuth PKCE ────────────────────────────────────────────────────────────
from hanzoai.auth import _base64url_encode
# Parity: claw-code/rust/crates/runtime/src/oauth.rs:s256_challenge_matches_expected_vector
def test_pkce_s256_rfc_vector():
verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
digest = hashlib.sha256(verifier.encode("ascii")).digest()
challenge = _base64url_encode(digest)
assert challenge == "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
# ── 7. Config Loader ─────────────────────────────────────────────────────────
from hanzoai.config import (
ConfigEntry,
ConfigLoader,
ConfigSource,
McpStdioConfig,
McpRemoteConfig,
McpWsConfig,
McpSdkConfig,
McpClaudeAiProxyConfig,
McpTransport,
_parse_mcp_server,
_sanitize_project_config,
)
# Parity: hierarchical merge — local overrides user
def test_config_hierarchical_merge():
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
user_dir = root / "user_config"
user_dir.mkdir()
(user_dir / "settings.json").write_text('{"model": "sonnet"}')
project_dir = root / "project"
project_dir.mkdir()
(project_dir / ".hanzo").mkdir()
(project_dir / ".hanzo" / "settings.json").write_text('{"model": "opus"}')
loader = ConfigLoader(cwd=project_dir, config_home=user_dir)
cfg = loader.load()
assert cfg.get("model") == "opus"
# Parity: MCP server parsing — all 6 transport types
def test_config_mcp_stdio():
cfg = _parse_mcp_server("test", {"transport": "stdio", "command": "uvx", "args": ["srv"]})
assert isinstance(cfg, McpStdioConfig)
assert cfg.command == "uvx"
def test_config_mcp_http():
cfg = _parse_mcp_server("test", {"transport": "http", "url": "https://x.com/mcp"})
assert isinstance(cfg, McpRemoteConfig)
assert cfg.transport == McpTransport.Http
def test_config_mcp_sse():
cfg = _parse_mcp_server("test", {"transport": "sse", "url": "https://x.com/mcp"})
assert isinstance(cfg, McpRemoteConfig)
assert cfg.transport == McpTransport.Sse
def test_config_mcp_ws():
cfg = _parse_mcp_server("test", {"transport": "ws", "url": "wss://x.com/mcp"})
assert isinstance(cfg, McpWsConfig)
def test_config_mcp_sdk():
cfg = _parse_mcp_server("test", {"transport": "sdk", "name": "builtin"})
assert isinstance(cfg, McpSdkConfig)
assert cfg.name == "builtin"
def test_config_mcp_claudeai_proxy():
cfg = _parse_mcp_server("test", {"transport": "claudeai-proxy", "url": "https://p.ai", "id": "abc"})
assert isinstance(cfg, McpClaudeAiProxyConfig)
# Parity: project config sanitization blocks stdio mcpServers
def test_config_sanitize_project_blocks_stdio():
entry = ConfigEntry(source=ConfigSource.Project, path=Path("/dev/null"))
data = {
"model": "opus",
"mcpServers": {
"evil": {"transport": "stdio", "command": "rm -rf /"},
"safe": {"transport": "http", "url": "https://safe.com"},
},
}
sanitized = _sanitize_project_config(data, entry)
assert "evil" not in sanitized.get("mcpServers", {})
assert "safe" in sanitized.get("mcpServers", {})
assert sanitized["model"] == "opus"
# ── 8. Backoff ────────────────────────────────────────────────────────────────
# Parity: hanzo-dev/backoff/src/lib.rs:backoff_doubles_until_maximum
def test_backoff_doubles_until_max():
"""Pure-math parity: 10ms base, 25ms max."""
initial_ms = 10
max_ms = 25
def delay(attempt: int) -> int:
multiplier = 1 << (attempt - 1)
return min(initial_ms * multiplier, max_ms)
assert delay(1) == 10
assert delay(2) == 20
assert delay(3) == 25 # capped
# Parity: hanzo-dev/backoff/src/lib.rs:retryable_statuses
def test_retryable_statuses():
retryable = {408, 409, 429, 500, 502, 503, 504}
for code in retryable:
assert code in retryable
for code in [200, 401, 404]:
assert code not in retryable
# ── 9. Session Roundtrip ─────────────────────────────────────────────────────
from hanzoai.session import ToolUseBlock
# Parity: claw-code/rust/crates/runtime/src/session.rs:persists_and_restores_session_json
def test_session_json_roundtrip():
session = Session()
session.messages.append(ConversationMessage.user_text("hello"))
session.messages.append(ConversationMessage.assistant_with_usage(
[
TextBlock(text="thinking"),
ToolUseBlock(id="tool-1", name="bash", input="echo hi"),
],
TokenUsage(input_tokens=10, output_tokens=4,
cache_creation_input_tokens=1, cache_read_input_tokens=2),
))
session.messages.append(ConversationMessage.tool_result("tool-1", "bash", "hi", False))
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
path = Path(f.name)
try:
session.save(path)
restored = Session.load(path)
assert restored.version == session.version
assert len(restored.messages) == 3
assert restored.messages[0].role == MessageRole.User
assert restored.messages[2].role == MessageRole.Tool
assert restored.messages[1].usage is not None
assert restored.messages[1].usage.total_tokens() == 17
# Verify JSON structure has expected keys
data = json.loads(path.read_text())
assert "version" in data
assert "messages" in data
msg1 = data["messages"][1]
assert msg1["role"] == "assistant"
assert msg1["blocks"][1]["type"] == "tool_use"
assert "usage" in msg1
finally:
path.unlink()
# ── 10. Hook Runner ──────────────────────────────────────────────────────────
from hanzo_hooks.types import HookConfig
from hanzo_hooks.runner import HookRunner
# Parity: claw-code/rust/crates/runtime/src/hooks.rs:allows_exit_code_zero_and_captures_stdout
def test_hook_exit_zero_allows():
runner = HookRunner(HookConfig(pre_tool_use=["printf 'pre ok'"]))
result = runner.run_pre_tool_use("Read", '{"path":"README.md"}')
assert result.denied is False
assert result.messages == ["pre ok"]
# Parity: claw-code/rust/crates/runtime/src/hooks.rs:denies_exit_code_two
def test_hook_exit_two_denies():
runner = HookRunner(HookConfig(pre_tool_use=["printf 'blocked by hook'; exit 2"]))
result = runner.run_pre_tool_use("Bash", '{"command":"pwd"}')
assert result.denied is True
assert result.messages == ["blocked by hook"]
# Parity: claw-code/rust/crates/runtime/src/hooks.rs:warns_for_other_non_zero_statuses
def test_hook_exit_one_warns():
runner = HookRunner(HookConfig(pre_tool_use=["printf 'warning hook'; exit 1"]))
result = runner.run_pre_tool_use("Edit", '{"file":"src/lib.rs"}')
assert result.denied is False
assert any("allowing tool execution to continue" in m for m in result.messages)
+3 -3
View File
@@ -41,7 +41,7 @@ class TestContentBlocks:
assert TextBlock.from_dict(d) == b
def test_tool_use_block_roundtrip(self):
b = ToolUseBlock(id="t1", name="bash", input={"command": "ls"})
b = ToolUseBlock(id="t1", name="bash", input='{"command":"ls"}')
d = b.to_dict()
assert d["type"] == "tool_use"
assert ToolUseBlock.from_dict(d) == b
@@ -100,7 +100,7 @@ class TestSession:
ConversationMessage.assistant_with_usage(
[
TextBlock(text="thinking"),
ToolUseBlock(id="t1", name="bash", input={"cmd": "echo hi"}),
ToolUseBlock(id="t1", name="bash", input='{"cmd":"echo hi"}'),
],
TokenUsage(input_tokens=10, output_tokens=4, cache_creation_input_tokens=1, cache_read_input_tokens=2),
)
@@ -169,7 +169,7 @@ class TestCompaction:
s.messages.append(ConversationMessage.user_text("x " * 200))
s.messages.append(
ConversationMessage.assistant(
[TextBlock(text="ok"), ToolUseBlock(id="t1", name="bash", input={"cmd": "ls"})]
[TextBlock(text="ok"), ToolUseBlock(id="t1", name="bash", input='{"cmd":"ls"}')]
)
)
s.messages.append(ConversationMessage.tool_result("t1", "bash", "output " * 50, False))
+5 -5
View File
@@ -62,12 +62,12 @@ class TestImports:
assert hanzo_agents is not None
@pytest.mark.skip(reason="hanzo_repl may not be installed in test environment")
def test_hanzo_repl_import(self):
"""Test hanzo_repl package import."""
import hanzo_repl
@pytest.mark.skip(reason="hanzo_dev may not be installed in test environment")
def test_hanzo_dev_import(self):
"""Test hanzo_dev package import."""
import hanzo_dev
assert hanzo_repl is not None
assert hanzo_dev is not None
class TestBasicClient: